detached 2df03cd

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 UnverifiedB5690EEEBB952194 The 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`).
2222 | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it |
2323 | `background` | `#111111` | Solid background color |
2424 | `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 |
2629 | `border.active` / `border.inactive` | `#808080` / `#111111` | Separator colors around the focused / other windows |
2730 | `layout.gaps` | `0` | Space between windows |
2831 | `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
6770 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.
6871
6972 - 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.
7174 - Screenshots and capture of HDR outputs are converted to 8-bit sRGB, clipping highlights at SDR white (only the capture is clipped).
7275 - The display gets the metadata from its own EDID, not the client's MaxCLL/MaxFALL.
7376
@@ -90,8 +93,8 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
9093 | `spawn-terminal` | Run `terminal` |
9194 | `close-window` | Close the focused window |
9295 | `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 |
9598 | `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 |
9699 | `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 |
97100 | `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
107110 | `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`) |
108111 | `quit` | Exit NeferWL |
109112
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+
110121 ### Default binds
111122
112123 | Keys | Action |
@@ -129,8 +140,8 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
129140 | `cmd+r` | `cycle-column-width` |
130141 | `cmd+f` | `maximize-column` |
131142 | `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` |
134145 | `cmd+q` | `close-window` |
135146 | `ctrl+alt+backspace` | `quit` |
136147 | `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 binds121 ### 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"
2222 ```
2323
2424 ```text
25-bind.cmd+shift+s = spawn screenshot-area
25+bind.cmd+shift+p = spawn screenshot-area
2626 ```
2727
2828 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
9696
9797 - every output, with its active numbered workspace, workspace count and the name of the workspace on screen;
9898 - 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.
100101
101102 ```sh
102103 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
104105 ```
105106
106107 `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 ```text24 ```text
25-bind.cmd+shift+s = spawn screenshot-area25+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 ```sh102 ```sh
102 neferwl state # the whole state as JSON103 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
118118 bind.cmd+r = cycle-column-width
119119 bind.cmd+f = maximize-column
120120 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
123123 bind.cmd+q = close-window
124124 bind.ctrl+alt+backspace = quit
125125 bind.ctrl+cmd+space = spawn fuzzel
@@ -118,8 +118,8 @@ bind.cmd+bracketright = consume-or-expel-window-right
118 bind.cmd+r = cycle-column-width118 bind.cmd+r = cycle-column-width
119 bind.cmd+f = maximize-column119 bind.cmd+f = maximize-column
120 bind.cmd+shift+f = toggle-fullscreen120 bind.cmd+shift+f = toggle-fullscreen
121-bind.cmd+v = toggle-window-floating121+bind.cmd+s = toggle-window-stash
122-bind.cmd+shift+v = toggle-floating-visible122+bind.cmd+shift+s = toggle-stash-visible
123 bind.cmd+q = close-window123 bind.cmd+q = close-window
124 bind.ctrl+alt+backspace = quit124 bind.ctrl+alt+backspace = quit
125 bind.ctrl+cmd+space = spawn fuzzel125 bind.ctrl+cmd+space = spawn fuzzel
M internal/adapters/config/config.go +24 -3
@@ -32,8 +32,8 @@ var defaultBinds = []struct{ combo, action string }{
3232 {"cmd+r", "cycle-column-width"},
3333 {"cmd+f", "maximize-column"},
3434 {"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"},
3737 {"cmd+q", "close-window"},
3838 {"ctrl+alt+backspace", "quit"},
3939 {"ctrl+cmd+space", "spawn fuzzel"},
@@ -46,7 +46,7 @@ var defaultBinds = []struct{ combo, action string }{
4646 {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"},
4747 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},
4848 }
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}
5050 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
5151 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
5252
@@ -90,6 +90,9 @@ func Defaults() ports.Config {
9090 c.Xwayland = "xwayland-satellite"
9191 c.Background.Color = "#111111"
9292 c.Floating.Dim = 0.3
93+ c.Stash.Width = 80
94+ c.Stash.Gap = 2
95+ c.Stash.Dim = 0.5
9396 c.Border.Width = 2
9497 c.Border.Active = "#808080"
9598 c.Border.Inactive = "#111111"
@@ -459,6 +462,24 @@ func set(c *ports.Config, key, v string) error {
459462 return fmt.Errorf("must be between 0 and 1")
460463 }
461464 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
462483 case "border.width":
463484 n, err := strconv.Atoi(v)
464485 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.392 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 = 296 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 = dim464 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) {
2525 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 {
2626 t.Fatalf("defaults: %+v", d)
2727 }
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 {
2929 t.Fatal(d.Binds)
3030 }
3131 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {
@@ -402,6 +402,32 @@ func TestTerminalResolutionAndAutoOpen(t *testing.T) {
402402 }
403403 }
404404
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+
405431 func TestFloatingDim(t *testing.T) {
406432 for _, tc := range []struct {
407433 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 string433 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
7474 }
7575 c := surfaces[w.ID]
7676 // 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() {
7980 pick = w
8081 continue
8182 }
@@ -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 show76 // 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 = w80 pick = w
80 continue81 continue
81 }82 }
M internal/adapters/drm/overlay_test.go +6 -0
@@ -78,6 +78,12 @@ func TestOverlayCandidate(t *testing.T) {
7878 if _, _, reason := overlayCandidate(s, c); reason != "dim" {
7979 t.Fatalf("dim reason %q", reason)
8080 }
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+ }
8187 // A viewport crop is composed: the plane would show the whole buffer.
8288 s, c = overlayScene()
8389 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
7878 if full == nil {
7979 return ports.SurfaceContent{}, "no_fullscreen"
8080 }
81+ if full.Dim > 0 {
82+ // Its veil is composed over it: the plane would show it bright.
83+ return ports.SurfaceContent{}, "dim"
84+ }
8185 // Core leaves the layers a fullscreen window hides out of the scene:
8286 // one above the windows here is drawn.
8387 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) {
2525 s.Windows = append(s.Windows, ports.SceneWindow{ID: 2, Hidden: true})
2626 }, nil, ""},
2727 {"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"},
2829 {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"},
2930 {"float above", func(s *ports.Scene) {
3031 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 {
5656 Output string `json:"output"`
5757 Workspace int `json:"workspace"`
5858 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"`
5967 }
6068
6169 func fromPorts(st ports.State) State {
@@ -74,7 +82,7 @@ func fromPorts(st ports.State) State {
7482 }
7583
7684 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}
7886 }
7987
8088 // 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 removes88 // 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
22
33 import (
44 "context"
5+ "encoding/json"
56 "errors"
67 "os"
78 "path/filepath"
89 "reflect"
10+ "strings"
911 "testing"
1012 "time"
1113
@@ -61,6 +63,23 @@ func TestRunWritesAndRemoves(t *testing.T) {
6163 }
6264 }
6365
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+
6483 func TestPath(t *testing.T) {
6584 if p, err := Path("/run/user/1", "/run/user/1/wayland-1"); err != nil || p != "/run/user/1/neferwl/wayland-1.json" {
6685 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 {
303303 // leaves the destination at (1-alpha) of its previous value.
304304 func (r *Renderer) dimDraw(rect image.Rectangle, alpha float64) draw {
305305 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))
307307 return dr
308308 }
309309
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+
310323 // contentDraw draws the visible rect of a w×h buffer mapped onto full
311324 // (target pixels) through the inverse of its buffer transform t. exact: the
312325 // 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 dr307 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 full323 // 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: the324 // (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) {
5353 w.draws = append(w.draws, w.r.fillDraw(rect, c))
5454 }
5555
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+ }
6063 }
6164
6265 // layers draws the layer surfaces below the windows, or above them.
@@ -85,7 +88,7 @@ func (w *sceneWalk) windows() {
8588 w.separators(0)
8689 tileLines = true
8790 if w.s.Dim > 0 {
88- w.dim()
91+ w.dim(w.bounds, w.s.Dim)
8992 }
9093 }
9194 // Content sits inside the border; core sized the client to match.
@@ -105,6 +108,10 @@ func (w *sceneWalk) windows() {
105108 if win.Floating {
106109 w.separators(win.ID)
107110 }
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+ }
108115 }
109116 if !tileLines {
110117 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 clipping56+// 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 = true89 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
33 import (
44 "image"
55 "image/color"
6+ "math"
67 "testing"
78
89 "github.com/bnema/neferwl/internal/ports"
@@ -63,6 +64,47 @@ func TestSceneDimOrder(t *testing.T) {
6364 }
6465 }
6566
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+
66108 func TestSceneDimPartialDamage(t *testing.T) {
67109 r := &Renderer{width: 80, height: 60}
68110 s := ports.Scene{Seq: 8, Dim: 0.5, Windows: []ports.SceneWindow{
@@ -148,3 +190,52 @@ func TestRendererDimBlendAndDamage(t *testing.T) {
148190 t.Fatalf("damaged tile beneath veil = %v, want dark red", got)
149191 }
150192 }
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 (
1010 type Action string
1111
1212 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"
2727 // Workspaces stack vertically; up/down stop at the ends.
2828 ActionFocusWorkspaceUp Action = "focus-workspace-up"
2929 ActionFocusWorkspaceDown Action = "focus-workspace-down"
@@ -154,9 +154,11 @@ func (c *Core) applyAction(a Action) Effect {
154154 } else {
155155 dir = 1
156156 }
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())
160162 if i := c.neighbor(dir); edge && i >= 0 {
161163 c.focusScreen = i
162164 return Effect{}
@@ -245,11 +247,11 @@ func (m *Monitor) Apply(a Action) Effect {
245247 case ActionToggleFullscreen:
246248 m.ToggleFullscreen()
247249 return Effect{}
248- case ActionToggleWindowFloating:
249- m.Current().ToggleWindowFloating()
250+ case ActionToggleWindowStash:
251+ m.Current().ToggleWindowStash()
250252 return Effect{}
251- case ActionToggleFloatingVisible:
252- m.Current().ToggleFloatingVisible()
253+ case ActionToggleStashVisible:
254+ m.Current().ToggleStashVisible()
253255 return Effect{}
254256 case ActionMaximizeColumn:
255257 // A client that made itself fullscreen (Wine at monitor size) goes
@@ -309,10 +311,10 @@ func (w *Workspace) Apply(a Action) Effect {
309311 // In place only: binds go through Monitor.Apply, which gives a
310312 // fixed-overflow fullscreen its own workspace.
311313 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()
316318 case ActionCloseWindow:
317319 id, _ := w.Focused()
318320 return Effect{Close: id}
@@ -10,20 +10,20 @@ import (
10 type Action string10 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 = 1155 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 = i163 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) goes257 // 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 a311 // 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 {
200200 continue
201201 }
202202 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:
204204 default:
205205 return fmt.Errorf("invalid action %q", a)
206206 }
@@ -470,7 +470,11 @@ func (c *Core) publish(ctx context.Context) error {
470470 alive[p.ID] = true
471471 // Only the focused output has an activated window.
472472 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)
474478 floating := p.Floating
475479 if p.Hidden {
476480 // 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) {
668672 full := false
669673 for _, p := range sc.mon.Layout() {
670674 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+ }
672685 // Floating windows come last in the layout and are on top,
673686 // even of a fullscreen window; nothing else is.
674687 if full && !p.Floating {
@@ -945,7 +958,7 @@ func (c *Core) Run(ctx context.Context) error {
945958 // A click focuses the window and its output.
946959 s, w := c.screenOf(id)
947960 if v.Pressed && s != nil && w == s.mon.Current() && (c.focus != id || s != c.cur()) {
948- w.FocusID(id)
961+ w.Click(id)
949962 c.focusScreen = c.screenIndex(s.name())
950963 if err := c.publish(ctx); err != nil {
951964 return nil
@@ -200,7 +200,7 @@ func (c *Core) apply(cfg ports.Config) error {
200 continue200 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] = true470 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.focusScreen472 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.Floating478 floating := p.Floating
475 if p.Hidden {479 if p.Hidden {
476 // A hidden window keeps its size, state and output; it is480 // 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 := false672 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 nil964 return nil
M internal/core/expel.go +2 -2
@@ -20,7 +20,7 @@ func (w *Workspace) full() bool {
2020 // returns false when the window must leave for the neighbor monitor: a full
2121 // workspace with the window stacked in the edge column.
2222 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) {
2424 return true
2525 }
2626 // A fullscreen window hides the others (fixed) or its column (scroll):
@@ -82,7 +82,7 @@ func (w *Workspace) dropSlotOf(id WindowID) {
8282 // workspace; its slot, if any, is released.
8383 func (w *Workspace) takeWindow() (WindowID, bool) {
8484 id, ok := w.Focused()
85- if !ok || w.floatFocus {
85+ if !ok || w.onFloat() {
8686 return 0, false
8787 }
8888 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 full20 // 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 true24 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, false86 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 (
88 "github.com/bnema/neferwl/internal/ports"
99 )
1010
11-func TestToggleWindowFloating(t *testing.T) {
11+func TestToggleWindowStash(t *testing.T) {
1212 for _, stacked := range []bool{false, true} {
1313 t.Run(map[bool]string{false: "column", true: "stack"}[stacked], func(t *testing.T) {
1414 w := workspace()
@@ -20,59 +20,169 @@ func TestToggleWindowFloating(t *testing.T) {
2020 w.Columns[1].Windows = []WindowID{3, 2}
2121 w.Columns[1].Focus = 1
2222 }
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)
2626 }
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}) {
2929 t.Fatalf("placement %+v", p)
3030 }
3131 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")
3434 }
35- w.Apply(ActionToggleWindowFloating)
35+ w.Apply(ActionToggleWindowStash)
3636 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) {
3838 t.Fatalf("round trip: %+v", w)
3939 }
4040 })
4141 }
4242 w := workspace()
4343 w.AddFloating(4, 20, 10)
44- w.Apply(ActionToggleWindowFloating)
44+ w.Apply(ActionToggleWindowStash)
4545 if len(w.Floats) != 0 || len(w.Columns) != 1 || w.Columns[0].Windows[0] != 4 {
4646 t.Fatalf("native float: %+v", w)
4747 }
4848 }
4949
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) {
5164 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)
53122 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)
59132 }
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 {
62135 t.Fatalf("hidden %+v", p)
63136 }
64137 }
65138 if w.FocusID(2) {
66- t.Fatal("focused hidden float")
139+ t.Fatal("focused hidden stash window")
67140 }
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 {
70144 t.Fatalf("restored focus %d", id)
71145 }
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)
76186 }
77187 }
78188
@@ -90,7 +200,7 @@ func TestExclusiveFullscreenNewWindow(t *testing.T) {
90200 t.Fatalf("overflow %v: %+v", overflow, p)
91201 }
92202 }
93- if w.ToggleFloatingVisible(); w.floatsHidden {
203+ if w.ToggleStashVisible(); w.stashHidden {
94204 t.Fatal("floats toggled under fullscreen")
95205 }
96206 w.SetFullscreen(1, false)
@@ -143,37 +253,37 @@ func TestFullscreenPinsFocus(t *testing.T) {
143253 }
144254
145255 // A fullscreen window does not float until it leaves fullscreen.
146-func TestToggleWindowFloatingIgnoresFullscreen(t *testing.T) {
256+func TestToggleWindowStashIgnoresFullscreen(t *testing.T) {
147257 w := workspace()
148258 w.AddWindow(1)
149259 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 {
152262 t.Fatalf("fullscreen window floated: %+v", w)
153263 }
154264 w.SetFullscreen(1, false)
155- w.Apply(ActionToggleWindowFloating)
156- if len(w.Floats) != 1 {
265+ w.Apply(ActionToggleWindowStash)
266+ if len(w.Stash) != 1 {
157267 t.Fatal("window did not float after leaving fullscreen")
158268 }
159269 }
160270
161271 // A lone maximized column comes back maximized.
162-func TestToggleWindowFloatingKeepsFullWidth(t *testing.T) {
272+func TestToggleWindowStashKeepsFullWidth(t *testing.T) {
163273 w := workspace()
164274 w.Overflow = OverflowFixed
165275 w.AddWindow(1)
166276 w.AddWindow(2)
167277 w.Apply(ActionMaximizeColumn)
168- w.Apply(ActionToggleWindowFloating)
169- w.Apply(ActionToggleWindowFloating)
278+ w.Apply(ActionToggleWindowStash)
279+ w.Apply(ActionToggleWindowStash)
170280 if id, _ := w.Focused(); id != 2 || !w.Columns[w.Focus].FullWidth {
171281 t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus])
172282 }
173283 }
174284
175285 // A lone expanded column (fixed overflow) comes back expanded.
176-func TestToggleWindowFloatingKeepsExpanded(t *testing.T) {
286+func TestToggleWindowStashKeepsExpanded(t *testing.T) {
177287 w := workspace()
178288 w.Overflow = OverflowFixed
179289 w.SetMaxColumns(3)
@@ -183,64 +293,46 @@ func TestToggleWindowFloatingKeepsExpanded(t *testing.T) {
183293 if !w.Columns[w.Focus].Expanded {
184294 t.Fatal("column not expanded")
185295 }
186- w.Apply(ActionToggleWindowFloating)
187- w.Apply(ActionToggleWindowFloating)
296+ w.Apply(ActionToggleWindowStash)
297+ w.Apply(ActionToggleWindowStash)
188298 if id, _ := w.Focused(); id != 2 || !w.Columns[w.Focus].Expanded {
189299 t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus])
190300 }
191301 }
192302
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).
195306 func TestHiddenFloatFullscreenRequest(t *testing.T) {
196307 m := newMonitor("", "")
197308 m.SetOutput(100, 80)
198309 m.SetOverflow(OverflowFixed)
199310 m.AddWindow(1)
200- m.AddFloating(2, 20, 10)
311+ m.AddWindow(2)
201312 w := m.Current()
202- w.ToggleFloatingVisible()
313+ w.ToggleWindowStash()
314+ w.ToggleStashVisible()
203315 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)
206318 }
207- w.ToggleFloatingVisible()
319+ w.ToggleStashVisible()
208320 if w.fullscreen != 2 {
209321 t.Fatalf("fullscreen lost on show: %d", w.fullscreen)
210322 }
211323 }
212324
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) {
215328 w := workspace()
216- w.Overflow = OverflowFixed
217329 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()
235332 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)
244336 }
245337 }
246338
@@ -270,7 +362,7 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) {
270362 }
271363 }
272364 check(0)
273- w.ToggleWindowFloating()
365+ w.ToggleWindowStash()
274366 check(0.3)
275367 found := false
276368 for len(commands) > 0 {
@@ -300,14 +392,18 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) {
300392 if !found {
301393 t.Fatal("missing native configure")
302394 }
303- w.ToggleFloatingVisible()
395+ // Hiding the stash leaves the dialog, and its veil.
396+ w.ToggleStashVisible()
397+ check(0.3)
398+ w.RemoveWindow(2)
304399 check(0)
305- w.ToggleFloatingVisible()
400+ w.ToggleStashVisible()
306401 check(0.3)
402+ w.AddFloating(2, 20, 10)
307403 w.SetFullscreen(1, true)
308404 check(0)
309405 w.SetFullscreen(1, false)
310- w.ToggleWindowFloating()
406+ w.ToggleWindowStash()
311407 w.SetFullscreen(1, true)
312408 check(0)
313409 // A dialog of a fullscreen tile waits hidden: no veil over the game.
@@ -323,26 +419,27 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) {
323419 check(0.3)
324420 }
325421
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
327423 // fullscreen window it hid behind leaves fullscreen.
328424 func TestActivateHiddenFloat(t *testing.T) {
329425 w := workspace()
330426 w.AddWindow(1)
331- w.AddFloating(2, 20, 10)
332- w.ToggleFloatingVisible()
427+ w.AddWindow(2)
428+ w.ToggleWindowStash()
429+ w.ToggleStashVisible()
333430 w.FocusID(1)
334431 w.SetFullscreen(1, true)
335432 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)
338435 }
339436 // The float's own fullscreen, asked while hidden, comes back with it.
340437 w.FocusID(2)
341- w.ToggleFloatingVisible()
438+ w.ToggleStashVisible()
342439 w.SetFullscreen(2, true)
343440 w.SetFullscreen(1, true)
344441 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)
347444 }
348445 }
@@ -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 = 121 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 = OverflowFixed274 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 = OverflowFixed288 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 its303+// 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 := false367 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; the422+// 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 {
9797 Fullscreen, Focused, Hidden bool
9898 // Floating windows sit over the columns at their own size.
9999 Floating bool
100+ // Peek is a stashed window peeking in beside the selected one: dimmed.
101+ Peek bool
100102 // Neighbors are the sides touching another tiled window. Inset are the
101103 // sides where the border takes room from the window: all sides of a
102104 // float; for tiles, the right and bottom neighbors only, so two
@@ -134,13 +136,23 @@ type Workspace struct {
134136 border int
135137 presets []Width
136138 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.
144156 hiddenFullscreen WindowID
145157 // home is the monitor (key or connector) the workspace belongs to; ""
146158 // means the one it is on. On another monitor it is a guest; homePos is
@@ -171,24 +183,32 @@ type origPlace struct {
171183 // fullWidth is the column's maximize-column state.
172184 fullWidth bool
173185 float *Float
186+ // stash is set when float came from the stash, at index col.
187+ stash bool
174188 }
175189
176190 // Float is a floating window and its client size, logical.
177191 type Float struct {
178192 ID WindowID
179193 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.
181195 back *origPlace
182196 }
183197
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+}
185201
186-// windows lists every window: columns first, then floating ones.
202+// windows lists every window: columns first, then the stash, then native
203+// floats.
187204 func (w *Workspace) windows() []WindowID {
188205 var ids []WindowID
189206 for _, c := range w.Columns {
190207 ids = append(ids, c.Windows...)
191208 }
209+ for _, f := range w.Stash {
210+ ids = append(ids, f.ID)
211+ }
192212 for _, f := range w.Floats {
193213 ids = append(ids, f.ID)
194214 }
@@ -199,15 +219,12 @@ func (w *Workspace) floatIndex(id WindowID) int {
199219 return slices.IndexFunc(w.Floats, func(f Float) bool { return f.ID == id })
200220 }
201221
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.
203224 func (w *Workspace) AddFloating(id WindowID, width, height int) {
204225 if id == 0 || w.has(id) {
205226 return
206227 }
207- if w.floatsHidden {
208- // A new float must be seen: show the hidden ones with it.
209- w.showFloats()
210- }
211228 w.Floats = append(w.Floats, Float{ID: id, W: width, H: height})
212229 w.floatFocus = true
213230 }
@@ -215,15 +232,13 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) {
215232 // ResizeFloating records the size a floating window draws.
216233 func (w *Workspace) ResizeFloating(id WindowID, width, height int) {
217234 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
221236 }
222237 }
223238
224239 // has reports whether the workspace holds the window.
225240 func (w *Workspace) has(id WindowID) bool {
226- if w.floatIndex(id) >= 0 {
241+ if w.isFloat(id) {
227242 return true
228243 }
229244 for _, c := range w.Columns {
@@ -241,9 +256,12 @@ func (w *Workspace) Focused() (WindowID, bool) {
241256 if full := w.cover(); full != 0 {
242257 return full, true
243258 }
244- if w.floatFocus && !w.floatsHidden && len(w.Floats) > 0 {
259+ if w.floatFocus && len(w.Floats) > 0 {
245260 return w.Floats[len(w.Floats)-1].ID, true
246261 }
262+ if w.stashFocused() {
263+ return w.Stash[w.stashAt].ID, true
264+ }
247265 if w.Focus < 0 || w.Focus >= len(w.Columns) {
248266 return 0, false
249267 }
@@ -340,16 +358,27 @@ func (w *Workspace) unslot(n int) {
340358 }
341359
342360 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+ }
343367 if i := w.floatIndex(id); i >= 0 {
344368 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
347371 }
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)
350379 }
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
353382 }
354383 return
355384 }
@@ -360,9 +389,6 @@ func (w *Workspace) RemoveWindow(id WindowID) {
360389 }
361390 c := &w.Columns[i]
362391 c.Windows = append(c.Windows[:j], c.Windows[j+1:]...)
363- if w.fullscreen == id {
364- w.fullscreen = 0
365- }
366392 if len(c.Windows) == 0 {
367393 w.Columns = append(w.Columns[:i], w.Columns[i+1:]...)
368394 if len(w.Columns) == 0 {
@@ -386,22 +412,26 @@ func (w *Workspace) RemoveWindow(id WindowID) {
386412 }
387413 }
388414
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.
391417 func (w *Workspace) FocusID(id WindowID) bool {
392418 if i := w.floatIndex(id); i >= 0 {
393- if w.floatsHidden {
394- return false
395- }
396419 f := w.Floats[i]
397420 w.Floats = append(slices.Delete(w.Floats, i, i+1), f)
398421 w.floatFocus = true
399422 return true
400423 }
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+ }
402431 for i := range w.Columns {
403432 for j, v := range w.Columns[i].Windows {
404433 if v == id {
434+ w.floatFocus, w.stashFocus = false, false
405435 if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) {
406436 w.Columns[w.Focus].FullWidth = false
407437 }
@@ -419,10 +449,17 @@ func (w *Workspace) FocusColumn(dir int) {
419449 return
420450 }
421451 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.
423453 w.floatFocus = false
424454 return
425455 }
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+ }
426463 if i := w.columnToward(dir); i >= 0 {
427464 if w.Overflow == OverflowFixed {
428465 w.Columns[w.Focus].FullWidth = false
@@ -466,6 +503,10 @@ func (w *Workspace) FocusWindow(dir int) bool {
466503 w.floatFocus = false
467504 return true
468505 }
506+ if w.stashFocused() {
507+ // The stash is one row: up and down do nothing there.
508+ return true
509+ }
469510 if len(w.Columns) == 0 || (dir != -1 && dir != 1) {
470511 return false
471512 }
@@ -522,7 +563,7 @@ func (w *Workspace) screenNeighbor(dx, dy int) int {
522563
523564 func abs(n int) int { return max(n, -n) }
524565 func (w *Workspace) MoveColumn(dir int) {
525- if w.floatFocus {
566+ if w.onFloat() {
526567 return
527568 }
528569 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) {
535576
536577 // takeColumn removes the focused column and returns it, as a normal column.
537578 func (w *Workspace) takeColumn() (Column, bool) {
538- if len(w.Columns) == 0 || w.floatFocus {
579+ if len(w.Columns) == 0 || w.onFloat() {
539580 return Column{}, false
540581 }
541582 col := w.Columns[w.Focus]
@@ -571,7 +612,7 @@ func (w *Workspace) CycleWidth() {
571612 w.toggleExpanded()
572613 return
573614 }
574- if len(w.Columns) == 0 || len(w.presets) == 0 || w.floatFocus {
615+ if len(w.Columns) == 0 || len(w.presets) == 0 || w.onFloat() {
575616 return
576617 }
577618 // auto → presets in order → auto.
@@ -594,7 +635,7 @@ func (w *Workspace) CycleWidth() {
594635 // toggleExpanded makes the focused column the expanded one, or shrinks it
595636 // back when it already is. One column at most is expanded.
596637 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() {
598639 return
599640 }
600641 on := !w.Columns[w.Focus].Expanded
@@ -607,7 +648,7 @@ func (w *Workspace) toggleExpanded() {
607648
608649 // ToggleFullWidth expands the focused tiled column without changing its saved width.
609650 func (w *Workspace) ToggleFullWidth() {
610- if len(w.Columns) == 0 || w.floatFocus {
651+ if len(w.Columns) == 0 || w.onFloat() {
611652 return
612653 }
613654 c := &w.Columns[w.Focus]
@@ -631,12 +672,12 @@ func (w *Workspace) ToggleFullscreen() {
631672 // Activate focuses a window and makes it visible: it leaves another
632673 // window's fullscreen, which hides everything else.
633674 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()
640681 }
641682 if w.fullscreen != 0 && w.fullscreen != id {
642683 w.fullscreen = 0
@@ -647,9 +688,9 @@ func (w *Workspace) Activate(id WindowID) {
647688 // SetFullscreen applies a client request. It never moves focus: client
648689 // requests are automatic events (ADR 011 golden rule).
649690 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.
653694 if on {
654695 w.hiddenFullscreen = id
655696 } 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) }
723764 // Moves to another workspace or monitor still work.
724765 func (w *Workspace) pinned() bool {
725766 c := w.cover()
726- return c != 0 && (w.Overflow == OverflowFixed || w.floatIndex(c) >= 0)
767+ return c != 0 && (w.Overflow == OverflowFixed || w.isFloat(c))
727768 }
728769
729770 // focusCover points the focus state at the covering fullscreen window,
@@ -734,12 +775,11 @@ func (w *Workspace) focusCover() {
734775 if c == 0 {
735776 return
736777 }
737- if w.floatIndex(c) >= 0 {
738- w.floatFocus = true
778+ if w.isFloat(c) {
739779 w.FocusID(c)
740780 return
741781 }
742- w.floatFocus = false
782+ w.floatFocus, w.stashFocus = false, false
743783 for i, col := range w.Columns {
744784 if j := slices.Index(col.Windows, c); j >= 0 {
745785 w.Focus, w.Columns[i].Focus = i, j
@@ -754,7 +794,7 @@ func (w *Workspace) cover() WindowID {
754794 if w.fullscreen == 0 {
755795 return 0
756796 }
757- if w.floatIndex(w.fullscreen) >= 0 || w.Overflow == OverflowFixed {
797+ if w.isFloat(w.fullscreen) || w.Overflow == OverflowFixed {
758798 return w.fullscreen
759799 }
760800 // Scroll overflow: only while its column is aligned on the output.
@@ -963,11 +1003,13 @@ func (w *Workspace) Layout() []Placement {
9631003 }
9641004 }
9651005 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)...)
9671009 for _, f := range w.Floats {
9681010 // A covering fullscreen window hides every float, its own dialogs
9691011 // 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}
9711013 if p.Hidden {
9721014 p.Rect = Rect{}
9731015 result = append(result, p)
@@ -1014,106 +1056,38 @@ func setNeighbors(tiles []Placement, gap int) {
10141056 }
10151057 }
10161058
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.
10191061 func (w *Workspace) floatRect(f Float) Rect {
10201062 u := w.Usable
10211063 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 {
10261065 fw, fh = u.W/2, u.H/2
10271066 }
10281067 fw, fh = min(fw+2*w.border, u.W), min(fh+2*w.border, u.H)
10291068 return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh}
10301069 }
10311070
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 }
10371074
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() {
10411078 id, ok := w.Focused()
10421079 // A fullscreen window (a Wine game in scanout) keeps its place: it
10431080 // leaves fullscreen first, then may float.
10441081 if !ok || id == w.fullscreen {
10451082 return
10461083 }
1047- if i := w.floatIndex(id); i >= 0 {
1048- f := w.Floats[i]
1084+ switch {
1085+ case w.floatIndex(id) >= 0:
10491086 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)
11171092 }
1118- w.hiddenFullscreen = 0
11191093 }
@@ -97,6 +97,8 @@ type Placement struct {
97 Fullscreen, Focused, Hidden bool97 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 bool99 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 the102 // Neighbors are the sides touching another tiled window. Inset are the
101 // sides where the border takes room from the window: all sides of a103 // sides where the border takes room from the window: all sides of a
102 // float; for tiles, the right and bottom neighbors only, so two104 // float; for tiles, the right and bottom neighbors only, so two
@@ -134,13 +136,23 @@ type Workspace struct {
134 border int136 border int
135 presets []Width137 presets []Width
136 fullscreen WindowID138 fullscreen WindowID
137- // Floats are floating windows, bottom to top; floatFocus is set while139+ // 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 []Float141+ Floats []Float
140- floatFocus bool142+ floatFocus bool
141- floatsHidden bool143+ // Stash holds the windows set aside by toggle-window-stash, left to
142- // hiddenFullscreen holds a float's fullscreen while the floats are144+ // 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 WindowID156 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 is158 // 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 bool184 fullWidth bool
173 float *Float185 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 WindowID192 ID WindowID
179 W, H int193 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 *origPlace195 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 []WindowID205 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 return226 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 = true229 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 true242 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, true257 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, true260 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, false266 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 = 0370+ w.floatFocus = false
347 }371 }
348- if w.hiddenFullscreen == id {372+ return
349- w.hiddenFullscreen = 0373+ }
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, false381+ w.stashFocus, w.stashHidden = false, false
353 }382 }
354 return383 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 floating415+// 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 = true421 w.floatFocus = true
399 return true422 return true
400 }423 }
401- w.floatFocus = false424+ 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 = false436 w.Columns[w.Focus].FullWidth = false
407 }437 }
@@ -419,10 +449,17 @@ func (w *Workspace) FocusColumn(dir int) {
419 return449 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 = false453 w.floatFocus = false
424 return454 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 = false465 w.Columns[w.Focus].FullWidth = false
@@ -466,6 +503,10 @@ func (w *Workspace) FocusWindow(dir int) bool {
466 w.floatFocus = false503 w.floatFocus = false
467 return true504 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 false511 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 return567 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{}, false580 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 return613 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 return616 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 it635 // 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 return639 return
599 }640 }
600 on := !w.Columns[w.Focus].Expanded641 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 return652 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 another672 // 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. Another676+ // A hidden stashed window must be seen: show the stash with it,
636- // window's fullscreen leaves first, so the float's own pending677+ // selected. Another window's fullscreen leaves first, so its own
637- // fullscreen comes back.678+ // pending fullscreen comes back.
638- w.fullscreen = 0679+ 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 = 0683 w.fullscreen = 0
@@ -647,9 +688,9 @@ func (w *Workspace) Activate(id WindowID) {
647 // SetFullscreen applies a client request. It never moves focus: client688 // 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 = id695 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 return776 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 return780 return
741 }781 }
742- w.floatFocus = false782+ 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, j785 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 0795 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.fullscreen798 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 dialogs1010 // 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 border1059+// 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.Usable1062 u := w.Usable
1021 fw, fh := f.W, f.H1063 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/21065 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 its1075+// 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: it1079 // 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 return1082 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.back1090+ 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) {
8989 w.presets = append([]Width(nil), m.template.presets...)
9090 w.Gaps = m.template.Gaps
9191 w.border = m.template.border
92+ w.stashWidth, w.stashGap = m.template.stashWidth, m.template.stashGap
9293 w.SetOutput(m.template.Output.W, m.template.Output.H)
9394 w.SetUsable(m.template.Usable)
9495 if hidden {
@@ -124,6 +125,7 @@ func (m *Monitor) newWorkspace() *Workspace {
124125 w := m.template
125126 w.presets = append([]Width(nil), m.template.presets...)
126127 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
127129 (*m.nextID)++
128130 w.ID = *m.nextID
129131 return &w
@@ -284,8 +286,8 @@ func (m *Monitor) SetFullscreen(id WindowID, on bool) {
284286 case w.origin != nil && id != w.back.id:
285287 // A dialog of a fullscreen workspace already floats above it; its
286288 // 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.
289291 w.SetFullscreen(id, on)
290292 case on && m.ownWorkspace(w, id):
291293 focused, _ := w.Focused()
@@ -340,6 +342,12 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {
340342 w.RemoveWindow(id)
341343 fs.AddFloating(id, fl.W, fl.H)
342344 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)
343351 } else {
344352 col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) })
345353 c := w.Columns[col]
@@ -382,6 +390,16 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
382390 // A closure: back.col is where the window lands, known after the switch.
383391 defer func() { m.foldInto(fs, origin, back.col+1, focus, back.tiled) }()
384392 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)
385403 case back.float != nil && focus:
386404 origin.AddFloating(id, back.float.W, back.float.H)
387405 origin.Floats[len(origin.Floats)-1] = *back.float
@@ -401,7 +419,7 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
401419 row = min(row, len(c.Windows))
402420 c.Windows = slices.Insert(c.Windows, row, id)
403421 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
405423 } else if row <= c.Focus && len(c.Windows) > 1 {
406424 c.Focus++
407425 }
@@ -412,7 +430,7 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
412430 expanded := back.expanded && !slices.ContainsFunc(origin.Columns, func(c Column) bool { return c.Expanded })
413431 origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded})
414432 if focus {
415- origin.floatFocus = false
433+ origin.floatFocus, origin.stashFocus = false, false
416434 } else if len(origin.Columns) > 1 {
417435 origin.Focus = prev
418436 if at <= prev {
@@ -453,7 +471,11 @@ func (m *Monitor) foldInto(fs, origin *Workspace, at int, focus bool, tiled []Wi
453471 // The user is elsewhere: below, the focused float keeps the focus.
454472 origin.Floats = append(floats, origin.Floats...)
455473 }
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
457479 origin.scroll()
458480 m.take(fs)
459481 if shown {
@@ -524,6 +546,14 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) {
524546 } else {
525547 to.FocusID(id)
526548 }
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+ }
527557 } else {
528558 col := Column{Windows: []WindowID{id}}
529559 ok := true
@@ -613,9 +643,19 @@ func (m *Monitor) each(f func(*Workspace)) {
613643 func (m *Monitor) SetOutput(width, height int) {
614644 m.each(func(w *Workspace) { w.SetOutput(width, height) })
615645 }
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+}
619659 func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) }
620660
621661 // SetFollowMove makes moves to another workspace show it (focus.follow-move).
@@ -688,6 +728,9 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) {
688728 m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H)
689729 m.Workspaces[m.Active].Floats[len(m.Workspaces[m.Active].Floats)-1] = f
690730 }
731+ for _, f := range w.Stash {
732+ m.Workspaces[m.Active].Stash = append(m.Workspaces[m.Active].Stash, f.rehome())
733+ }
691734 }
692735 m.hidden = hidden
693736 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.Gaps90 w.Gaps = m.template.Gaps
91 w.border = m.template.border91 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.template125 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.nextID130 w.ID = *m.nextID
129 return &w131 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; its287 // 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] = fl344 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.float405 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, false422+ 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 = false433+ origin.floatFocus, origin.stashFocus = false, false
416 } else if len(origin.Columns) > 1 {434 } else if len(origin.Columns) > 1 {
417 origin.Focus = prev435 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, nil474+ // 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 := true559 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] = f729 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 = hidden735 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 {
130130 func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) {
131131 if p := c.popups[id]; p != nil {
132132 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) {
134136 return nil, Rect{}, false
135137 }
136138 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) {
155157 return nil, Rect{}, false
156158 }
157159
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+
158172 // placePopup places a new or repositioned popup and configures it; a popup
159173 // whose parent is not on screen is dismissed.
160174 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{}, false136 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}, true138 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{}, false157 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 popup172 // 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) {
335335 m.SetPresets(c.presets)
336336 m.SetGaps(c.cfg.Layout.Gaps)
337337 m.SetBorder(c.cfg.Border.Width)
338+ m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap)
338339 m.SetFollowMove(c.cfg.Focus.FollowMove)
339340 }
340341
@@ -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) {
879879 t.Fatalf("focused %v", focused)
880880 }
881881 }
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 {
4545 n := indexOf(m.Workspaces, w) + 1
4646 for _, id := range w.windows() {
4747 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)
4953 }
5054 }
5155 }
@@ -45,7 +45,11 @@ func (c *Core) state() ports.State {
45 n := indexOf(m.Workspaces, w) + 145 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) {
6363 visible(map[WindowID]bool{1: true, 2: false})
6464 // A floated window is visible until the floats are hidden.
6565 w.FocusID(2)
66- w.ToggleWindowFloating()
66+ w.ToggleWindowStash()
6767 visible(map[WindowID]bool{1: true, 2: true})
68- w.ToggleFloatingVisible()
68+ w.ToggleStashVisible()
6969 visible(map[WindowID]bool{1: true, 2: false})
70- w.ToggleFloatingVisible()
70+ w.ToggleStashVisible()
7171 // Another workspace hides them all; coming back shows them.
7272 if len(m.Workspaces) < 2 {
7373 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 {
7575 Startup [][]string
7676 Background struct{ Color string }
7777 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+ }
7886 // Border is drawn inside the window edge; Width 0 disables it.
7987 Border struct {
8088 Width int
@@ -75,6 +75,14 @@ type Config struct {
75 Startup [][]string75 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 int88 Width int
M internal/ports/messages.go +10 -0
@@ -1007,6 +1007,9 @@ type SceneWindow struct {
10071007 Inset Sides
10081008 // Floating windows are drawn over the tiles and their lines.
10091009 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
10101013 // Popups are drawn from their content only: no border, no background.
10111014 Popup bool
10121015 // OverLayers popups hang from a layer surface: drawn over the top and
@@ -1047,4 +1050,11 @@ type WindowState struct {
10471050 Visible bool
10481051 // IdleInhibit is set while the window keeps the session from idling.
10491052 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
10501060 }
@@ -1007,6 +1007,9 @@ type SceneWindow struct {
1007 Inset Sides1007 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 bool1009 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 bool1014 Popup bool
1012 // OverLayers popups hang from a layer surface: drawn over the top and1015 // OverLayers popups hang from a layer surface: drawn over the top and
@@ -1047,4 +1050,11 @@ type WindowState struct {
1047 Visible bool1050 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 }

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close