detached 5117e75

feat(core): stack fixed-mode overview columns and order covering floats (#66)

* fix(core): show a covering fullscreen float in the overview

A fixed-size game maps as a native float; the overview hid every float, so a workspace covered by a fullscreen game showed an empty row. The covering float is now that row's only preview, clickable and selectable; its dialogs and the columns under it stay hidden. Also fixes a panic when a window maps under a covering float with no column (scroll overflow).

* fix(core): configure a covering float fullscreen in its overview preview

A float made fullscreen while hidden kept its non-fullscreen configure in the overview preview. The preview placement now carries Fullscreen, and configures sizes such a window for the output, never the preview. Documents that the overview shows a fullscreen float.

* fix(core): open the overview over a covering fullscreen window

Cmd+o did nothing on a workspace covered by a fullscreen window (a game on its own workspace). The overview now opens there too: the game's row is selected, composed while the overview is open, and Escape returns to it.

* fix(core): show bars and pin selection in the overview over fullscreen

Opened over a covering fullscreen window, the overview kept bars and notifications hidden, and in fixed overflow a tiled fullscreen window's hidden columns stayed selectable while Return kept the fullscreen window in front. The overview now shows the layers, and any pinned fullscreen window (tiled or floating) is its row's only preview, as on screen.

* docs(config): describe the overview over fixed and scroll fullscreen

Documents that only pinned fullscreen windows (floating, or fixed overflow) are their row's only preview, and tests picking another column over a scroll-overflow fullscreen column.

* feat(core): order covering floats behind user-focused columns

* fix(core): keep float focus alone and raise columns on fullscreen return

Directional focus on a covering float with no column or stash under it kept nothing focused; the float now keeps the focus. A tile returning focused from its own fullscreen workspace is raised over a covering float mapped in its origin meanwhile.

* feat(core): show covering float stack cards in overview

* fix(core): bring columns front from the stash and drop the float veil in the overview

Right past the last stash card now brings the column group to the front of a covering-float stack. Stack previews are no longer marked floating, so the overview draws no output-wide float veil or float border.

* fix(core): keep stack previews floating without the float veil

Stack previews of covering floats stay floating so a float first seen in the overview is configured as a float, sized by the client. The float veil and the tile/float line boundary skip overview previews instead.

* fix(core): match fixed overflow overview previews to screen geometry

* fix(core): raise demoted covering float with focus-window-up

* test(core): cover focus-window-up from the stash and a pinned fullscreen

A shown stash keeps the focus on up with a demoted covering float, and a pinned fullscreen window still goes to the workspace above.

* feat(core): show hidden fixed columns as overview stack cards

* fix(core): restore overview maximization and track hidden selection

* fix(core): restore one maximized column on overview cancel

Escape anchored the maximization on every window of the opening column, so a column split while the overview was open could leave two maximized columns. It now anchors on the window focused at opening, where Escape returns the focus, and maximizes only its column.

* fix(core): keep scroll overflow maximized columns on overview cancel

Only fixed overflow maximizes at most one column; Escape now restores the anchor there and leaves scroll overflow columns as they are.

* refactor(core): unify overview stack, state and card placement

* feat(core): navigate overview cards as a vertical stack

* fix(core): configure real sizes of windows resized during the overview

* fix(core): align the stash pile with stacked rows

* fix(core): show hidden columns behind a maximized column as one card

* fix(core): route focus binds through overview navigation and keep rows centred beside the stash

* fix(core): lay out hidden columns without the maximized column's slot

* fix(overview): align window actions with preview selection

* fix(overview): follow highlighted column after layout mutations

* fix(core): preserve float focus across lifecycle transitions

* fix(overview): commit the clicked window in stacked column cards

* test(overview): cover Vim focus binds through keyboard events

* docs(test): describe Vim overview navigation coverage
commit
5117e75e8cb294b5d13a2b184bb95b6023840b13
author
bnema · · 2026-09-30 02:51 UTC
committer
GitHub · · 2026-09-30 02:51 UTC
parent
09abb12
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
27 files · +2791 -241
M docs/config.md +13 -11
@@ -22,7 +22,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
2222 | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it |
2323 | `background` | `#111111` | Solid background color |
2424 | `border.width` | `2` | Lines between visible neighboring tiles and around non-fullscreen floats, in logical pixels; `0` hides them |
25-| `floating.dim` | `0.3` | Black veil over tiles and lower layers when a native floating window (a dialog) or the stash is visible; opacity 0 to 1 |
25+| `floating.dim` | `0.3` | Black veil over tiles and lower layers when a native floating window above the tiles or the stash is visible; opacity 0 to 1 |
2626 | `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 |
2727 | `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 |
2828 | `stash.dim` | `0.5` | Black veil over the stash neighbors, borders included; opacity 0 to 1 |
@@ -114,7 +114,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
114114 | `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 |
115115 | `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 |
116116 | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen |
117-| `focus-window-up/down` | Focus in the column; past the edge, the next workspace. In fixed overflow, the column on screen above or below comes first |
117+| `focus-window-up/down` | Focus in the column; past the edge, the next workspace. In fixed overflow, the column on screen above or below comes first. Up at the top brings back a covering float put behind the columns |
118118 | `move-column-left/right` | Move the focused column |
119119 | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column |
120120 | `focus-workspace <N>` / `focus-workspace-up/down` | Show a numbered or neighbor workspace |
@@ -132,24 +132,26 @@ Each workspace has a stash: a horizontal strip of windows set aside with `toggle
132132
133133 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).
134134
135-Native floating windows (dialogs, file pickers) are not in the stash: they stay centred above it.
135+Native floating windows are not in the stash: they stay centred in the usable output area. A window-sized float that fills that area (within two border widths plus two logical pixels per axis) stays below the columns when you focus a tile; `focus-window-up` at the top of a column raises it again. It does not hide bars or pin focus. Small dialogs and file pickers always stay above the columns; shrinking a float promotes it above them. Real fullscreen remains exclusive.
136136
137137 ### Overview
138138
139-`toggle-overview` shows every column of the current workspace in one row, shrunk by the same factor so each window keeps its shape, as small as needed to fit (down to a quarter, then the row scrolls). The workspaces above and below show dimmed. Windows are not resized: the previews are their last frames. Native floating windows are hidden. The overview opens over a fullscreen window too. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's only preview; with `scroll` overflow the other columns stay selectable.
139+`toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's only preview; `scroll` overflow keeps its columns selectable in one card.
140140
141141 | Key | Action |
142142 | --- | --- |
143-| `h` / `l`, `left` / `right` | Select the column on the left / right; left of the first column, the stash pile |
144-| `k` / `j`, `up` / `down` | Select the workspace above / below |
145-| `return` | Close the overview on the selected window |
146-| `escape` | Close it and return to the window it opened on |
143+| `h` / `l`, `left` / `right` | Move within a front column-group card; left of its first tile enters the stash. Left on a single-window card sends it behind, or enters the stash when none is behind; right does nothing on a single card. |
144+| `k` / `j`, `up` / `down` | Bring the next card above / previous card below to the front, then change workspace at the stack boundary |
145+| `return` | Show the selected front card; picking a hidden column moves the maximization to it |
146+| `escape` | Restore the original focus, maximization and float order |
147147
148-Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row: its selected window in front, up to three others behind it, dimmed. In the pile, `h` / `l` browse the stash and `l` past its last window returns to the first column. `return` or a click on a card closes the overview with the stash shown on that window.
148+The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows` by default) move the selection like these keys.
149149
150-A four-finger swipe up opens the overview and a swipe down closes it on the selection, whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through the workspaces, following `touchpad.natural-scroll`. A three-finger swipe moves it one step when the fingers lift.
150+Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row, which stays centred unless it would overlap the pile: its selected window in front, up to three others behind it, dimmed. In the pile, `h` / `l` browse the stash and `l` past its last window returns to the front card. A successful up/down move through the stack leaves the stash and selects the new front card. `return` or a click on a card closes the overview with the stash shown on that window.
151151
152-A click on a preview picks it. Other binds still work. Native floating windows (dialogs) are not shown.
152+A four-finger swipe up opens the overview and a swipe down closes it on the selection, whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through stack cards before crossing workspaces, following `touchpad.natural-scroll`. A three-finger swipe moves it one step when the fingers lift.
153+
154+Return or a click on a front tile or peeking card commits it; card changes remain provisional until then. `close-window` targets the selected preview. Window mutation binds (moving, resizing, maximizing, fullscreen and stash toggles) and workspace moves are disabled while the overview is open. Workspace and monitor navigation, launch and quit binds remain active.
153155
154156 ### Default binds
155157
@@ -22,7 +22,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
22 | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it |22 | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it |
23 | `background` | `#111111` | Solid background color |23 | `background` | `#111111` | Solid background color |
24 | `border.width` | `2` | Lines between visible neighboring tiles and around non-fullscreen floats, in logical pixels; `0` hides them |24 | `border.width` | `2` | Lines between visible neighboring tiles and around non-fullscreen floats, in logical pixels; `0` hides them |
25-| `floating.dim` | `0.3` | Black veil over tiles and lower layers when a native floating window (a dialog) or the stash is visible; opacity 0 to 1 |25+| `floating.dim` | `0.3` | Black veil over tiles and lower layers when a native floating window above the tiles or the stash is visible; opacity 0 to 1 |
26 | `stash.width` | `80` | Width of the selected stashed window, over the tiles, in percent of the usable width; 10 to 90 (`80%` works too). The rest is split between both sides, where the neighbors show. Its height is 80% of the usable height |26 | `stash.width` | `80` | Width of the selected stashed window, over the tiles, in percent of the usable width; 10 to 90 (`80%` works too). The rest is split between both sides, where the neighbors show. Its height is 80% of the usable height |
27 | `stash.gap` | `2` | Space between the selected stashed window and its neighbors, in percent of the usable width; 0 to 10. The neighbors show from there to the screen edge: with the defaults, 8% of each. A gap as wide as the side margin hides them |27 | `stash.gap` | `2` | Space between the selected stashed window and its neighbors, in percent of the usable width; 0 to 10. The neighbors show from there to the screen edge: with the defaults, 8% of each. A gap as wide as the side margin hides them |
28 | `stash.dim` | `0.5` | Black veil over the stash neighbors, borders included; opacity 0 to 1 |28 | `stash.dim` | `0.5` | Black veil over the stash neighbors, borders included; opacity 0 to 1 |
@@ -114,7 +114,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
114 | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column |114 | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column |
115 | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time |115 | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time |
116 | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen |116 | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen |
117-| `focus-window-up/down` | Focus in the column; past the edge, the next workspace. In fixed overflow, the column on screen above or below comes first |117+| `focus-window-up/down` | Focus in the column; past the edge, the next workspace. In fixed overflow, the column on screen above or below comes first. Up at the top brings back a covering float put behind the columns |
118 | `move-column-left/right` | Move the focused column |118 | `move-column-left/right` | Move the focused column |
119 | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column |119 | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column |
120 | `focus-workspace <N>` / `focus-workspace-up/down` | Show a numbered or neighbor workspace |120 | `focus-workspace <N>` / `focus-workspace-up/down` | Show a numbered or neighbor workspace |
@@ -132,24 +132,26 @@ Each workspace has a stash: a horizontal strip of windows set aside with `toggle
132 132
133 While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it and stop at its ends; `focus-window-up/down` do nothing. Hide it with `toggle-stash-visible` to get back to the tiles. Scripts see each stashed window's place, and whether the stash is hidden, in the [state file](desktop.md#state-for-scripts).133 While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it and stop at its ends; `focus-window-up/down` do nothing. Hide it with `toggle-stash-visible` to get back to the tiles. Scripts see each stashed window's place, and whether the stash is hidden, in the [state file](desktop.md#state-for-scripts).
134 134
135-Native floating windows (dialogs, file pickers) are not in the stash: they stay centred above it.135+Native floating windows are not in the stash: they stay centred in the usable output area. A window-sized float that fills that area (within two border widths plus two logical pixels per axis) stays below the columns when you focus a tile; `focus-window-up` at the top of a column raises it again. It does not hide bars or pin focus. Small dialogs and file pickers always stay above the columns; shrinking a float promotes it above them. Real fullscreen remains exclusive.
136 136
137 ### Overview137 ### Overview
138 138
139-`toggle-overview` shows every column of the current workspace in one row, shrunk by the same factor so each window keeps its shape, as small as needed to fit (down to a quarter, then the row scrolls). The workspaces above and below show dimmed. Windows are not resized: the previews are their last frames. Native floating windows are hidden. The overview opens over a fullscreen window too. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's only preview; with `scroll` overflow the other columns stay selectable.139+`toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's only preview; `scroll` overflow keeps its columns selectable in one card.
140 140
141 | Key | Action |141 | Key | Action |
142 | --- | --- |142 | --- | --- |
143-| `h` / `l`, `left` / `right` | Select the column on the left / right; left of the first column, the stash pile |143+| `h` / `l`, `left` / `right` | Move within a front column-group card; left of its first tile enters the stash. Left on a single-window card sends it behind, or enters the stash when none is behind; right does nothing on a single card. |
144-| `k` / `j`, `up` / `down` | Select the workspace above / below |144+| `k` / `j`, `up` / `down` | Bring the next card above / previous card below to the front, then change workspace at the stack boundary |
145-| `return` | Close the overview on the selected window |145+| `return` | Show the selected front card; picking a hidden column moves the maximization to it |
146-| `escape` | Close it and return to the window it opened on |146+| `escape` | Restore the original focus, maximization and float order |
147 147
148-Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row: its selected window in front, up to three others behind it, dimmed. In the pile, `h` / `l` browse the stash and `l` past its last window returns to the first column. `return` or a click on a card closes the overview with the stash shown on that window.148+The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows` by default) move the selection like these keys.
149 149
150-A four-finger swipe up opens the overview and a swipe down closes it on the selection, whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through the workspaces, following `touchpad.natural-scroll`. A three-finger swipe moves it one step when the fingers lift.150+Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row, which stays centred unless it would overlap the pile: its selected window in front, up to three others behind it, dimmed. In the pile, `h` / `l` browse the stash and `l` past its last window returns to the front card. A successful up/down move through the stack leaves the stash and selects the new front card. `return` or a click on a card closes the overview with the stash shown on that window.
151 151
152-A click on a preview picks it. Other binds still work. Native floating windows (dialogs) are not shown.152+A four-finger swipe up opens the overview and a swipe down closes it on the selection, whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through stack cards before crossing workspaces, following `touchpad.natural-scroll`. A three-finger swipe moves it one step when the fingers lift.
153+
154+Return or a click on a front tile or peeking card commits it; card changes remain provisional until then. `close-window` targets the selected preview. Window mutation binds (moving, resizing, maximizing, fullscreen and stash toggles) and workspace moves are disabled while the overview is open. Workspace and monitor navigation, launch and quit binds remain active.
153 155
154 ### Default binds156 ### Default binds
155 157
M internal/adapters/vulkan/scene.go +4 -3
@@ -85,15 +85,16 @@ func (w *sceneWalk) layers(afterWindows bool) {
8585 }
8686 }
8787
88-// windows draws the tiled then floating windows with their borders.
89-// Tile lines (Window 0) go over the tiles, a float's border with it.
88+// windows draws ordered placements with borders. Tile lines go after
89+// the column group, before the first float above it; an overview preview
90+// of a float is not one.
9091 func (w *sceneWalk) windows() {
9192 tileLines := false
9293 for _, win := range w.s.Windows {
9394 if win.Hidden || win.Popup || win.Rect.W <= 0 || win.Rect.H <= 0 {
9495 continue
9596 }
96- if win.Floating && !tileLines {
97+ if !win.Below && win.Floating && win.Preview == 0 && !tileLines {
9798 w.separators(0)
9899 tileLines = true
99100 if w.s.Dim > 0 {
@@ -85,15 +85,16 @@ func (w *sceneWalk) layers(afterWindows bool) {
85 }85 }
86 }86 }
87 87
88-// windows draws the tiled then floating windows with their borders.88+// windows draws ordered placements with borders. Tile lines go after
89-// Tile lines (Window 0) go over the tiles, a float's border with it.89+// the column group, before the first float above it; an overview preview
90+// of a float is not one.
90 func (w *sceneWalk) windows() {91 func (w *sceneWalk) windows() {
91 tileLines := false92 tileLines := false
92 for _, win := range w.s.Windows {93 for _, win := range w.s.Windows {
93 if win.Hidden || win.Popup || win.Rect.W <= 0 || win.Rect.H <= 0 {94 if win.Hidden || win.Popup || win.Rect.W <= 0 || win.Rect.H <= 0 {
94 continue95 continue
95 }96 }
96- if win.Floating && !tileLines {97+ if !win.Below && win.Floating && win.Preview == 0 && !tileLines {
97 w.separators(0)98 w.separators(0)
98 tileLines = true99 tileLines = true
99 if w.s.Dim > 0 {100 if w.s.Dim > 0 {
A internal/adapters/vulkan/scene_float_order_test.go +38 -0
@@ -0,0 +1,38 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestSceneBelowFloatBoundary(t *testing.T) {
11+ r := &Renderer{width: 80, height: 60}
12+ s := ports.Scene{Border: ports.Border{Active: "#ffffff"}, Windows: []ports.SceneWindow{
13+ {ID: 1, Floating: true, Below: true, Rect: ports.Rect{W: 80, H: 60}},
14+ {ID: 2, Rect: ports.Rect{W: 40, H: 60}},
15+ }, Separators: []ports.Separator{{Rect: ports.Rect{W: 80, H: 1}, Active: true}}}
16+ draws := func() []draw { return r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60))) }
17+ ds := draws()
18+ if len(ds) != 3 || ds[0].pc.rect[2] != 80 || ds[1].pc.rect[2] != 40 || ds[2].pc.rect[3] != 1 {
19+ t.Fatalf("below float / tile / lines: %+v", ds)
20+ }
21+ s.Dim = 0.4
22+ s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Floating: true, Rect: ports.Rect{X: 50, W: 20, H: 20}})
23+ ds = draws()
24+ if len(ds) != 5 || ds[3].pc.color != [4]float32{0, 0, 0, 0.4} || ds[4].pc.rect[0] != 50 {
25+ t.Fatalf("veil boundary: %+v", ds)
26+ }
27+ // An overview preview of a float is not a float above the tiles: the
28+ // tile lines follow the last window.
29+ s.Dim = 0
30+ s.Windows = []ports.SceneWindow{
31+ {ID: 1, Floating: true, Preview: 0.5, Rect: ports.Rect{W: 40, H: 30}},
32+ {ID: 2, Preview: 0.5, Rect: ports.Rect{X: 40, W: 40, H: 30}},
33+ }
34+ ds = draws()
35+ if len(ds) != 3 || ds[2].pc.rect[3] != 1 {
36+ t.Fatalf("preview boundary: %+v", ds)
37+ }
38+}
@@ -0,0 +1,38 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestSceneBelowFloatBoundary(t *testing.T) {
11+ r := &Renderer{width: 80, height: 60}
12+ s := ports.Scene{Border: ports.Border{Active: "#ffffff"}, Windows: []ports.SceneWindow{
13+ {ID: 1, Floating: true, Below: true, Rect: ports.Rect{W: 80, H: 60}},
14+ {ID: 2, Rect: ports.Rect{W: 40, H: 60}},
15+ }, Separators: []ports.Separator{{Rect: ports.Rect{W: 80, H: 1}, Active: true}}}
16+ draws := func() []draw { return r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60))) }
17+ ds := draws()
18+ if len(ds) != 3 || ds[0].pc.rect[2] != 80 || ds[1].pc.rect[2] != 40 || ds[2].pc.rect[3] != 1 {
19+ t.Fatalf("below float / tile / lines: %+v", ds)
20+ }
21+ s.Dim = 0.4
22+ s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Floating: true, Rect: ports.Rect{X: 50, W: 20, H: 20}})
23+ ds = draws()
24+ if len(ds) != 5 || ds[3].pc.color != [4]float32{0, 0, 0, 0.4} || ds[4].pc.rect[0] != 50 {
25+ t.Fatalf("veil boundary: %+v", ds)
26+ }
27+ // An overview preview of a float is not a float above the tiles: the
28+ // tile lines follow the last window.
29+ s.Dim = 0
30+ s.Windows = []ports.SceneWindow{
31+ {ID: 1, Floating: true, Preview: 0.5, Rect: ports.Rect{W: 40, H: 30}},
32+ {ID: 2, Preview: 0.5, Rect: ports.Rect{X: 40, W: 40, H: 30}},
33+ }
34+ ds = draws()
35+ if len(ds) != 3 || ds[2].pc.rect[3] != 1 {
36+ t.Fatalf("preview boundary: %+v", ds)
37+ }
38+}
M internal/core/action.go +19 -6
@@ -109,6 +109,14 @@ type Effect struct {
109109 // screen; column focus past the edge column moves to the neighbor screen;
110110 // everything else applies to the focused screen's monitor.
111111 func (c *Core) applyAction(a Action) Effect {
112+ if m := c.cur().mon; m.ov.open {
113+ if m.overviewFocus(a) || overviewBlocks(a) {
114+ return Effect{}
115+ }
116+ if a == ActionCloseWindow {
117+ return m.Apply(a)
118+ }
119+ }
112120 // A bind acts on the window on screen: under a covering fullscreen
113121 // window, that is the one Focused reports.
114122 c.cur().mon.Current().focusCover()
@@ -135,11 +143,11 @@ func (c *Core) applyAction(a Action) Effect {
135143 } else {
136144 dir = 1
137145 }
138- // The first move from a float stays on this monitor (FocusColumn):
146+ // The first move from a float stays here unless nothing is under it:
139147 // a native float leaves for the stash or the columns, and the stash
140148 // keeps the focus at its ends. A covering fullscreen window pins
141149 // the focus: the move goes to the neighbor monitor.
142- edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.pinned())
150+ edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.pinned() || w.floatFocus && !w.canLeaveFloat())
143151 if i := c.neighbor(dir); edge && i >= 0 {
144152 c.focusScreen = i
145153 return Effect{}
@@ -201,9 +209,13 @@ func (c *Core) applyAction(a Action) Effect {
201209
202210 // Apply runs a bind action on the monitor.
203211 func (m *Monitor) Apply(a Action) Effect {
204- // In the overview, close-window closes the selected stash card.
205- if id := m.card(); id != 0 && a == ActionCloseWindow {
206- return Effect{Close: id}
212+ if m.ov.open {
213+ if a == ActionCloseWindow {
214+ return Effect{Close: m.overviewTarget()}
215+ }
216+ if overviewBlocks(a) {
217+ return Effect{}
218+ }
207219 }
208220 if n, op, ok := WorkspaceArg(a); ok {
209221 if op == FocusWorkspace {
@@ -219,7 +231,8 @@ func (m *Monitor) Apply(a Action) Effect {
219231 }
220232 switch a {
221233 case ActionFocusWindowUp, ActionFocusWindowDown:
222- // Past the top or bottom window of the column, move to the next workspace.
234+ // Past the column and any on-screen neighbor or demoted float,
235+ // move to the next workspace.
223236 dir := 1
224237 if a == ActionFocusWindowUp {
225238 dir = -1
@@ -109,6 +109,14 @@ type Effect struct {
109 // screen; column focus past the edge column moves to the neighbor screen;109 // screen; column focus past the edge column moves to the neighbor screen;
110 // everything else applies to the focused screen's monitor.110 // everything else applies to the focused screen's monitor.
111 func (c *Core) applyAction(a Action) Effect {111 func (c *Core) applyAction(a Action) Effect {
112+ if m := c.cur().mon; m.ov.open {
113+ if m.overviewFocus(a) || overviewBlocks(a) {
114+ return Effect{}
115+ }
116+ if a == ActionCloseWindow {
117+ return m.Apply(a)
118+ }
119+ }
112 // A bind acts on the window on screen: under a covering fullscreen120 // A bind acts on the window on screen: under a covering fullscreen
113 // window, that is the one Focused reports.121 // window, that is the one Focused reports.
114 c.cur().mon.Current().focusCover()122 c.cur().mon.Current().focusCover()
@@ -135,11 +143,11 @@ func (c *Core) applyAction(a Action) Effect {
135 } else {143 } else {
136 dir = 1144 dir = 1
137 }145 }
138- // The first move from a float stays on this monitor (FocusColumn):146+ // The first move from a float stays here unless nothing is under it:
139 // a native float leaves for the stash or the columns, and the stash147 // a native float leaves for the stash or the columns, and the stash
140 // keeps the focus at its ends. A covering fullscreen window pins148 // keeps the focus at its ends. A covering fullscreen window pins
141 // the focus: the move goes to the neighbor monitor.149 // the focus: the move goes to the neighbor monitor.
142- edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.pinned())150+ edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.pinned() || w.floatFocus && !w.canLeaveFloat())
143 if i := c.neighbor(dir); edge && i >= 0 {151 if i := c.neighbor(dir); edge && i >= 0 {
144 c.focusScreen = i152 c.focusScreen = i
145 return Effect{}153 return Effect{}
@@ -201,9 +209,13 @@ func (c *Core) applyAction(a Action) Effect {
201 209
202 // Apply runs a bind action on the monitor.210 // Apply runs a bind action on the monitor.
203 func (m *Monitor) Apply(a Action) Effect {211 func (m *Monitor) Apply(a Action) Effect {
204- // In the overview, close-window closes the selected stash card.212+ if m.ov.open {
205- if id := m.card(); id != 0 && a == ActionCloseWindow {213+ if a == ActionCloseWindow {
206- return Effect{Close: id}214+ return Effect{Close: m.overviewTarget()}
215+ }
216+ if overviewBlocks(a) {
217+ return Effect{}
218+ }
207 }219 }
208 if n, op, ok := WorkspaceArg(a); ok {220 if n, op, ok := WorkspaceArg(a); ok {
209 if op == FocusWorkspace {221 if op == FocusWorkspace {
@@ -219,7 +231,8 @@ func (m *Monitor) Apply(a Action) Effect {
219 }231 }
220 switch a {232 switch a {
221 case ActionFocusWindowUp, ActionFocusWindowDown:233 case ActionFocusWindowUp, ActionFocusWindowDown:
222- // Past the top or bottom window of the column, move to the next workspace.234+ // Past the column and any on-screen neighbor or demoted float,
235+ // move to the next workspace.
223 dir := 1236 dir := 1
224 if a == ActionFocusWindowUp {237 if a == ActionFocusWindowUp {
225 dir = -1238 dir = -1
M internal/core/configures.go +10 -1
@@ -11,6 +11,8 @@ type configureTarget struct {
1111 // floating window (native floats pick their own size).
1212 client Rect
1313 imposed bool
14+ // realTiled sizes a visible tiled preview from its real client rect.
15+ realTiled bool
1416 }
1517
1618 // configures owns the last configure sent to each window and decides the
@@ -44,13 +46,20 @@ func (s *configures) next(p Placement, t configureTarget) (ports.ConfigureWindow
4446 // A preview keeps its client's size: only its focus changes.
4547 v = old
4648 if !ok {
47- v = ports.ConfigureWindow{ID: p.ID, Width: int(float64(p.Rect.W) / p.Preview), Height: int(float64(p.Rect.H) / p.Preview)}
49+ v = ports.ConfigureWindow{ID: p.ID, Width: int(float64(p.Rect.W) / p.Preview), Height: int(float64(p.Rect.H) / p.Preview), Floating: p.Floating}
50+ if p.Floating {
51+ // A native float picks its own size.
52+ v.Width, v.Height = 0, 0
53+ }
4854 }
4955 if p.Fullscreen && !v.Fullscreen {
5056 // A float made fullscreen off screen: its preview row shows
5157 // it fullscreen, sized for the output, never the preview.
5258 v.Width, v.Height, v.Fullscreen, v.Floating = t.area.W, t.area.H, true, false
5359 }
60+ if t.realTiled {
61+ v.Width, v.Height = t.client.W, t.client.H
62+ }
5463 v.Activated, v.Visible, v.Output = t.focused, onScreen(p, t.area), t.output
5564 default:
5665 v = ports.ConfigureWindow{ID: p.ID, Width: t.client.W, Height: t.client.H, Fullscreen: p.Fullscreen, Activated: t.focused, Floating: p.Floating && !p.Fullscreen, Output: t.output, Visible: onScreen(p, t.area)}
@@ -11,6 +11,8 @@ type configureTarget struct {
11 // floating window (native floats pick their own size).11 // floating window (native floats pick their own size).
12 client Rect12 client Rect
13 imposed bool13 imposed bool
14+ // realTiled sizes a visible tiled preview from its real client rect.
15+ realTiled bool
14 }16 }
15 17
16 // configures owns the last configure sent to each window and decides the18 // configures owns the last configure sent to each window and decides the
@@ -44,13 +46,20 @@ func (s *configures) next(p Placement, t configureTarget) (ports.ConfigureWindow
44 // A preview keeps its client's size: only its focus changes.46 // A preview keeps its client's size: only its focus changes.
45 v = old47 v = old
46 if !ok {48 if !ok {
47- v = ports.ConfigureWindow{ID: p.ID, Width: int(float64(p.Rect.W) / p.Preview), Height: int(float64(p.Rect.H) / p.Preview)}49+ v = ports.ConfigureWindow{ID: p.ID, Width: int(float64(p.Rect.W) / p.Preview), Height: int(float64(p.Rect.H) / p.Preview), Floating: p.Floating}
50+ if p.Floating {
51+ // A native float picks its own size.
52+ v.Width, v.Height = 0, 0
53+ }
48 }54 }
49 if p.Fullscreen && !v.Fullscreen {55 if p.Fullscreen && !v.Fullscreen {
50 // A float made fullscreen off screen: its preview row shows56 // A float made fullscreen off screen: its preview row shows
51 // it fullscreen, sized for the output, never the preview.57 // it fullscreen, sized for the output, never the preview.
52 v.Width, v.Height, v.Fullscreen, v.Floating = t.area.W, t.area.H, true, false58 v.Width, v.Height, v.Fullscreen, v.Floating = t.area.W, t.area.H, true, false
53 }59 }
60+ if t.realTiled {
61+ v.Width, v.Height = t.client.W, t.client.H
62+ }
54 v.Activated, v.Visible, v.Output = t.focused, onScreen(p, t.area), t.output63 v.Activated, v.Visible, v.Output = t.focused, onScreen(p, t.area), t.output
55 default:64 default:
56 v = ports.ConfigureWindow{ID: p.ID, Width: t.client.W, Height: t.client.H, Fullscreen: p.Fullscreen, Activated: t.focused, Floating: p.Floating && !p.Fullscreen, Output: t.output, Visible: onScreen(p, t.area)}65 v = ports.ConfigureWindow{ID: p.ID, Width: t.client.W, Height: t.client.H, Fullscreen: p.Fullscreen, Activated: t.focused, Floating: p.Floating && !p.Fullscreen, Output: t.output, Visible: onScreen(p, t.area)}
M internal/core/configures_test.go +3 -0
@@ -30,6 +30,9 @@ func TestConfiguresNext(t *testing.T) {
3030 {name: "fullscreen preview keeps size and state", first: &Placement{ID: 1, Rect: area, Floating: true, Fullscreen: true}, p: Placement{ID: 1, Rect: Rect{W: 100, H: 80}, Preview: 0.1}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Fullscreen: true, Output: "A", Visible: true}, send: true},
3131 {name: "hidden float made fullscreen previews fullscreen", first: &Placement{ID: 1, Hidden: true, Floating: true}, p: Placement{ID: 1, Rect: Rect{W: 100, H: 80}, Preview: 0.1, Fullscreen: true}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 1000, Height: 800, Fullscreen: true, Output: "A", Visible: true}, send: true},
3232 {name: "preview keeps size", first: &tiled, p: Placement{ID: 1, Rect: Rect{W: 100, H: 100}, Preview: 0.5}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Output: "A", Visible: true}, send: true},
33+ {name: "new float preview stays floating", p: Placement{ID: 1, Rect: Rect{W: 600, H: 480}, Preview: 0.6, Floating: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}, send: true},
34+ {name: "covering float preview keeps native size", first: &Placement{ID: 1, Rect: area, Floating: true}, p: Placement{ID: 1, Rect: Rect{W: 600, H: 480}, Preview: 0.6, Floating: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}},
35+ {name: "covering float peek keeps native size", first: &Placement{ID: 1, Rect: area, Floating: true}, p: Placement{ID: 1, Rect: Rect{X: 20, Y: 20, W: 600, H: 480}, Preview: 0.6, Floating: true, Peek: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}},
3336 }
3437 for _, tt := range tests {
3538 t.Run(tt.name, func(t *testing.T) {
@@ -30,6 +30,9 @@ func TestConfiguresNext(t *testing.T) {
30 {name: "fullscreen preview keeps size and state", first: &Placement{ID: 1, Rect: area, Floating: true, Fullscreen: true}, p: Placement{ID: 1, Rect: Rect{W: 100, H: 80}, Preview: 0.1}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Fullscreen: true, Output: "A", Visible: true}, send: true},30 {name: "fullscreen preview keeps size and state", first: &Placement{ID: 1, Rect: area, Floating: true, Fullscreen: true}, p: Placement{ID: 1, Rect: Rect{W: 100, H: 80}, Preview: 0.1}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Fullscreen: true, Output: "A", Visible: true}, send: true},
31 {name: "hidden float made fullscreen previews fullscreen", first: &Placement{ID: 1, Hidden: true, Floating: true}, p: Placement{ID: 1, Rect: Rect{W: 100, H: 80}, Preview: 0.1, Fullscreen: true}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 1000, Height: 800, Fullscreen: true, Output: "A", Visible: true}, send: true},31 {name: "hidden float made fullscreen previews fullscreen", first: &Placement{ID: 1, Hidden: true, Floating: true}, p: Placement{ID: 1, Rect: Rect{W: 100, H: 80}, Preview: 0.1, Fullscreen: true}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 1000, Height: 800, Fullscreen: true, Output: "A", Visible: true}, send: true},
32 {name: "preview keeps size", first: &tiled, p: Placement{ID: 1, Rect: Rect{W: 100, H: 100}, Preview: 0.5}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Output: "A", Visible: true}, send: true},32 {name: "preview keeps size", first: &tiled, p: Placement{ID: 1, Rect: Rect{W: 100, H: 100}, Preview: 0.5}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Output: "A", Visible: true}, send: true},
33+ {name: "new float preview stays floating", p: Placement{ID: 1, Rect: Rect{W: 600, H: 480}, Preview: 0.6, Floating: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}, send: true},
34+ {name: "covering float preview keeps native size", first: &Placement{ID: 1, Rect: area, Floating: true}, p: Placement{ID: 1, Rect: Rect{W: 600, H: 480}, Preview: 0.6, Floating: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}},
35+ {name: "covering float peek keeps native size", first: &Placement{ID: 1, Rect: area, Floating: true}, p: Placement{ID: 1, Rect: Rect{X: 20, Y: 20, W: 600, H: 480}, Preview: 0.6, Floating: true, Peek: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}},
33 }36 }
34 for _, tt := range tests {37 for _, tt := range tests {
35 t.Run(tt.name, func(t *testing.T) {38 t.Run(tt.name, func(t *testing.T) {
M internal/core/core.go +28 -22
@@ -338,12 +338,13 @@ func onScreen(p Placement, o Rect) bool {
338338 return !p.Hidden && p.Rect.Overlaps(Rect{W: o.W, H: o.H})
339339 }
340340
341-// floatDim is the veil opacity of a layout: dim while a float is drawn
342-// over the tiles, none under a fullscreen float (nothing is seen below).
341+// floatDim is the veil opacity of a layout: dim only when a float is
342+// drawn above the tiles, never for a demoted covering float alone nor for
343+// an overview preview.
343344 func floatDim(layout []Placement, o Rect, dim float64) float64 {
344345 shown := false
345346 for _, p := range layout {
346- if p.Floating && onScreen(p, o) {
347+ if p.Floating && !p.Below && p.Preview == 0 && onScreen(p, o) {
347348 if p.Fullscreen {
348349 return 0
349350 }
@@ -450,17 +451,26 @@ func (c *Core) publish(ctx context.Context) error {
450451 o := sc.mon.Output()
451452 scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)}
452453 layout := sc.mon.Layout()
454+ var real map[WindowID]Placement
455+ if sc.mon.ov.open {
456+ real = make(map[WindowID]Placement)
457+ for _, w := range sc.mon.all() {
458+ for _, p := range w.Layout() {
459+ real[p.ID] = p
460+ }
461+ }
462+ }
453463 scene.Dim = floatDim(layout, o, c.cfg.Floating.Dim)
454464 // Only the focused output lights the focused window's lines.
455465 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen)
456- if sc.mon.overview {
466+ if sc.mon.ov.open {
457467 // Previews have no lines: the selected one is framed.
458468 scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2))
459469 }
460470 for _, p := range layout {
461471 // Only the focused output has an activated window.
462472 focused := p.Focused && i == c.focusScreen
463- sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Inset: p.Inset, Preview: p.Preview}
473+ sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview}
464474 if p.Peek {
465475 sw.Dim = c.cfg.Stash.Dim
466476 }
@@ -469,6 +479,11 @@ func (c *Core) publish(ctx context.Context) error {
469479 if !p.Hidden && p.Preview == 0 {
470480 // Only a sized configure needs the client size.
471481 t.client, t.imposed = c.clientRect(p), sc.mon.Current().imposedFloat(p.ID)
482+ } else if p.Preview > 0 && !p.Hidden {
483+ if rp, ok := real[p.ID]; ok && !rp.Hidden {
484+ t.realTiled = !rp.Floating
485+ t.client = c.clientRect(rp)
486+ }
472487 }
473488 if v, send := c.configures.next(p, t); send {
474489 if err := c.command(ctx, v); err != nil {
@@ -684,8 +699,8 @@ func (c *Core) acceptsInput(id WindowID, x, y float64) bool {
684699
685700 // hit returns the surface under the global logical point and the point in
686701 // its surface coordinates: popups, then overlay and top layers, then
687-// windows (fullscreen wins; otherwise the last visible placement is
688-// topmost), then bottom and background layers.
702+// windows (last visible placement is topmost), then bottom and
703+// background layers.
689704 func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
690705 o, ok := c.layout().At(x, y)
691706 if !ok {
@@ -704,14 +719,13 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
704719 if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 {
705720 return id, px, py
706721 }
707- if sc.mon.overview {
722+ if sc.mon.ov.open {
708723 // Previews and the layers under them take no input: a click
709724 // picks a preview (overviewClick).
710725 return 0, 0, 0
711726 }
712727 var id WindowID
713728 var sx, sy float64
714- full := false
715729 for _, p := range sc.mon.Layout() {
716730 r := c.clientRect(p)
717731 // A peek is clickable wherever it shows, border included: it may
@@ -724,18 +738,10 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
724738 if p.Peek {
725739 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))
726740 }
727- // Floating windows come last in the layout and are on top,
728- // even of a fullscreen window; nothing else is.
729- if full && !p.Floating {
730- continue
731- }
732- if !c.acceptsInput(p.ID, lx-float64(r.X), ly-float64(r.Y)) {
733- continue
734- }
735- if id == 0 || p.Fullscreen || p.Floating {
741+ if c.acceptsInput(p.ID, lx-float64(r.X), ly-float64(r.Y)) {
742+ // Layout is bottom to top, including fullscreen and floats.
736743 id, sx, sy = p.ID, lx-float64(r.X), ly-float64(r.Y)
737744 }
738- full = full || p.Fullscreen
739745 }
740746 }
741747 if id == 0 {
@@ -1024,7 +1030,7 @@ func (c *Core) Run(ctx context.Context) error {
10241030 continue
10251031 case ports.PointerAxis:
10261032 // In the overview, scrolling moves the selection.
1027- if c.cur().mon.overview && !c.overviewKeyboardTaken() {
1033+ if c.cur().mon.ov.open && !c.overviewKeyboardTaken() {
10281034 if c.cur().mon.overviewScroll(v) {
10291035 if c.workspaceVisible(ctx, true) != nil {
10301036 return nil
@@ -1066,7 +1072,7 @@ func (c *Core) Run(ctx context.Context) error {
10661072 // The overview takes its keys before any window; others still
10671073 // run binds, and are not forwarded.
10681074 // A launcher or a menu holding the keyboard gets them first.
1069- if mon := c.cur().mon; mon.overview && !c.overviewKeyboardTaken() {
1075+ if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() {
10701076 if key.Pressed && mon.overviewKey(key) {
10711077 c.pressed[heldKey(key)] = true
10721078 if c.workspaceVisible(ctx, true) != nil {
@@ -1172,7 +1178,7 @@ func (c *Core) Run(ctx context.Context) error {
11721178 }
11731179 c.pressed[held] = false
11741180 }
1175- if id := c.keyboardFocus(); id != 0 && (!c.cur().mon.overview || c.overviewKeyboardTaken()) {
1181+ if id := c.keyboardFocus(); id != 0 && (!c.cur().mon.ov.open || c.overviewKeyboardTaken()) {
11761182 if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil {
11771183 return nil
11781184 }
@@ -338,12 +338,13 @@ func onScreen(p Placement, o Rect) bool {
338 return !p.Hidden && p.Rect.Overlaps(Rect{W: o.W, H: o.H})338 return !p.Hidden && p.Rect.Overlaps(Rect{W: o.W, H: o.H})
339 }339 }
340 340
341-// floatDim is the veil opacity of a layout: dim while a float is drawn341+// floatDim is the veil opacity of a layout: dim only when a float is
342-// over the tiles, none under a fullscreen float (nothing is seen below).342+// drawn above the tiles, never for a demoted covering float alone nor for
343+// an overview preview.
343 func floatDim(layout []Placement, o Rect, dim float64) float64 {344 func floatDim(layout []Placement, o Rect, dim float64) float64 {
344 shown := false345 shown := false
345 for _, p := range layout {346 for _, p := range layout {
346- if p.Floating && onScreen(p, o) {347+ if p.Floating && !p.Below && p.Preview == 0 && onScreen(p, o) {
347 if p.Fullscreen {348 if p.Fullscreen {
348 return 0349 return 0
349 }350 }
@@ -450,17 +451,26 @@ func (c *Core) publish(ctx context.Context) error {
450 o := sc.mon.Output()451 o := sc.mon.Output()
451 scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)}452 scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)}
452 layout := sc.mon.Layout()453 layout := sc.mon.Layout()
454+ var real map[WindowID]Placement
455+ if sc.mon.ov.open {
456+ real = make(map[WindowID]Placement)
457+ for _, w := range sc.mon.all() {
458+ for _, p := range w.Layout() {
459+ real[p.ID] = p
460+ }
461+ }
462+ }
453 scene.Dim = floatDim(layout, o, c.cfg.Floating.Dim)463 scene.Dim = floatDim(layout, o, c.cfg.Floating.Dim)
454 // Only the focused output lights the focused window's lines.464 // Only the focused output lights the focused window's lines.
455 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen)465 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen)
456- if sc.mon.overview {466+ if sc.mon.ov.open {
457 // Previews have no lines: the selected one is framed.467 // Previews have no lines: the selected one is framed.
458 scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2))468 scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2))
459 }469 }
460 for _, p := range layout {470 for _, p := range layout {
461 // Only the focused output has an activated window.471 // Only the focused output has an activated window.
462 focused := p.Focused && i == c.focusScreen472 focused := p.Focused && i == c.focusScreen
463- sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Inset: p.Inset, Preview: p.Preview}473+ sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview}
464 if p.Peek {474 if p.Peek {
465 sw.Dim = c.cfg.Stash.Dim475 sw.Dim = c.cfg.Stash.Dim
466 }476 }
@@ -469,6 +479,11 @@ func (c *Core) publish(ctx context.Context) error {
469 if !p.Hidden && p.Preview == 0 {479 if !p.Hidden && p.Preview == 0 {
470 // Only a sized configure needs the client size.480 // Only a sized configure needs the client size.
471 t.client, t.imposed = c.clientRect(p), sc.mon.Current().imposedFloat(p.ID)481 t.client, t.imposed = c.clientRect(p), sc.mon.Current().imposedFloat(p.ID)
482+ } else if p.Preview > 0 && !p.Hidden {
483+ if rp, ok := real[p.ID]; ok && !rp.Hidden {
484+ t.realTiled = !rp.Floating
485+ t.client = c.clientRect(rp)
486+ }
472 }487 }
473 if v, send := c.configures.next(p, t); send {488 if v, send := c.configures.next(p, t); send {
474 if err := c.command(ctx, v); err != nil {489 if err := c.command(ctx, v); err != nil {
@@ -684,8 +699,8 @@ func (c *Core) acceptsInput(id WindowID, x, y float64) bool {
684 699
685 // hit returns the surface under the global logical point and the point in700 // hit returns the surface under the global logical point and the point in
686 // its surface coordinates: popups, then overlay and top layers, then701 // its surface coordinates: popups, then overlay and top layers, then
687-// windows (fullscreen wins; otherwise the last visible placement is702+// windows (last visible placement is topmost), then bottom and
688-// topmost), then bottom and background layers.703+// background layers.
689 func (c *Core) hit(x, y float64) (WindowID, float64, float64) {704 func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
690 o, ok := c.layout().At(x, y)705 o, ok := c.layout().At(x, y)
691 if !ok {706 if !ok {
@@ -704,14 +719,13 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
704 if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 {719 if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 {
705 return id, px, py720 return id, px, py
706 }721 }
707- if sc.mon.overview {722+ if sc.mon.ov.open {
708 // Previews and the layers under them take no input: a click723 // Previews and the layers under them take no input: a click
709 // picks a preview (overviewClick).724 // picks a preview (overviewClick).
710 return 0, 0, 0725 return 0, 0, 0
711 }726 }
712 var id WindowID727 var id WindowID
713 var sx, sy float64728 var sx, sy float64
714- full := false
715 for _, p := range sc.mon.Layout() {729 for _, p := range sc.mon.Layout() {
716 r := c.clientRect(p)730 r := c.clientRect(p)
717 // A peek is clickable wherever it shows, border included: it may731 // A peek is clickable wherever it shows, border included: it may
@@ -724,18 +738,10 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
724 if p.Peek {738 if p.Peek {
725 lx, ly = min(max(lx, float64(r.X)), float64(r.X+r.W-1)), min(max(ly, float64(r.Y)), float64(r.Y+r.H-1))739 lx, ly = min(max(lx, float64(r.X)), float64(r.X+r.W-1)), min(max(ly, float64(r.Y)), float64(r.Y+r.H-1))
726 }740 }
727- // Floating windows come last in the layout and are on top,741+ if c.acceptsInput(p.ID, lx-float64(r.X), ly-float64(r.Y)) {
728- // even of a fullscreen window; nothing else is.742+ // Layout is bottom to top, including fullscreen and floats.
729- if full && !p.Floating {
730- continue
731- }
732- if !c.acceptsInput(p.ID, lx-float64(r.X), ly-float64(r.Y)) {
733- continue
734- }
735- if id == 0 || p.Fullscreen || p.Floating {
736 id, sx, sy = p.ID, lx-float64(r.X), ly-float64(r.Y)743 id, sx, sy = p.ID, lx-float64(r.X), ly-float64(r.Y)
737 }744 }
738- full = full || p.Fullscreen
739 }745 }
740 }746 }
741 if id == 0 {747 if id == 0 {
@@ -1024,7 +1030,7 @@ func (c *Core) Run(ctx context.Context) error {
1024 continue1030 continue
1025 case ports.PointerAxis:1031 case ports.PointerAxis:
1026 // In the overview, scrolling moves the selection.1032 // In the overview, scrolling moves the selection.
1027- if c.cur().mon.overview && !c.overviewKeyboardTaken() {1033+ if c.cur().mon.ov.open && !c.overviewKeyboardTaken() {
1028 if c.cur().mon.overviewScroll(v) {1034 if c.cur().mon.overviewScroll(v) {
1029 if c.workspaceVisible(ctx, true) != nil {1035 if c.workspaceVisible(ctx, true) != nil {
1030 return nil1036 return nil
@@ -1066,7 +1072,7 @@ func (c *Core) Run(ctx context.Context) error {
1066 // The overview takes its keys before any window; others still1072 // The overview takes its keys before any window; others still
1067 // run binds, and are not forwarded.1073 // run binds, and are not forwarded.
1068 // A launcher or a menu holding the keyboard gets them first.1074 // A launcher or a menu holding the keyboard gets them first.
1069- if mon := c.cur().mon; mon.overview && !c.overviewKeyboardTaken() {1075+ if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() {
1070 if key.Pressed && mon.overviewKey(key) {1076 if key.Pressed && mon.overviewKey(key) {
1071 c.pressed[heldKey(key)] = true1077 c.pressed[heldKey(key)] = true
1072 if c.workspaceVisible(ctx, true) != nil {1078 if c.workspaceVisible(ctx, true) != nil {
@@ -1172,7 +1178,7 @@ func (c *Core) Run(ctx context.Context) error {
1172 }1178 }
1173 c.pressed[held] = false1179 c.pressed[held] = false
1174 }1180 }
1175- if id := c.keyboardFocus(); id != 0 && (!c.cur().mon.overview || c.overviewKeyboardTaken()) {1181+ if id := c.keyboardFocus(); id != 0 && (!c.cur().mon.ov.open || c.overviewKeyboardTaken()) {
1176 if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil {1182 if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil {
1177 return nil1183 return nil
1178 }1184 }
M internal/core/expel.go +3 -3
@@ -38,7 +38,7 @@ func (w *Workspace) ConsumeOrExpel(dir int) bool {
3838 // Consume at the edge: move-column already crosses monitors.
3939 case len(col.Windows) == 1:
4040 // The column goes away, and a slot with it.
41- w.stack(id, w.Columns[next].Windows[0])
41+ w.stackWindow(id, w.Columns[next].Windows[0])
4242 case !w.full():
4343 c := Column{Windows: []WindowID{id}, Width: col.Width}
4444 if col.Focus == 0 && col.Slot != 0 {
@@ -53,14 +53,14 @@ func (w *Workspace) ConsumeOrExpel(dir int) bool {
5353 case edge:
5454 return false
5555 default:
56- w.stack(id, w.Columns[next].Windows[0])
56+ w.stackWindow(id, w.Columns[next].Windows[0])
5757 }
5858 return true
5959 }
6060
6161 // stack moves tiled window id to the bottom of the column holding target
6262 // and focuses it. Leaving a slot column's first row releases the slot.
63-func (w *Workspace) stack(id, target WindowID) {
63+func (w *Workspace) stackWindow(id, target WindowID) {
6464 w.dropSlotOf(id)
6565 w.RemoveWindow(id)
6666 i := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, target) })
@@ -38,7 +38,7 @@ func (w *Workspace) ConsumeOrExpel(dir int) bool {
38 // Consume at the edge: move-column already crosses monitors.38 // Consume at the edge: move-column already crosses monitors.
39 case len(col.Windows) == 1:39 case len(col.Windows) == 1:
40 // The column goes away, and a slot with it.40 // The column goes away, and a slot with it.
41- w.stack(id, w.Columns[next].Windows[0])41+ w.stackWindow(id, w.Columns[next].Windows[0])
42 case !w.full():42 case !w.full():
43 c := Column{Windows: []WindowID{id}, Width: col.Width}43 c := Column{Windows: []WindowID{id}, Width: col.Width}
44 if col.Focus == 0 && col.Slot != 0 {44 if col.Focus == 0 && col.Slot != 0 {
@@ -53,14 +53,14 @@ func (w *Workspace) ConsumeOrExpel(dir int) bool {
53 case edge:53 case edge:
54 return false54 return false
55 default:55 default:
56- w.stack(id, w.Columns[next].Windows[0])56+ w.stackWindow(id, w.Columns[next].Windows[0])
57 }57 }
58 return true58 return true
59 }59 }
60 60
61 // stack moves tiled window id to the bottom of the column holding target61 // stack moves tiled window id to the bottom of the column holding target
62 // and focuses it. Leaving a slot column's first row releases the slot.62 // and focuses it. Leaving a slot column's first row releases the slot.
63-func (w *Workspace) stack(id, target WindowID) {63+func (w *Workspace) stackWindow(id, target WindowID) {
64 w.dropSlotOf(id)64 w.dropSlotOf(id)
65 w.RemoveWindow(id)65 w.RemoveWindow(id)
66 i := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, target) })66 i := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, target) })
A internal/core/float_core_test.go +63 -0
@@ -0,0 +1,63 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestCoreCoveringFloatSceneAndHit(t *testing.T) {
11+ cfg := ports.Config{}
12+ cfg.Keyboard.CmdKey = "super"
13+ cfg.Layout.MaxColumns = 2
14+ cfg.Border.Width = 2
15+ cfg.Floating.Dim = 0.3
16+ scenes := make(chan []ports.Scene, 1)
17+ commands := make(chan ports.ClientCommand, 128)
18+ c, err := New(cfg, Channels{Scenes: scenes, Commands: commands})
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ c.cur().info = ports.OutputInfo{Name: "OUT", Width: 100, Height: 80}
23+ m := c.cur().mon
24+ m.SetOutput(100, 80)
25+ w := m.Current()
26+ w.AddWindow(1)
27+ w.AddFloating(2, 100, 80)
28+ ctx := context.Background()
29+ check := func(dim float64, ids ...WindowID) {
30+ t.Helper()
31+ if err := c.publish(ctx); err != nil {
32+ t.Fatal(err)
33+ }
34+ s := (<-scenes)[0]
35+ if s.Dim != dim {
36+ t.Fatalf("dim %v, want %v", s.Dim, dim)
37+ }
38+ if len(s.Windows) != len(ids) {
39+ t.Fatalf("scene %+v", s.Windows)
40+ }
41+ for i, id := range ids {
42+ if s.Windows[i].ID != id {
43+ t.Fatalf("scene %+v, want %v", s.Windows, ids)
44+ }
45+ }
46+ }
47+ check(0.3, 1, 2)
48+ w.FocusID(1)
49+ check(0, 2, 1)
50+ if !w.Layout()[0].Below {
51+ t.Fatal("float not below")
52+ }
53+ if id, _, _ := c.hit(50, 40); id != 1 {
54+ t.Fatalf("hit %d, want tile", id)
55+ }
56+ w.AddFloating(3, 12, 10)
57+ check(0.3, 2, 1, 3)
58+ if id, _, _ := c.hit(50, 40); id != 3 {
59+ t.Fatalf("hit %d, want dialog", id)
60+ }
61+ w.FocusID(2)
62+ check(0.3, 1, 2, 3)
63+}
@@ -0,0 +1,63 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestCoreCoveringFloatSceneAndHit(t *testing.T) {
11+ cfg := ports.Config{}
12+ cfg.Keyboard.CmdKey = "super"
13+ cfg.Layout.MaxColumns = 2
14+ cfg.Border.Width = 2
15+ cfg.Floating.Dim = 0.3
16+ scenes := make(chan []ports.Scene, 1)
17+ commands := make(chan ports.ClientCommand, 128)
18+ c, err := New(cfg, Channels{Scenes: scenes, Commands: commands})
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ c.cur().info = ports.OutputInfo{Name: "OUT", Width: 100, Height: 80}
23+ m := c.cur().mon
24+ m.SetOutput(100, 80)
25+ w := m.Current()
26+ w.AddWindow(1)
27+ w.AddFloating(2, 100, 80)
28+ ctx := context.Background()
29+ check := func(dim float64, ids ...WindowID) {
30+ t.Helper()
31+ if err := c.publish(ctx); err != nil {
32+ t.Fatal(err)
33+ }
34+ s := (<-scenes)[0]
35+ if s.Dim != dim {
36+ t.Fatalf("dim %v, want %v", s.Dim, dim)
37+ }
38+ if len(s.Windows) != len(ids) {
39+ t.Fatalf("scene %+v", s.Windows)
40+ }
41+ for i, id := range ids {
42+ if s.Windows[i].ID != id {
43+ t.Fatalf("scene %+v, want %v", s.Windows, ids)
44+ }
45+ }
46+ }
47+ check(0.3, 1, 2)
48+ w.FocusID(1)
49+ check(0, 2, 1)
50+ if !w.Layout()[0].Below {
51+ t.Fatal("float not below")
52+ }
53+ if id, _, _ := c.hit(50, 40); id != 1 {
54+ t.Fatalf("hit %d, want tile", id)
55+ }
56+ w.AddFloating(3, 12, 10)
57+ check(0.3, 2, 1, 3)
58+ if id, _, _ := c.hit(50, 40); id != 3 {
59+ t.Fatalf("hit %d, want dialog", id)
60+ }
61+ w.FocusID(2)
62+ check(0.3, 1, 2, 3)
63+}
A internal/core/float_order_test.go +290 -0
@@ -0,0 +1,290 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+func order(w *Workspace) []WindowID {
9+ ids := []WindowID{}
10+ for _, p := range w.Layout() {
11+ if !p.Hidden {
12+ ids = append(ids, p.ID)
13+ }
14+ }
15+ return ids
16+}
17+
18+func wantOrder(t *testing.T, w *Workspace, want ...WindowID) {
19+ t.Helper()
20+ if got := order(w); !slices.Equal(got, want) {
21+ t.Fatalf("order %v, want %v", got, want)
22+ }
23+}
24+
25+func TestCoveringFloatOrder(t *testing.T) {
26+ for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} {
27+ t.Run(string(overflow), func(t *testing.T) {
28+ w := workspace()
29+ w.Overflow = overflow
30+ w.border = 2
31+ w.AddWindow(1)
32+ w.AddWindow(2)
33+ w.AddFloating(3, 98, 79) // placed rect fills the 100x81 usable area
34+ wantOrder(t, w, 1, 2, 3)
35+ w.FocusID(2)
36+ wantOrder(t, w, 3, 1, 2)
37+ if id, _ := w.Focused(); id != 2 {
38+ t.Fatalf("focus %d", id)
39+ }
40+ w.RemoveWindow(2)
41+ wantOrder(t, w, 3, 1)
42+ if id, _ := w.Focused(); id != 1 {
43+ t.Fatalf("after close focus %d", id)
44+ }
45+ w.AddFloating(4, 20, 20) // new dialogs map on top
46+ wantOrder(t, w, 3, 1, 4)
47+ w.FocusID(3)
48+ wantOrder(t, w, 1, 3, 4) // user-selected float rises only within its allowed group
49+ w.FocusID(1)
50+ wantOrder(t, w, 3, 1, 4)
51+ w.ResizeFloating(3, 99, 80)
52+ wantOrder(t, w, 3, 1, 4)
53+ w.SetOutput(102, 83)
54+ w.SetUsable(Rect{W: 102, H: 83})
55+ wantOrder(t, w, 3, 1, 4)
56+ w.ResizeFloating(3, 20, 20)
57+ wantOrder(t, w, 1, 4, 3)
58+ })
59+ }
60+}
61+
62+func TestCoveringFloatGeometryAndGroup(t *testing.T) {
63+ w := workspace()
64+ w.border = 2
65+ w.AddWindow(1)
66+ w.AddFloating(2, 94, 75) // two border widths plus two pixels on each axis
67+ if !w.coversFloat(w.Floats[0]) {
68+ t.Fatal("rounding tolerance")
69+ }
70+ w.FocusID(1)
71+ wantOrder(t, w, 2, 1)
72+ w.SetUsable(Rect{X: 5, Y: 4, W: 90, H: 73})
73+ wantOrder(t, w, 2, 1)
74+ w.SetUsable(Rect{W: 100, H: 81})
75+ wantOrder(t, w, 2, 1)
76+ w.border = 0
77+ w.reconcileFloats()
78+ wantOrder(t, w, 1, 2)
79+}
80+
81+func TestDemotedFloatFullscreenRoundTrip(t *testing.T) {
82+ m := monitor()
83+ m.SetBorder(2)
84+ for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} {
85+ m = monitor()
86+ m.SetBorder(2)
87+ m.Current().Overflow = overflow
88+ m.AddWindow(1)
89+ m.AddFloating(2, 100, 80)
90+ w := m.Current()
91+ w.FocusID(1)
92+ wantOrder(t, w, 2, 1)
93+ m.SetFullscreen(2, true)
94+ if overflow == OverflowFixed {
95+ fs := m.Workspaces[1]
96+ if fs.origin != w || fs.back.float == nil || !fs.back.float.below {
97+ t.Fatalf("fullscreen origin %+v", fs.back)
98+ }
99+ m.SetFullscreen(2, false)
100+ } else {
101+ if p := placement(w, 2); !p.Fullscreen {
102+ t.Fatalf("fullscreen %+v", p)
103+ }
104+ m.SetFullscreen(2, false)
105+ }
106+ wantOrder(t, w, 2, 1)
107+ if id, _ := w.Focused(); id != 1 {
108+ t.Fatalf("focus %d", id)
109+ }
110+ }
111+}
112+
113+func TestDemotedFloatMoveAndAdopt(t *testing.T) {
114+ m := monitor()
115+ m.SetBorder(2)
116+ m.AddWindow(1)
117+ m.AddFloating(2, 100, 80)
118+ w := m.Current()
119+ w.FocusID(1)
120+ other := monitor()
121+ other.SetOutput(102, 82)
122+ other.SetBorder(2)
123+ m.take(w)
124+ other.adopt(w, false, 0)
125+ if !w.Floats[0].below {
126+ t.Fatal("adopt raised float")
127+ }
128+ wantOrder(t, w, 2, 1)
129+ // A user moving the selected float to a workspace raises it there.
130+ other.show(w)
131+ w.FocusID(2)
132+ other.MoveToWorkspace(1, false)
133+ if w.has(2) || !other.Workspaces[1].has(2) {
134+ t.Fatal("move lost float")
135+ }
136+}
137+
138+// Up from the top of a column raises the last demoted covering float;
139+// down leaves it for the columns again without changing workspaces.
140+func TestFocusWindowReturnsToDemotedCoveringFloat(t *testing.T) {
141+ for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} {
142+ t.Run(string(overflow), func(t *testing.T) {
143+ m := monitor()
144+ m.SetOverflow(overflow)
145+ m.SetBorder(2)
146+ m.AddWindow(1) // kitty
147+ m.AddFloating(2, 100, 80) // covering game
148+ m.AddFloating(3, 100, 80) // last demoted float is on top
149+ w := m.Current()
150+ w.FocusID(1)
151+ wantOrder(t, w, 2, 3, 1)
152+ m.Apply(ActionFocusWindowUp)
153+ if id, _ := w.Focused(); id != 3 || m.Active != 0 || w.Floats[len(w.Floats)-1].below {
154+ t.Fatalf("up focused %d on workspace %d, floats %+v", id, m.Active, w.Floats)
155+ }
156+ wantOrder(t, w, 2, 1, 3)
157+ m.Apply(ActionFocusWindowDown)
158+ if id, _ := w.Focused(); id != 1 || m.Active != 0 {
159+ t.Fatalf("down focused %d on workspace %d", id, m.Active)
160+ }
161+ wantOrder(t, w, 2, 3, 1)
162+ })
163+ }
164+}
165+
166+func TestFocusWindowDemotedFloatAfterColumnAndScreenNeighbors(t *testing.T) {
167+ m := monitor()
168+ m.SetOverflow(OverflowFixed)
169+ m.SetBorder(2)
170+ m.AddWindow(1)
171+ m.AddWindow(2)
172+ w := m.Current()
173+ w.ConsumeOrExpel(-1) // two windows in the same column
174+ w.AddFloating(9, 100, 80)
175+ w.FocusID(1) // top of the column
176+ w.FocusID(2) // bottom of the column
177+ if !w.FocusWindow(-1) {
178+ t.Fatal("up inside column failed")
179+ }
180+ if id, _ := w.Focused(); id != 1 || !w.Floats[0].below {
181+ t.Fatalf("up inside column focused %d, float %+v", id, w.Floats[0])
182+ }
183+ if !w.FocusWindow(-1) {
184+ t.Fatal("up to float failed")
185+ }
186+ if id, _ := w.Focused(); id != 9 {
187+ t.Fatalf("up to float focused %d", id)
188+ }
189+
190+ m = monitor()
191+ m.SetOverflow(OverflowFixed)
192+ m.SetMaxColumns(2)
193+ for id := WindowID(1); id <= 4; id++ {
194+ m.AddWindow(id)
195+ }
196+ w = m.Current()
197+ w.AddFloating(9, 100, 80)
198+ w.FocusID(4)
199+ if !w.FocusWindow(-1) {
200+ t.Fatal("up to on-screen column failed")
201+ }
202+ if id, _ := w.Focused(); id != 2 || !w.Floats[0].below {
203+ t.Fatalf("up focused %d, float %+v", id, w.Floats[0])
204+ }
205+}
206+
207+func TestFocusWindowWithoutDemotedFloatChangesWorkspace(t *testing.T) {
208+ m := monitor()
209+ m.AddWindow(1)
210+ m.Focus(1)
211+ m.AddWindow(2)
212+ m.Apply(ActionFocusWindowUp)
213+ if m.Active != 0 {
214+ t.Fatalf("up on workspace %d", m.Active)
215+ }
216+}
217+
218+// Up from a shown stash stays in the stash, even with a demoted covering
219+// float; from a pinned fullscreen window it goes to the workspace above,
220+// as without covering floats.
221+func TestFocusWindowUpStashAndPinned(t *testing.T) {
222+ m := monitor()
223+ m.SetBorder(2)
224+ m.AddWindow(1)
225+ m.AddWindow(2)
226+ w := m.Current()
227+ w.AddFloating(9, 100, 80)
228+ w.FocusID(2)
229+ w.ToggleWindowStash() // 2 to the shown stash, focused
230+ if !w.Floats[0].below {
231+ t.Fatalf("float not below: %+v", w.Floats[0])
232+ }
233+ m.Apply(ActionFocusWindowUp)
234+ if id, _ := w.Focused(); id != 2 || !w.Floats[0].below || m.Active != 0 {
235+ t.Fatalf("stash up focused %d on %d, float %+v", id, m.Active, w.Floats[0])
236+ }
237+
238+ m = monitor()
239+ m.SetOverflow(OverflowFixed)
240+ m.AddWindow(1)
241+ m.AddFloating(2, 50, 40)
242+ m.SetFullscreen(2, true) // own workspace below
243+ if m.Active != 1 || !m.Current().pinned() {
244+ t.Fatalf("setup: active %d", m.Active)
245+ }
246+ m.Apply(ActionFocusWindowUp)
247+ if id, _ := m.Current().Focused(); id != 1 || m.Active != 0 {
248+ t.Fatalf("pinned up focused %d on %d", id, m.Active)
249+ }
250+}
251+
252+// Directional focus on a covering float with nothing under it keeps the
253+// float focused.
254+func TestCoveringFloatAloneKeepsFocus(t *testing.T) {
255+ w := workspace()
256+ w.border = 2
257+ w.AddFloating(3, 98, 79)
258+ w.FocusColumn(-1)
259+ w.FocusWindow(1)
260+ if id, ok := w.Focused(); !ok || id != 3 {
261+ t.Fatalf("focus %d %v", id, ok)
262+ }
263+ wantOrder(t, w, 3)
264+}
265+
266+// A tile returning from its own fullscreen workspace with the focus comes
267+// back in front of a covering float mapped in its origin meanwhile.
268+func TestFullscreenReturnRaisesColumns(t *testing.T) {
269+ for _, stacked := range []bool{false, true} {
270+ m := monitor()
271+ m.SetBorder(2)
272+ m.SetOverflow(OverflowFixed)
273+ m.AddWindow(1)
274+ m.AddWindow(2)
275+ if stacked {
276+ m.Current().ConsumeOrExpel(-1)
277+ }
278+ w := m.Current()
279+ w.FocusID(2)
280+ m.ToggleFullscreen() // 2 to its own workspace
281+ w.AddFloating(3, 100, 80)
282+ m.ToggleFullscreen() // back, focused
283+ if id, _ := w.Focused(); id != 2 {
284+ t.Fatalf("stacked %v: focus %d", stacked, id)
285+ }
286+ if got := order(w); got[0] != 3 {
287+ t.Fatalf("stacked %v: order %v, covering float not below", stacked, got)
288+ }
289+ }
290+}
@@ -0,0 +1,290 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+func order(w *Workspace) []WindowID {
9+ ids := []WindowID{}
10+ for _, p := range w.Layout() {
11+ if !p.Hidden {
12+ ids = append(ids, p.ID)
13+ }
14+ }
15+ return ids
16+}
17+
18+func wantOrder(t *testing.T, w *Workspace, want ...WindowID) {
19+ t.Helper()
20+ if got := order(w); !slices.Equal(got, want) {
21+ t.Fatalf("order %v, want %v", got, want)
22+ }
23+}
24+
25+func TestCoveringFloatOrder(t *testing.T) {
26+ for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} {
27+ t.Run(string(overflow), func(t *testing.T) {
28+ w := workspace()
29+ w.Overflow = overflow
30+ w.border = 2
31+ w.AddWindow(1)
32+ w.AddWindow(2)
33+ w.AddFloating(3, 98, 79) // placed rect fills the 100x81 usable area
34+ wantOrder(t, w, 1, 2, 3)
35+ w.FocusID(2)
36+ wantOrder(t, w, 3, 1, 2)
37+ if id, _ := w.Focused(); id != 2 {
38+ t.Fatalf("focus %d", id)
39+ }
40+ w.RemoveWindow(2)
41+ wantOrder(t, w, 3, 1)
42+ if id, _ := w.Focused(); id != 1 {
43+ t.Fatalf("after close focus %d", id)
44+ }
45+ w.AddFloating(4, 20, 20) // new dialogs map on top
46+ wantOrder(t, w, 3, 1, 4)
47+ w.FocusID(3)
48+ wantOrder(t, w, 1, 3, 4) // user-selected float rises only within its allowed group
49+ w.FocusID(1)
50+ wantOrder(t, w, 3, 1, 4)
51+ w.ResizeFloating(3, 99, 80)
52+ wantOrder(t, w, 3, 1, 4)
53+ w.SetOutput(102, 83)
54+ w.SetUsable(Rect{W: 102, H: 83})
55+ wantOrder(t, w, 3, 1, 4)
56+ w.ResizeFloating(3, 20, 20)
57+ wantOrder(t, w, 1, 4, 3)
58+ })
59+ }
60+}
61+
62+func TestCoveringFloatGeometryAndGroup(t *testing.T) {
63+ w := workspace()
64+ w.border = 2
65+ w.AddWindow(1)
66+ w.AddFloating(2, 94, 75) // two border widths plus two pixels on each axis
67+ if !w.coversFloat(w.Floats[0]) {
68+ t.Fatal("rounding tolerance")
69+ }
70+ w.FocusID(1)
71+ wantOrder(t, w, 2, 1)
72+ w.SetUsable(Rect{X: 5, Y: 4, W: 90, H: 73})
73+ wantOrder(t, w, 2, 1)
74+ w.SetUsable(Rect{W: 100, H: 81})
75+ wantOrder(t, w, 2, 1)
76+ w.border = 0
77+ w.reconcileFloats()
78+ wantOrder(t, w, 1, 2)
79+}
80+
81+func TestDemotedFloatFullscreenRoundTrip(t *testing.T) {
82+ m := monitor()
83+ m.SetBorder(2)
84+ for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} {
85+ m = monitor()
86+ m.SetBorder(2)
87+ m.Current().Overflow = overflow
88+ m.AddWindow(1)
89+ m.AddFloating(2, 100, 80)
90+ w := m.Current()
91+ w.FocusID(1)
92+ wantOrder(t, w, 2, 1)
93+ m.SetFullscreen(2, true)
94+ if overflow == OverflowFixed {
95+ fs := m.Workspaces[1]
96+ if fs.origin != w || fs.back.float == nil || !fs.back.float.below {
97+ t.Fatalf("fullscreen origin %+v", fs.back)
98+ }
99+ m.SetFullscreen(2, false)
100+ } else {
101+ if p := placement(w, 2); !p.Fullscreen {
102+ t.Fatalf("fullscreen %+v", p)
103+ }
104+ m.SetFullscreen(2, false)
105+ }
106+ wantOrder(t, w, 2, 1)
107+ if id, _ := w.Focused(); id != 1 {
108+ t.Fatalf("focus %d", id)
109+ }
110+ }
111+}
112+
113+func TestDemotedFloatMoveAndAdopt(t *testing.T) {
114+ m := monitor()
115+ m.SetBorder(2)
116+ m.AddWindow(1)
117+ m.AddFloating(2, 100, 80)
118+ w := m.Current()
119+ w.FocusID(1)
120+ other := monitor()
121+ other.SetOutput(102, 82)
122+ other.SetBorder(2)
123+ m.take(w)
124+ other.adopt(w, false, 0)
125+ if !w.Floats[0].below {
126+ t.Fatal("adopt raised float")
127+ }
128+ wantOrder(t, w, 2, 1)
129+ // A user moving the selected float to a workspace raises it there.
130+ other.show(w)
131+ w.FocusID(2)
132+ other.MoveToWorkspace(1, false)
133+ if w.has(2) || !other.Workspaces[1].has(2) {
134+ t.Fatal("move lost float")
135+ }
136+}
137+
138+// Up from the top of a column raises the last demoted covering float;
139+// down leaves it for the columns again without changing workspaces.
140+func TestFocusWindowReturnsToDemotedCoveringFloat(t *testing.T) {
141+ for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} {
142+ t.Run(string(overflow), func(t *testing.T) {
143+ m := monitor()
144+ m.SetOverflow(overflow)
145+ m.SetBorder(2)
146+ m.AddWindow(1) // kitty
147+ m.AddFloating(2, 100, 80) // covering game
148+ m.AddFloating(3, 100, 80) // last demoted float is on top
149+ w := m.Current()
150+ w.FocusID(1)
151+ wantOrder(t, w, 2, 3, 1)
152+ m.Apply(ActionFocusWindowUp)
153+ if id, _ := w.Focused(); id != 3 || m.Active != 0 || w.Floats[len(w.Floats)-1].below {
154+ t.Fatalf("up focused %d on workspace %d, floats %+v", id, m.Active, w.Floats)
155+ }
156+ wantOrder(t, w, 2, 1, 3)
157+ m.Apply(ActionFocusWindowDown)
158+ if id, _ := w.Focused(); id != 1 || m.Active != 0 {
159+ t.Fatalf("down focused %d on workspace %d", id, m.Active)
160+ }
161+ wantOrder(t, w, 2, 3, 1)
162+ })
163+ }
164+}
165+
166+func TestFocusWindowDemotedFloatAfterColumnAndScreenNeighbors(t *testing.T) {
167+ m := monitor()
168+ m.SetOverflow(OverflowFixed)
169+ m.SetBorder(2)
170+ m.AddWindow(1)
171+ m.AddWindow(2)
172+ w := m.Current()
173+ w.ConsumeOrExpel(-1) // two windows in the same column
174+ w.AddFloating(9, 100, 80)
175+ w.FocusID(1) // top of the column
176+ w.FocusID(2) // bottom of the column
177+ if !w.FocusWindow(-1) {
178+ t.Fatal("up inside column failed")
179+ }
180+ if id, _ := w.Focused(); id != 1 || !w.Floats[0].below {
181+ t.Fatalf("up inside column focused %d, float %+v", id, w.Floats[0])
182+ }
183+ if !w.FocusWindow(-1) {
184+ t.Fatal("up to float failed")
185+ }
186+ if id, _ := w.Focused(); id != 9 {
187+ t.Fatalf("up to float focused %d", id)
188+ }
189+
190+ m = monitor()
191+ m.SetOverflow(OverflowFixed)
192+ m.SetMaxColumns(2)
193+ for id := WindowID(1); id <= 4; id++ {
194+ m.AddWindow(id)
195+ }
196+ w = m.Current()
197+ w.AddFloating(9, 100, 80)
198+ w.FocusID(4)
199+ if !w.FocusWindow(-1) {
200+ t.Fatal("up to on-screen column failed")
201+ }
202+ if id, _ := w.Focused(); id != 2 || !w.Floats[0].below {
203+ t.Fatalf("up focused %d, float %+v", id, w.Floats[0])
204+ }
205+}
206+
207+func TestFocusWindowWithoutDemotedFloatChangesWorkspace(t *testing.T) {
208+ m := monitor()
209+ m.AddWindow(1)
210+ m.Focus(1)
211+ m.AddWindow(2)
212+ m.Apply(ActionFocusWindowUp)
213+ if m.Active != 0 {
214+ t.Fatalf("up on workspace %d", m.Active)
215+ }
216+}
217+
218+// Up from a shown stash stays in the stash, even with a demoted covering
219+// float; from a pinned fullscreen window it goes to the workspace above,
220+// as without covering floats.
221+func TestFocusWindowUpStashAndPinned(t *testing.T) {
222+ m := monitor()
223+ m.SetBorder(2)
224+ m.AddWindow(1)
225+ m.AddWindow(2)
226+ w := m.Current()
227+ w.AddFloating(9, 100, 80)
228+ w.FocusID(2)
229+ w.ToggleWindowStash() // 2 to the shown stash, focused
230+ if !w.Floats[0].below {
231+ t.Fatalf("float not below: %+v", w.Floats[0])
232+ }
233+ m.Apply(ActionFocusWindowUp)
234+ if id, _ := w.Focused(); id != 2 || !w.Floats[0].below || m.Active != 0 {
235+ t.Fatalf("stash up focused %d on %d, float %+v", id, m.Active, w.Floats[0])
236+ }
237+
238+ m = monitor()
239+ m.SetOverflow(OverflowFixed)
240+ m.AddWindow(1)
241+ m.AddFloating(2, 50, 40)
242+ m.SetFullscreen(2, true) // own workspace below
243+ if m.Active != 1 || !m.Current().pinned() {
244+ t.Fatalf("setup: active %d", m.Active)
245+ }
246+ m.Apply(ActionFocusWindowUp)
247+ if id, _ := m.Current().Focused(); id != 1 || m.Active != 0 {
248+ t.Fatalf("pinned up focused %d on %d", id, m.Active)
249+ }
250+}
251+
252+// Directional focus on a covering float with nothing under it keeps the
253+// float focused.
254+func TestCoveringFloatAloneKeepsFocus(t *testing.T) {
255+ w := workspace()
256+ w.border = 2
257+ w.AddFloating(3, 98, 79)
258+ w.FocusColumn(-1)
259+ w.FocusWindow(1)
260+ if id, ok := w.Focused(); !ok || id != 3 {
261+ t.Fatalf("focus %d %v", id, ok)
262+ }
263+ wantOrder(t, w, 3)
264+}
265+
266+// A tile returning from its own fullscreen workspace with the focus comes
267+// back in front of a covering float mapped in its origin meanwhile.
268+func TestFullscreenReturnRaisesColumns(t *testing.T) {
269+ for _, stacked := range []bool{false, true} {
270+ m := monitor()
271+ m.SetBorder(2)
272+ m.SetOverflow(OverflowFixed)
273+ m.AddWindow(1)
274+ m.AddWindow(2)
275+ if stacked {
276+ m.Current().ConsumeOrExpel(-1)
277+ }
278+ w := m.Current()
279+ w.FocusID(2)
280+ m.ToggleFullscreen() // 2 to its own workspace
281+ w.AddFloating(3, 100, 80)
282+ m.ToggleFullscreen() // back, focused
283+ if id, _ := w.Focused(); id != 2 {
284+ t.Fatalf("stacked %v: focus %d", stacked, id)
285+ }
286+ if got := order(w); got[0] != 3 {
287+ t.Fatalf("stacked %v: order %v, covering float not below", stacked, got)
288+ }
289+ }
290+}
A internal/core/float_review_regression_test.go +146 -0
@@ -0,0 +1,146 @@
1+package core
2+
3+import "testing"
4+
5+func TestFullscreenFloatReturnPreservesDialogFocus(t *testing.T) {
6+ for _, covering := range []bool{false, true} {
7+ m := monitor()
8+ m.SetOverflow(OverflowFixed)
9+ m.AddWindow(1)
10+ size := 30
11+ if covering {
12+ size = 100
13+ }
14+ m.AddFloating(2, size, size)
15+ w := m.Current()
16+ if covering {
17+ w.FocusID(1)
18+ }
19+ m.SetFullscreen(2, true)
20+ m.show(w)
21+ m.AddFloating(3, 20, 20)
22+ m.SetFullscreen(2, false)
23+ if id, _ := w.Focused(); id != 3 || !w.floatFocus || w.Floats[len(w.Floats)-1].ID != 3 {
24+ t.Fatalf("covering %v: focus %d, float focus %v, floats %+v", covering, id, w.floatFocus, w.Floats)
25+ }
26+ }
27+}
28+
29+func TestDemotedFloatFullscreenUserReturn(t *testing.T) {
30+ m := monitor()
31+ m.SetOverflow(OverflowFixed)
32+ m.AddWindow(1)
33+ m.AddFloating(2, 100, 80)
34+ w := m.Current()
35+ w.FocusID(1)
36+ m.SetFullscreen(2, true)
37+ m.Focus(1)
38+ m.Current().FocusID(2)
39+ m.SetFullscreen(2, false)
40+ if id, _ := w.Focused(); id != 2 || !w.floatFocus || w.Floats[0].below {
41+ t.Fatalf("returned focus %d floats %+v", id, w.Floats)
42+ }
43+}
44+
45+func TestSetNamedDemotedFloatKeepsColumnActions(t *testing.T) {
46+ m := monitor()
47+ m.SetNamed([]NamedWorkspace{{Name: "work"}})
48+ m.AddWindow(1)
49+ m.AddFloating(2, 100, 80)
50+ m.Current().FocusID(1)
51+ m.SetNamed(nil)
52+ w := m.Current()
53+ if id, _ := w.Focused(); id != 1 || w.onFloat() || w.floatFocus {
54+ t.Fatalf("focus %d float focus %v floats %+v", id, w.floatFocus, w.Floats)
55+ }
56+ w.ToggleFullWidth()
57+ if !w.Columns[w.Focus].FullWidth {
58+ t.Fatal("column action was blocked")
59+ }
60+}
61+
62+func TestFloatOnlyWorkspaceDirectionalExit(t *testing.T) {
63+ for _, dir := range []int{-1, 1} {
64+ m := monitor()
65+ m.AddWindow(1)
66+ m.Focus(1)
67+ m.AddFloating(2, 20, 20)
68+ m.Focus(2)
69+ m.AddWindow(3)
70+ m.Focus(1)
71+ a := ActionFocusWindowUp
72+ if dir > 0 {
73+ a = ActionFocusWindowDown
74+ }
75+ m.Apply(a)
76+ if m.Active != 1+dir {
77+ t.Fatalf("direction %d stayed on workspace %d", dir, m.Active)
78+ }
79+ }
80+}
81+
82+func TestOverviewMaximizedPreviewAfterAnchorRemoval(t *testing.T) {
83+ m := maximizedOverview()
84+ w := m.Current()
85+ w.Columns[1].Windows = append(w.Columns[1].Windows, 5)
86+ // Closing the maximization anchor leaves the surviving column maximized.
87+ w.RemoveWindow(2)
88+ m.ToggleOverview()
89+ p := previewOf(t, m.Layout(), 5)
90+ if p.Hidden || p.Preview <= 0 {
91+ t.Fatalf("preview %+v", p)
92+ }
93+ real := previewOf(t, w.Layout(), 5)
94+ if p.Rect.W != int(float64(real.Rect.W)*p.Preview+0.5) || p.Rect.H != int(float64(real.Rect.H)*p.Preview+0.5) {
95+ t.Fatalf("preview %+v real %+v", p, real)
96+ }
97+}
98+
99+func TestOverviewPickStackedColumnWindow(t *testing.T) {
100+ for _, hidden := range []bool{false, true} {
101+ m := maximizedOverview()
102+ w := m.Current()
103+ i := w.Focus
104+ if hidden {
105+ i = 2
106+ }
107+ w.Columns[i].Windows = append(w.Columns[i].Windows, 6)
108+ m.ToggleOverview()
109+ m.OverviewPick(6)
110+ if id, _ := w.Focused(); id != 6 || m.ov.open {
111+ t.Fatalf("hidden %v: picked focus %d, overview %v", hidden, id, m.ov.open)
112+ }
113+ }
114+}
115+
116+func TestOverviewTargetAfterColumnActivation(t *testing.T) {
117+ for _, maximized := range []bool{false, true} {
118+ m := overviewMonitor()
119+ w := m.Current()
120+ w.AddWindow(5)
121+ w.ConsumeOrExpel(-1)
122+ w.FocusID(3)
123+ if maximized {
124+ m.SetOverflow(OverflowFixed)
125+ w.ToggleFullWidth()
126+ }
127+ m.ToggleOverview()
128+ if maximized && w.stack()[0].kind != stackColumn {
129+ t.Fatalf("expected maximized column card: %+v", w.stack())
130+ }
131+ w.Activate(5)
132+ var highlighted WindowID
133+ for _, p := range m.Layout() {
134+ if p.Focused && !p.Hidden {
135+ highlighted = p.ID
136+ }
137+ }
138+ if highlighted == 0 || m.overviewTarget() != highlighted || m.Apply(ActionCloseWindow).Close != highlighted {
139+ t.Fatalf("maximized %v: highlight %d target %d", maximized, highlighted, m.overviewTarget())
140+ }
141+ m.ToggleOverview()
142+ if id, _ := w.Focused(); id != highlighted {
143+ t.Fatalf("accepted %d want %d", id, highlighted)
144+ }
145+ }
146+}
@@ -0,0 +1,146 @@
1+package core
2+
3+import "testing"
4+
5+func TestFullscreenFloatReturnPreservesDialogFocus(t *testing.T) {
6+ for _, covering := range []bool{false, true} {
7+ m := monitor()
8+ m.SetOverflow(OverflowFixed)
9+ m.AddWindow(1)
10+ size := 30
11+ if covering {
12+ size = 100
13+ }
14+ m.AddFloating(2, size, size)
15+ w := m.Current()
16+ if covering {
17+ w.FocusID(1)
18+ }
19+ m.SetFullscreen(2, true)
20+ m.show(w)
21+ m.AddFloating(3, 20, 20)
22+ m.SetFullscreen(2, false)
23+ if id, _ := w.Focused(); id != 3 || !w.floatFocus || w.Floats[len(w.Floats)-1].ID != 3 {
24+ t.Fatalf("covering %v: focus %d, float focus %v, floats %+v", covering, id, w.floatFocus, w.Floats)
25+ }
26+ }
27+}
28+
29+func TestDemotedFloatFullscreenUserReturn(t *testing.T) {
30+ m := monitor()
31+ m.SetOverflow(OverflowFixed)
32+ m.AddWindow(1)
33+ m.AddFloating(2, 100, 80)
34+ w := m.Current()
35+ w.FocusID(1)
36+ m.SetFullscreen(2, true)
37+ m.Focus(1)
38+ m.Current().FocusID(2)
39+ m.SetFullscreen(2, false)
40+ if id, _ := w.Focused(); id != 2 || !w.floatFocus || w.Floats[0].below {
41+ t.Fatalf("returned focus %d floats %+v", id, w.Floats)
42+ }
43+}
44+
45+func TestSetNamedDemotedFloatKeepsColumnActions(t *testing.T) {
46+ m := monitor()
47+ m.SetNamed([]NamedWorkspace{{Name: "work"}})
48+ m.AddWindow(1)
49+ m.AddFloating(2, 100, 80)
50+ m.Current().FocusID(1)
51+ m.SetNamed(nil)
52+ w := m.Current()
53+ if id, _ := w.Focused(); id != 1 || w.onFloat() || w.floatFocus {
54+ t.Fatalf("focus %d float focus %v floats %+v", id, w.floatFocus, w.Floats)
55+ }
56+ w.ToggleFullWidth()
57+ if !w.Columns[w.Focus].FullWidth {
58+ t.Fatal("column action was blocked")
59+ }
60+}
61+
62+func TestFloatOnlyWorkspaceDirectionalExit(t *testing.T) {
63+ for _, dir := range []int{-1, 1} {
64+ m := monitor()
65+ m.AddWindow(1)
66+ m.Focus(1)
67+ m.AddFloating(2, 20, 20)
68+ m.Focus(2)
69+ m.AddWindow(3)
70+ m.Focus(1)
71+ a := ActionFocusWindowUp
72+ if dir > 0 {
73+ a = ActionFocusWindowDown
74+ }
75+ m.Apply(a)
76+ if m.Active != 1+dir {
77+ t.Fatalf("direction %d stayed on workspace %d", dir, m.Active)
78+ }
79+ }
80+}
81+
82+func TestOverviewMaximizedPreviewAfterAnchorRemoval(t *testing.T) {
83+ m := maximizedOverview()
84+ w := m.Current()
85+ w.Columns[1].Windows = append(w.Columns[1].Windows, 5)
86+ // Closing the maximization anchor leaves the surviving column maximized.
87+ w.RemoveWindow(2)
88+ m.ToggleOverview()
89+ p := previewOf(t, m.Layout(), 5)
90+ if p.Hidden || p.Preview <= 0 {
91+ t.Fatalf("preview %+v", p)
92+ }
93+ real := previewOf(t, w.Layout(), 5)
94+ if p.Rect.W != int(float64(real.Rect.W)*p.Preview+0.5) || p.Rect.H != int(float64(real.Rect.H)*p.Preview+0.5) {
95+ t.Fatalf("preview %+v real %+v", p, real)
96+ }
97+}
98+
99+func TestOverviewPickStackedColumnWindow(t *testing.T) {
100+ for _, hidden := range []bool{false, true} {
101+ m := maximizedOverview()
102+ w := m.Current()
103+ i := w.Focus
104+ if hidden {
105+ i = 2
106+ }
107+ w.Columns[i].Windows = append(w.Columns[i].Windows, 6)
108+ m.ToggleOverview()
109+ m.OverviewPick(6)
110+ if id, _ := w.Focused(); id != 6 || m.ov.open {
111+ t.Fatalf("hidden %v: picked focus %d, overview %v", hidden, id, m.ov.open)
112+ }
113+ }
114+}
115+
116+func TestOverviewTargetAfterColumnActivation(t *testing.T) {
117+ for _, maximized := range []bool{false, true} {
118+ m := overviewMonitor()
119+ w := m.Current()
120+ w.AddWindow(5)
121+ w.ConsumeOrExpel(-1)
122+ w.FocusID(3)
123+ if maximized {
124+ m.SetOverflow(OverflowFixed)
125+ w.ToggleFullWidth()
126+ }
127+ m.ToggleOverview()
128+ if maximized && w.stack()[0].kind != stackColumn {
129+ t.Fatalf("expected maximized column card: %+v", w.stack())
130+ }
131+ w.Activate(5)
132+ var highlighted WindowID
133+ for _, p := range m.Layout() {
134+ if p.Focused && !p.Hidden {
135+ highlighted = p.ID
136+ }
137+ }
138+ if highlighted == 0 || m.overviewTarget() != highlighted || m.Apply(ActionCloseWindow).Close != highlighted {
139+ t.Fatalf("maximized %v: highlight %d target %d", maximized, highlighted, m.overviewTarget())
140+ }
141+ m.ToggleOverview()
142+ if id, _ := w.Focused(); id != highlighted {
143+ t.Fatalf("accepted %d want %d", id, highlighted)
144+ }
145+ }
146+}
M internal/core/gesture.go +3 -3
@@ -159,7 +159,7 @@ func (c *Core) decide(g *swipeGesture) {
159159 case g.fingers == 4:
160160 // Four fingers sideways do nothing.
161161 g.mode = swipeDropped
162- case m.overview:
162+ case m.ov.open:
163163 // The overview does not slide: the swipe moves its selection.
164164 g.mode = swipeDiscrete
165165 case g.horizontal && w.slidable():
@@ -238,7 +238,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
238238 m.switchMotion = newMotion(workspaceSpring(off, velocity), now)
239239 case swipeOverview:
240240 p := g.tracker.projectedEnd()
241- if e.Cancelled || math.Abs(p) < discreteSwipeMin || (p < 0) == m.overview {
241+ if e.Cancelled || math.Abs(p) < discreteSwipeMin || (p < 0) == m.ov.open {
242242 return false
243243 }
244244 before := m.Current()
@@ -267,7 +267,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
267267 }
268268 before := c.cur().mon.Current()
269269 c.keyboard.takeBack()
270- if mon := c.cur().mon; mon.overview {
270+ if mon := c.cur().mon; mon.ov.open {
271271 mon.overviewSwipe(a)
272272 } else {
273273 c.applyAction(a)
@@ -159,7 +159,7 @@ func (c *Core) decide(g *swipeGesture) {
159 case g.fingers == 4:159 case g.fingers == 4:
160 // Four fingers sideways do nothing.160 // Four fingers sideways do nothing.
161 g.mode = swipeDropped161 g.mode = swipeDropped
162- case m.overview:162+ case m.ov.open:
163 // The overview does not slide: the swipe moves its selection.163 // The overview does not slide: the swipe moves its selection.
164 g.mode = swipeDiscrete164 g.mode = swipeDiscrete
165 case g.horizontal && w.slidable():165 case g.horizontal && w.slidable():
@@ -238,7 +238,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
238 m.switchMotion = newMotion(workspaceSpring(off, velocity), now)238 m.switchMotion = newMotion(workspaceSpring(off, velocity), now)
239 case swipeOverview:239 case swipeOverview:
240 p := g.tracker.projectedEnd()240 p := g.tracker.projectedEnd()
241- if e.Cancelled || math.Abs(p) < discreteSwipeMin || (p < 0) == m.overview {241+ if e.Cancelled || math.Abs(p) < discreteSwipeMin || (p < 0) == m.ov.open {
242 return false242 return false
243 }243 }
244 before := m.Current()244 before := m.Current()
@@ -267,7 +267,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
267 }267 }
268 before := c.cur().mon.Current()268 before := c.cur().mon.Current()
269 c.keyboard.takeBack()269 c.keyboard.takeBack()
270- if mon := c.cur().mon; mon.overview {270+ if mon := c.cur().mon; mon.ov.open {
271 mon.overviewSwipe(a)271 mon.overviewSwipe(a)
272 } else {272 } else {
273 c.applyAction(a)273 c.applyAction(a)
M internal/core/layers.go +1 -1
@@ -90,7 +90,7 @@ func (c *Core) layerOf(id WindowID) (*screen, ports.SceneLayer, bool) {
9090 // scroll overflow, one scrolled off by the user no longer does; nor does
9191 // one shown as an overview preview.
9292 func hasFullscreen(sc *screen) bool {
93- return !sc.mon.overview && sc.mon.Current().cover() != 0
93+ return !sc.mon.ov.open && sc.mon.Current().cover() != 0
9494 }
9595
9696 // shown reports whether a layer surface is drawn. A fullscreen window is
@@ -90,7 +90,7 @@ func (c *Core) layerOf(id WindowID) (*screen, ports.SceneLayer, bool) {
90 // scroll overflow, one scrolled off by the user no longer does; nor does90 // scroll overflow, one scrolled off by the user no longer does; nor does
91 // one shown as an overview preview.91 // one shown as an overview preview.
92 func hasFullscreen(sc *screen) bool {92 func hasFullscreen(sc *screen) bool {
93- return !sc.mon.overview && sc.mon.Current().cover() != 093+ return !sc.mon.ov.open && sc.mon.Current().cover() != 0
94 }94 }
95 95
96 // shown reports whether a layer surface is drawn. A fullscreen window is96 // shown reports whether a layer surface is drawn. A fullscreen window is
M internal/core/layout.go +194 -48
@@ -96,8 +96,9 @@ type Placement struct {
9696 ID WindowID
9797 Rect Rect
9898 Fullscreen, Focused, Hidden bool
99- // Floating windows sit over the columns at their own size.
100- Floating bool
99+ // Floating windows sit at their own size. Below marks a native float
100+ // ordered behind the columns (never a stashed window).
101+ Floating, Below bool
101102 // Peek is a stashed window peeking in beside the selected one, or an
102103 // overview preview of a neighbor workspace: dimmed.
103104 Peek bool
@@ -129,8 +130,11 @@ type Workspace struct {
129130 Name string
130131 Overflow Overflow
131132 Columns []Column
132- Focus int
133- ViewX int
133+ // maximized remembers recently maximized columns by window ID, newest
134+ // first; stack() filters removed windows at read time.
135+ maximized []WindowID
136+ Focus int
137+ ViewX int
134138 // shift slides the columns on screen past ViewX, in logical pixels,
135139 // while a swipe follows the fingers or its spring (motion) lands.
136140 shift float64
@@ -143,8 +147,8 @@ type Workspace struct {
143147 border int
144148 presets []Width
145149 fullscreen WindowID
146- // Floats are native floating windows (dialogs), bottom to top;
147- // floatFocus is set while the top one has the focus.
150+ // Floats are native floating windows in user selection order;
151+ // below/above and dialog groups determine their layout order.
148152 Floats []Float
149153 floatFocus bool
150154 // Stash holds the windows set aside by toggle-window-stash, left to
@@ -190,6 +194,8 @@ type origPlace struct {
190194 // fullWidth is the column's maximize-column state.
191195 fullWidth bool
192196 float *Float
197+ // floatAt is the original native float position on fullscreen entry.
198+ floatAt int
193199 // stash is set when float came from the stash, at index col.
194200 stash bool
195201 }
@@ -198,6 +204,8 @@ type origPlace struct {
198204 type Float struct {
199205 ID WindowID
200206 W, H int
207+ // below places a covering native float behind columns and stash.
208+ below bool
201209 // back records the former column of a stashed window; nil for native floats.
202210 back *origPlace
203211 }
@@ -226,6 +234,56 @@ func (w *Workspace) floatIndex(id WindowID) int {
226234 return slices.IndexFunc(w.Floats, func(f Float) bool { return f.ID == id })
227235 }
228236
237+// coversFloat uses the placed rectangle, not a map-time size. Allow up to
238+// two border widths plus two logical pixels per axis for scale rounding.
239+func (w *Workspace) coversFloat(f Float) bool {
240+ if w.fullscreen == f.ID || w.Usable.W <= 0 || w.Usable.H <= 0 {
241+ return false
242+ }
243+ r := w.floatRect(f)
244+ return w.Usable.W-r.W <= 2*w.border+2 && w.Usable.H-r.H <= 2*w.border+2
245+}
246+
247+// reconcileFloats promotes floats that no longer cover, without raising
248+// existing covering floats on automatic geometry changes. It also drops
249+// float focus when no raised float remains on top.
250+func (w *Workspace) reconcileFloats() {
251+ for i := range w.Floats {
252+ if w.Floats[i].below && !w.coversFloat(w.Floats[i]) && w.fullscreen != w.Floats[i].ID {
253+ w.Floats[i].below = false
254+ }
255+ }
256+ if len(w.Floats) == 0 || w.Floats[len(w.Floats)-1].below {
257+ w.floatFocus = false
258+ }
259+}
260+
261+// canLeaveFloat reports whether columns or a shown stash can receive focus.
262+func (w *Workspace) canLeaveFloat() bool {
263+ return len(w.Columns) > 0 || !w.stashHidden && len(w.Stash) > 0
264+}
265+
266+// leaveFloat moves focus to the columns or shown stash under the float.
267+// Without either, it keeps focus and reports false.
268+func (w *Workspace) leaveFloat() bool {
269+ if !w.canLeaveFloat() {
270+ return false
271+ }
272+ w.floatFocus = false
273+ w.raiseColumns()
274+ return true
275+}
276+
277+// raiseColumns moves only covering floats above the columns behind them.
278+// The user-selected column group rises as one; dialogs remain above it.
279+func (w *Workspace) raiseColumns() {
280+ for i := range w.Floats {
281+ if w.coversFloat(w.Floats[i]) {
282+ w.Floats[i].below = true
283+ }
284+ }
285+}
286+
229287 // AddFloating shows a native floating window over the columns and the
230288 // stash, and focuses it.
231289 func (w *Workspace) AddFloating(id WindowID, width, height int) {
@@ -240,6 +298,7 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) {
240298 func (w *Workspace) ResizeFloating(id WindowID, width, height int) {
241299 if i := w.floatIndex(id); i >= 0 {
242300 w.Floats[i].W, w.Floats[i].H = width, height
301+ w.reconcileFloats()
243302 }
244303 }
245304
@@ -263,7 +322,7 @@ func (w *Workspace) Focused() (WindowID, bool) {
263322 if full := w.cover(); full != 0 {
264323 return full, true
265324 }
266- if w.floatFocus && len(w.Floats) > 0 {
325+ if w.floatFocus && len(w.Floats) > 0 && !w.Floats[len(w.Floats)-1].below {
267326 return w.Floats[len(w.Floats)-1].ID, true
268327 }
269328 if w.stashFocused() {
@@ -306,7 +365,7 @@ func (w *Workspace) addColumn(col Column) {
306365 at = w.Focus + 1
307366 }
308367 if w.Overflow == OverflowFixed && len(w.Columns) > 0 {
309- w.Columns[w.Focus].FullWidth = false
368+ w.unmaximize()
310369 }
311370 w.Columns = append(w.Columns, Column{})
312371 copy(w.Columns[at+1:], w.Columns[at:])
@@ -366,6 +425,7 @@ func (w *Workspace) unslot(n int) {
366425 }
367426
368427 func (w *Workspace) RemoveWindow(id WindowID) {
428+ w.maximized = slices.DeleteFunc(w.maximized, func(v WindowID) bool { return v == id })
369429 if w.fullscreen == id {
370430 w.fullscreen = 0
371431 }
@@ -374,7 +434,7 @@ func (w *Workspace) RemoveWindow(id WindowID) {
374434 }
375435 if i := w.floatIndex(id); i >= 0 {
376436 w.Floats = slices.Delete(w.Floats, i, i+1)
377- if len(w.Floats) == 0 {
437+ if len(w.Floats) == 0 || w.Floats[len(w.Floats)-1].below {
378438 w.floatFocus = false
379439 }
380440 return
@@ -425,6 +485,7 @@ func (w *Workspace) RemoveWindow(id WindowID) {
425485 func (w *Workspace) FocusID(id WindowID) bool {
426486 if i := w.floatIndex(id); i >= 0 {
427487 f := w.Floats[i]
488+ f.below = false
428489 w.Floats = append(slices.Delete(w.Floats, i, i+1), f)
429490 w.floatFocus = true
430491 return true
@@ -440,8 +501,9 @@ func (w *Workspace) FocusID(id WindowID) bool {
440501 for j, v := range w.Columns[i].Windows {
441502 if v == id {
442503 w.floatFocus, w.stashFocus = false, false
443- if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) {
444- w.Columns[w.Focus].FullWidth = false
504+ w.raiseColumns()
505+ if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) && !w.Columns[i].FullWidth {
506+ w.unmaximize()
445507 }
446508 w.Focus = i
447509 w.Columns[i].Focus = j
@@ -457,8 +519,9 @@ func (w *Workspace) FocusColumn(dir int) {
457519 return
458520 }
459521 if w.floatFocus {
460- // The first move leaves the native float for the stash or columns.
461- w.floatFocus = false
522+ // The first move leaves the native float for the stash or columns,
523+ // if there is one: otherwise the float keeps the focus.
524+ w.leaveFloat()
462525 return
463526 }
464527 if w.stashFocused() {
@@ -469,8 +532,9 @@ func (w *Workspace) FocusColumn(dir int) {
469532 return
470533 }
471534 if i := w.columnToward(dir); i >= 0 {
535+ w.raiseColumns()
472536 if w.Overflow == OverflowFixed {
473- w.Columns[w.Focus].FullWidth = false
537+ w.unmaximize()
474538 }
475539 w.Focus = i
476540 w.scroll()
@@ -500,16 +564,15 @@ func (w *Workspace) onScreenFocus() bool {
500564 return w.Overflow == OverflowFixed && w.fullscreen == 0 && !w.Columns[w.Focus].FullWidth
501565 }
502566
503-// FocusWindow moves focus inside the column; false means it was already at the edge.
504-// Fixed overflow also stacks columns (spiral, expanded strips): past the
505-// column edge, focus goes to the column on screen above or below.
567+// FocusWindow moves focus inside the column, then to a fixed-overflow column
568+// above or below on screen. Up at the top raises the topmost demoted covering
569+// float; false means no window is available in that direction.
506570 func (w *Workspace) FocusWindow(dir int) bool {
507571 if w.pinned() {
508572 return false
509573 }
510574 if w.floatFocus {
511- w.floatFocus = false
512- return true
575+ return w.leaveFloat()
513576 }
514577 if w.stashFocused() {
515578 // The stash is one row: up and down do nothing there.
@@ -521,19 +584,27 @@ func (w *Workspace) FocusWindow(dir int) bool {
521584 c := &w.Columns[w.Focus]
522585 if c.Focus+dir >= 0 && c.Focus+dir < len(c.Windows) {
523586 c.Focus += dir
587+ w.raiseColumns()
524588 w.scroll()
525589 return true
526590 }
527- if !w.onScreenFocus() {
528- return false
591+ if w.onScreenFocus() {
592+ if i := w.screenNeighbor(0, dir); i >= 0 {
593+ w.Focus = i
594+ w.raiseColumns()
595+ w.Columns[i].Focus = 0
596+ if dir < 0 {
597+ w.Columns[i].Focus = len(w.Columns[i].Windows) - 1
598+ }
599+ return true
600+ }
529601 }
530- if i := w.screenNeighbor(0, dir); i >= 0 {
531- w.Focus = i
532- w.Columns[i].Focus = 0
533- if dir < 0 {
534- w.Columns[i].Focus = len(w.Columns[i].Windows) - 1
602+ if dir < 0 {
603+ for i := len(w.Floats) - 1; i >= 0; i-- {
604+ if f := w.Floats[i]; f.below && w.coversFloat(f) {
605+ return w.FocusID(f.ID)
606+ }
535607 }
536- return true
537608 }
538609 return false
539610 }
@@ -606,7 +677,7 @@ func (w *Workspace) takeColumn() (Column, bool) {
606677 func (w *Workspace) insertColumn(at int, col Column) {
607678 at = min(max(at, 0), len(w.Columns))
608679 if w.Overflow == OverflowFixed && len(w.Columns) > 0 {
609- w.Columns[w.Focus].FullWidth = false
680+ w.unmaximize()
610681 }
611682 w.Columns = slices.Insert(w.Columns, at, col)
612683 w.Focus = at
@@ -636,7 +707,7 @@ func (w *Workspace) CycleWidth() {
636707 }
637708 }
638709 c.Width = next
639- c.FullWidth = false
710+ w.unmaximize()
640711 w.scroll()
641712 }
642713
@@ -651,7 +722,39 @@ func (w *Workspace) toggleExpanded() {
651722 w.Columns[i].Expanded = false
652723 }
653724 w.Columns[w.Focus].Expanded = on
725+ w.unmaximize()
726+}
727+
728+// maximize sets FullWidth on column i; unlike ToggleFullWidth it never toggles.
729+func (w *Workspace) maximize(i int) {
730+ if i >= 0 && i < len(w.Columns) {
731+ w.Columns[i].FullWidth = true
732+ id := w.Columns[i].Windows[w.Columns[i].Focus]
733+ w.maximized = slices.DeleteFunc(w.maximized, func(v WindowID) bool { return v == id })
734+ w.maximized = slices.Insert(w.maximized, 0, id)
735+ }
736+}
737+
738+// unmaximize clears the focused column in scroll mode, or all maximized
739+// columns in fixed mode (including after Escape changed focus).
740+func (w *Workspace) unmaximize() {
741+ for i := range w.Columns {
742+ if w.Overflow == OverflowFixed || i == w.Focus {
743+ w.Columns[i].FullWidth = false
744+ }
745+ }
746+ if w.Overflow == OverflowFixed {
747+ w.maximized = nil
748+ }
749+}
750+
751+// transferMaximization changes the maximized column without forgetting MRU.
752+func (w *Workspace) transferMaximization(i int) {
753+ if i == w.Focus || i < 0 || i >= len(w.Columns) {
754+ return
755+ }
654756 w.Columns[w.Focus].FullWidth = false
757+ w.maximize(i)
655758 }
656759
657760 // ToggleFullWidth expands the focused tiled column without changing its saved width.
@@ -659,8 +762,11 @@ func (w *Workspace) ToggleFullWidth() {
659762 if len(w.Columns) == 0 || w.onFloat() {
660763 return
661764 }
662- c := &w.Columns[w.Focus]
663- c.FullWidth = !c.FullWidth
765+ if w.Columns[w.Focus].FullWidth {
766+ w.unmaximize()
767+ } else {
768+ w.maximize(w.Focus)
769+ }
664770 w.scroll()
665771 }
666772
@@ -710,6 +816,7 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) {
710816 w.fullscreen = id
711817 } else if w.fullscreen == id {
712818 w.fullscreen = 0
819+ w.reconcileFloats()
713820 }
714821 return
715822 }
@@ -750,6 +857,7 @@ func (w *Workspace) SetUsable(r Rect) {
750857 r.W = min(max(r.W, 0), w.Output.W-r.X)
751858 r.H = min(max(r.H, 0), w.Output.H-r.Y)
752859 w.Usable = r
860+ w.reconcileFloats()
753861 w.scroll()
754862 }
755863 func (w *Workspace) SetGaps(g int) {
@@ -823,7 +931,11 @@ func (w *Workspace) fullscreenColumn(i int) bool {
823931 return false
824932 }
825933 func (w *Workspace) columnWidth(i int) int {
826- if w.Columns[i].FullWidth {
934+ return w.columnWidthFor(i, false)
935+}
936+
937+func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {
938+ if w.Columns[i].FullWidth && !ignoreFullWidth {
827939 return max(w.Usable.W-2*w.gap(), 0)
828940 }
829941 if w.fullscreenColumn(i) {
@@ -875,14 +987,23 @@ func (w *Workspace) scroll() {
875987
876988 // columnRects returns each column's area on screen, before stacking windows.
877989 func (w *Workspace) columnRects() []Rect {
990+ return w.columnRectsFor(false)
991+}
992+
993+// columnRectsFor shares the fixed layout geometry with the overview's hidden
994+// columns card, without changing FullWidth or the clients' saved buffers.
995+func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
878996 g := w.gap()
879997 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)
880998 rects := make([]Rect, len(w.Columns))
881999 view := w.ViewX + w.shiftPixels()
1000+ x := w.Usable.X + g
8821001 for i := range w.Columns {
883- rects[i] = Rect{X: w.columnX(i) - view, Y: y, W: w.columnWidth(i), H: h}
1002+ width := w.columnWidthFor(i, ignoreFullWidth)
1003+ rects[i] = Rect{X: x - view, Y: y, W: width, H: h}
1004+ x += width + g
8841005 }
885- if w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth {
1006+ if !ignoreFullWidth && w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth {
8861007 rects[w.Focus].X = w.Usable.X + g
8871008 return rects
8881009 }
@@ -1014,23 +1135,48 @@ func (w *Workspace) Layout() []Placement {
10141135 }
10151136 }
10161137 setVisibleNeighbors(result, gap, w.Output)
1017- // Floating windows go last: they are drawn and hit on top, native
1018- // floats (dialogs) over the stash.
1019- result = append(result, w.stashLayout(focusedID, cover)...)
1020- for _, f := range w.Floats {
1021- // A covering fullscreen window hides every float, its own dialogs
1022- // too: they show again when it leaves fullscreen.
1023- p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover}
1024- if p.Hidden {
1025- p.Rect = Rect{}
1138+ // The column/stash group sits between demoted covering floats and
1139+ // floats above it. Keep the order within each group stable.
1140+ tiles := result
1141+ result = nil
1142+ const (
1143+ belowTiles = iota
1144+ coveringFloats
1145+ dialogs
1146+ )
1147+ appendFloats := func(group int) {
1148+ for _, f := range w.Floats {
1149+ below := f.below && w.coversFloat(f)
1150+ // Dialogs stay over covering floats even when the latter is
1151+ // selected. Fullscreen floats remain in the upper group.
1152+ order := dialogs
1153+ if below {
1154+ order = belowTiles
1155+ } else if w.coversFloat(f) || w.fullscreen == f.ID {
1156+ order = coveringFloats
1157+ }
1158+ if order != group {
1159+ continue
1160+ }
1161+ // A covering fullscreen window hides every float, its own dialogs
1162+ // too: they show again when it leaves fullscreen.
1163+ p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover}
1164+ if p.Hidden {
1165+ p.Rect = Rect{}
1166+ result = append(result, p)
1167+ continue
1168+ }
1169+ if w.fullscreen == f.ID {
1170+ p.Rect, p.Fullscreen, p.Inset = Rect{W: w.Output.W, H: w.Output.H}, true, 0
1171+ }
10261172 result = append(result, p)
1027- continue
10281173 }
1029- if w.fullscreen == f.ID {
1030- p.Rect, p.Fullscreen, p.Inset = Rect{W: w.Output.W, H: w.Output.H}, true, 0
1031- }
1032- result = append(result, p)
10331174 }
1175+ appendFloats(belowTiles)
1176+ result = append(result, tiles...)
1177+ result = append(result, w.stashLayout(focusedID, cover)...)
1178+ appendFloats(coveringFloats)
1179+ appendFloats(dialogs)
10341180 return result
10351181 }
10361182
@@ -96,8 +96,9 @@ type Placement struct {
96 ID WindowID96 ID WindowID
97 Rect Rect97 Rect Rect
98 Fullscreen, Focused, Hidden bool98 Fullscreen, Focused, Hidden bool
99- // Floating windows sit over the columns at their own size.99+ // Floating windows sit at their own size. Below marks a native float
100- Floating bool100+ // ordered behind the columns (never a stashed window).
101+ Floating, Below bool
101 // Peek is a stashed window peeking in beside the selected one, or an102 // Peek is a stashed window peeking in beside the selected one, or an
102 // overview preview of a neighbor workspace: dimmed.103 // overview preview of a neighbor workspace: dimmed.
103 Peek bool104 Peek bool
@@ -129,8 +130,11 @@ type Workspace struct {
129 Name string130 Name string
130 Overflow Overflow131 Overflow Overflow
131 Columns []Column132 Columns []Column
132- Focus int133+ // maximized remembers recently maximized columns by window ID, newest
133- ViewX int134+ // first; stack() filters removed windows at read time.
135+ maximized []WindowID
136+ Focus int
137+ ViewX int
134 // shift slides the columns on screen past ViewX, in logical pixels,138 // shift slides the columns on screen past ViewX, in logical pixels,
135 // while a swipe follows the fingers or its spring (motion) lands.139 // while a swipe follows the fingers or its spring (motion) lands.
136 shift float64140 shift float64
@@ -143,8 +147,8 @@ type Workspace struct {
143 border int147 border int
144 presets []Width148 presets []Width
145 fullscreen WindowID149 fullscreen WindowID
146- // Floats are native floating windows (dialogs), bottom to top;150+ // Floats are native floating windows in user selection order;
147- // floatFocus is set while the top one has the focus.151+ // below/above and dialog groups determine their layout order.
148 Floats []Float152 Floats []Float
149 floatFocus bool153 floatFocus bool
150 // Stash holds the windows set aside by toggle-window-stash, left to154 // Stash holds the windows set aside by toggle-window-stash, left to
@@ -190,6 +194,8 @@ type origPlace struct {
190 // fullWidth is the column's maximize-column state.194 // fullWidth is the column's maximize-column state.
191 fullWidth bool195 fullWidth bool
192 float *Float196 float *Float
197+ // floatAt is the original native float position on fullscreen entry.
198+ floatAt int
193 // stash is set when float came from the stash, at index col.199 // stash is set when float came from the stash, at index col.
194 stash bool200 stash bool
195 }201 }
@@ -198,6 +204,8 @@ type origPlace struct {
198 type Float struct {204 type Float struct {
199 ID WindowID205 ID WindowID
200 W, H int206 W, H int
207+ // below places a covering native float behind columns and stash.
208+ below bool
201 // back records the former column of a stashed window; nil for native floats.209 // back records the former column of a stashed window; nil for native floats.
202 back *origPlace210 back *origPlace
203 }211 }
@@ -226,6 +234,56 @@ func (w *Workspace) floatIndex(id WindowID) int {
226 return slices.IndexFunc(w.Floats, func(f Float) bool { return f.ID == id })234 return slices.IndexFunc(w.Floats, func(f Float) bool { return f.ID == id })
227 }235 }
228 236
237+// coversFloat uses the placed rectangle, not a map-time size. Allow up to
238+// two border widths plus two logical pixels per axis for scale rounding.
239+func (w *Workspace) coversFloat(f Float) bool {
240+ if w.fullscreen == f.ID || w.Usable.W <= 0 || w.Usable.H <= 0 {
241+ return false
242+ }
243+ r := w.floatRect(f)
244+ return w.Usable.W-r.W <= 2*w.border+2 && w.Usable.H-r.H <= 2*w.border+2
245+}
246+
247+// reconcileFloats promotes floats that no longer cover, without raising
248+// existing covering floats on automatic geometry changes. It also drops
249+// float focus when no raised float remains on top.
250+func (w *Workspace) reconcileFloats() {
251+ for i := range w.Floats {
252+ if w.Floats[i].below && !w.coversFloat(w.Floats[i]) && w.fullscreen != w.Floats[i].ID {
253+ w.Floats[i].below = false
254+ }
255+ }
256+ if len(w.Floats) == 0 || w.Floats[len(w.Floats)-1].below {
257+ w.floatFocus = false
258+ }
259+}
260+
261+// canLeaveFloat reports whether columns or a shown stash can receive focus.
262+func (w *Workspace) canLeaveFloat() bool {
263+ return len(w.Columns) > 0 || !w.stashHidden && len(w.Stash) > 0
264+}
265+
266+// leaveFloat moves focus to the columns or shown stash under the float.
267+// Without either, it keeps focus and reports false.
268+func (w *Workspace) leaveFloat() bool {
269+ if !w.canLeaveFloat() {
270+ return false
271+ }
272+ w.floatFocus = false
273+ w.raiseColumns()
274+ return true
275+}
276+
277+// raiseColumns moves only covering floats above the columns behind them.
278+// The user-selected column group rises as one; dialogs remain above it.
279+func (w *Workspace) raiseColumns() {
280+ for i := range w.Floats {
281+ if w.coversFloat(w.Floats[i]) {
282+ w.Floats[i].below = true
283+ }
284+ }
285+}
286+
229 // AddFloating shows a native floating window over the columns and the287 // AddFloating shows a native floating window over the columns and the
230 // stash, and focuses it.288 // stash, and focuses it.
231 func (w *Workspace) AddFloating(id WindowID, width, height int) {289 func (w *Workspace) AddFloating(id WindowID, width, height int) {
@@ -240,6 +298,7 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) {
240 func (w *Workspace) ResizeFloating(id WindowID, width, height int) {298 func (w *Workspace) ResizeFloating(id WindowID, width, height int) {
241 if i := w.floatIndex(id); i >= 0 {299 if i := w.floatIndex(id); i >= 0 {
242 w.Floats[i].W, w.Floats[i].H = width, height300 w.Floats[i].W, w.Floats[i].H = width, height
301+ w.reconcileFloats()
243 }302 }
244 }303 }
245 304
@@ -263,7 +322,7 @@ func (w *Workspace) Focused() (WindowID, bool) {
263 if full := w.cover(); full != 0 {322 if full := w.cover(); full != 0 {
264 return full, true323 return full, true
265 }324 }
266- if w.floatFocus && len(w.Floats) > 0 {325+ if w.floatFocus && len(w.Floats) > 0 && !w.Floats[len(w.Floats)-1].below {
267 return w.Floats[len(w.Floats)-1].ID, true326 return w.Floats[len(w.Floats)-1].ID, true
268 }327 }
269 if w.stashFocused() {328 if w.stashFocused() {
@@ -306,7 +365,7 @@ func (w *Workspace) addColumn(col Column) {
306 at = w.Focus + 1365 at = w.Focus + 1
307 }366 }
308 if w.Overflow == OverflowFixed && len(w.Columns) > 0 {367 if w.Overflow == OverflowFixed && len(w.Columns) > 0 {
309- w.Columns[w.Focus].FullWidth = false368+ w.unmaximize()
310 }369 }
311 w.Columns = append(w.Columns, Column{})370 w.Columns = append(w.Columns, Column{})
312 copy(w.Columns[at+1:], w.Columns[at:])371 copy(w.Columns[at+1:], w.Columns[at:])
@@ -366,6 +425,7 @@ func (w *Workspace) unslot(n int) {
366 }425 }
367 426
368 func (w *Workspace) RemoveWindow(id WindowID) {427 func (w *Workspace) RemoveWindow(id WindowID) {
428+ w.maximized = slices.DeleteFunc(w.maximized, func(v WindowID) bool { return v == id })
369 if w.fullscreen == id {429 if w.fullscreen == id {
370 w.fullscreen = 0430 w.fullscreen = 0
371 }431 }
@@ -374,7 +434,7 @@ func (w *Workspace) RemoveWindow(id WindowID) {
374 }434 }
375 if i := w.floatIndex(id); i >= 0 {435 if i := w.floatIndex(id); i >= 0 {
376 w.Floats = slices.Delete(w.Floats, i, i+1)436 w.Floats = slices.Delete(w.Floats, i, i+1)
377- if len(w.Floats) == 0 {437+ if len(w.Floats) == 0 || w.Floats[len(w.Floats)-1].below {
378 w.floatFocus = false438 w.floatFocus = false
379 }439 }
380 return440 return
@@ -425,6 +485,7 @@ func (w *Workspace) RemoveWindow(id WindowID) {
425 func (w *Workspace) FocusID(id WindowID) bool {485 func (w *Workspace) FocusID(id WindowID) bool {
426 if i := w.floatIndex(id); i >= 0 {486 if i := w.floatIndex(id); i >= 0 {
427 f := w.Floats[i]487 f := w.Floats[i]
488+ f.below = false
428 w.Floats = append(slices.Delete(w.Floats, i, i+1), f)489 w.Floats = append(slices.Delete(w.Floats, i, i+1), f)
429 w.floatFocus = true490 w.floatFocus = true
430 return true491 return true
@@ -440,8 +501,9 @@ func (w *Workspace) FocusID(id WindowID) bool {
440 for j, v := range w.Columns[i].Windows {501 for j, v := range w.Columns[i].Windows {
441 if v == id {502 if v == id {
442 w.floatFocus, w.stashFocus = false, false503 w.floatFocus, w.stashFocus = false, false
443- if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) {504+ w.raiseColumns()
444- w.Columns[w.Focus].FullWidth = false505+ if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) && !w.Columns[i].FullWidth {
506+ w.unmaximize()
445 }507 }
446 w.Focus = i508 w.Focus = i
447 w.Columns[i].Focus = j509 w.Columns[i].Focus = j
@@ -457,8 +519,9 @@ func (w *Workspace) FocusColumn(dir int) {
457 return519 return
458 }520 }
459 if w.floatFocus {521 if w.floatFocus {
460- // The first move leaves the native float for the stash or columns.522+ // The first move leaves the native float for the stash or columns,
461- w.floatFocus = false523+ // if there is one: otherwise the float keeps the focus.
524+ w.leaveFloat()
462 return525 return
463 }526 }
464 if w.stashFocused() {527 if w.stashFocused() {
@@ -469,8 +532,9 @@ func (w *Workspace) FocusColumn(dir int) {
469 return532 return
470 }533 }
471 if i := w.columnToward(dir); i >= 0 {534 if i := w.columnToward(dir); i >= 0 {
535+ w.raiseColumns()
472 if w.Overflow == OverflowFixed {536 if w.Overflow == OverflowFixed {
473- w.Columns[w.Focus].FullWidth = false537+ w.unmaximize()
474 }538 }
475 w.Focus = i539 w.Focus = i
476 w.scroll()540 w.scroll()
@@ -500,16 +564,15 @@ func (w *Workspace) onScreenFocus() bool {
500 return w.Overflow == OverflowFixed && w.fullscreen == 0 && !w.Columns[w.Focus].FullWidth564 return w.Overflow == OverflowFixed && w.fullscreen == 0 && !w.Columns[w.Focus].FullWidth
501 }565 }
502 566
503-// FocusWindow moves focus inside the column; false means it was already at the edge.567+// FocusWindow moves focus inside the column, then to a fixed-overflow column
504-// Fixed overflow also stacks columns (spiral, expanded strips): past the568+// above or below on screen. Up at the top raises the topmost demoted covering
505-// column edge, focus goes to the column on screen above or below.569+// float; false means no window is available in that direction.
506 func (w *Workspace) FocusWindow(dir int) bool {570 func (w *Workspace) FocusWindow(dir int) bool {
507 if w.pinned() {571 if w.pinned() {
508 return false572 return false
509 }573 }
510 if w.floatFocus {574 if w.floatFocus {
511- w.floatFocus = false575+ return w.leaveFloat()
512- return true
513 }576 }
514 if w.stashFocused() {577 if w.stashFocused() {
515 // The stash is one row: up and down do nothing there.578 // The stash is one row: up and down do nothing there.
@@ -521,19 +584,27 @@ func (w *Workspace) FocusWindow(dir int) bool {
521 c := &w.Columns[w.Focus]584 c := &w.Columns[w.Focus]
522 if c.Focus+dir >= 0 && c.Focus+dir < len(c.Windows) {585 if c.Focus+dir >= 0 && c.Focus+dir < len(c.Windows) {
523 c.Focus += dir586 c.Focus += dir
587+ w.raiseColumns()
524 w.scroll()588 w.scroll()
525 return true589 return true
526 }590 }
527- if !w.onScreenFocus() {591+ if w.onScreenFocus() {
528- return false592+ if i := w.screenNeighbor(0, dir); i >= 0 {
593+ w.Focus = i
594+ w.raiseColumns()
595+ w.Columns[i].Focus = 0
596+ if dir < 0 {
597+ w.Columns[i].Focus = len(w.Columns[i].Windows) - 1
598+ }
599+ return true
600+ }
529 }601 }
530- if i := w.screenNeighbor(0, dir); i >= 0 {602+ if dir < 0 {
531- w.Focus = i603+ for i := len(w.Floats) - 1; i >= 0; i-- {
532- w.Columns[i].Focus = 0604+ if f := w.Floats[i]; f.below && w.coversFloat(f) {
533- if dir < 0 {605+ return w.FocusID(f.ID)
534- w.Columns[i].Focus = len(w.Columns[i].Windows) - 1606+ }
535 }607 }
536- return true
537 }608 }
538 return false609 return false
539 }610 }
@@ -606,7 +677,7 @@ func (w *Workspace) takeColumn() (Column, bool) {
606 func (w *Workspace) insertColumn(at int, col Column) {677 func (w *Workspace) insertColumn(at int, col Column) {
607 at = min(max(at, 0), len(w.Columns))678 at = min(max(at, 0), len(w.Columns))
608 if w.Overflow == OverflowFixed && len(w.Columns) > 0 {679 if w.Overflow == OverflowFixed && len(w.Columns) > 0 {
609- w.Columns[w.Focus].FullWidth = false680+ w.unmaximize()
610 }681 }
611 w.Columns = slices.Insert(w.Columns, at, col)682 w.Columns = slices.Insert(w.Columns, at, col)
612 w.Focus = at683 w.Focus = at
@@ -636,7 +707,7 @@ func (w *Workspace) CycleWidth() {
636 }707 }
637 }708 }
638 c.Width = next709 c.Width = next
639- c.FullWidth = false710+ w.unmaximize()
640 w.scroll()711 w.scroll()
641 }712 }
642 713
@@ -651,7 +722,39 @@ func (w *Workspace) toggleExpanded() {
651 w.Columns[i].Expanded = false722 w.Columns[i].Expanded = false
652 }723 }
653 w.Columns[w.Focus].Expanded = on724 w.Columns[w.Focus].Expanded = on
725+ w.unmaximize()
726+}
727+
728+// maximize sets FullWidth on column i; unlike ToggleFullWidth it never toggles.
729+func (w *Workspace) maximize(i int) {
730+ if i >= 0 && i < len(w.Columns) {
731+ w.Columns[i].FullWidth = true
732+ id := w.Columns[i].Windows[w.Columns[i].Focus]
733+ w.maximized = slices.DeleteFunc(w.maximized, func(v WindowID) bool { return v == id })
734+ w.maximized = slices.Insert(w.maximized, 0, id)
735+ }
736+}
737+
738+// unmaximize clears the focused column in scroll mode, or all maximized
739+// columns in fixed mode (including after Escape changed focus).
740+func (w *Workspace) unmaximize() {
741+ for i := range w.Columns {
742+ if w.Overflow == OverflowFixed || i == w.Focus {
743+ w.Columns[i].FullWidth = false
744+ }
745+ }
746+ if w.Overflow == OverflowFixed {
747+ w.maximized = nil
748+ }
749+}
750+
751+// transferMaximization changes the maximized column without forgetting MRU.
752+func (w *Workspace) transferMaximization(i int) {
753+ if i == w.Focus || i < 0 || i >= len(w.Columns) {
754+ return
755+ }
654 w.Columns[w.Focus].FullWidth = false756 w.Columns[w.Focus].FullWidth = false
757+ w.maximize(i)
655 }758 }
656 759
657 // ToggleFullWidth expands the focused tiled column without changing its saved width.760 // ToggleFullWidth expands the focused tiled column without changing its saved width.
@@ -659,8 +762,11 @@ func (w *Workspace) ToggleFullWidth() {
659 if len(w.Columns) == 0 || w.onFloat() {762 if len(w.Columns) == 0 || w.onFloat() {
660 return763 return
661 }764 }
662- c := &w.Columns[w.Focus]765+ if w.Columns[w.Focus].FullWidth {
663- c.FullWidth = !c.FullWidth766+ w.unmaximize()
767+ } else {
768+ w.maximize(w.Focus)
769+ }
664 w.scroll()770 w.scroll()
665 }771 }
666 772
@@ -710,6 +816,7 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) {
710 w.fullscreen = id816 w.fullscreen = id
711 } else if w.fullscreen == id {817 } else if w.fullscreen == id {
712 w.fullscreen = 0818 w.fullscreen = 0
819+ w.reconcileFloats()
713 }820 }
714 return821 return
715 }822 }
@@ -750,6 +857,7 @@ func (w *Workspace) SetUsable(r Rect) {
750 r.W = min(max(r.W, 0), w.Output.W-r.X)857 r.W = min(max(r.W, 0), w.Output.W-r.X)
751 r.H = min(max(r.H, 0), w.Output.H-r.Y)858 r.H = min(max(r.H, 0), w.Output.H-r.Y)
752 w.Usable = r859 w.Usable = r
860+ w.reconcileFloats()
753 w.scroll()861 w.scroll()
754 }862 }
755 func (w *Workspace) SetGaps(g int) {863 func (w *Workspace) SetGaps(g int) {
@@ -823,7 +931,11 @@ func (w *Workspace) fullscreenColumn(i int) bool {
823 return false931 return false
824 }932 }
825 func (w *Workspace) columnWidth(i int) int {933 func (w *Workspace) columnWidth(i int) int {
826- if w.Columns[i].FullWidth {934+ return w.columnWidthFor(i, false)
935+}
936+
937+func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {
938+ if w.Columns[i].FullWidth && !ignoreFullWidth {
827 return max(w.Usable.W-2*w.gap(), 0)939 return max(w.Usable.W-2*w.gap(), 0)
828 }940 }
829 if w.fullscreenColumn(i) {941 if w.fullscreenColumn(i) {
@@ -875,14 +987,23 @@ func (w *Workspace) scroll() {
875 987
876 // columnRects returns each column's area on screen, before stacking windows.988 // columnRects returns each column's area on screen, before stacking windows.
877 func (w *Workspace) columnRects() []Rect {989 func (w *Workspace) columnRects() []Rect {
990+ return w.columnRectsFor(false)
991+}
992+
993+// columnRectsFor shares the fixed layout geometry with the overview's hidden
994+// columns card, without changing FullWidth or the clients' saved buffers.
995+func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
878 g := w.gap()996 g := w.gap()
879 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)997 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)
880 rects := make([]Rect, len(w.Columns))998 rects := make([]Rect, len(w.Columns))
881 view := w.ViewX + w.shiftPixels()999 view := w.ViewX + w.shiftPixels()
1000+ x := w.Usable.X + g
882 for i := range w.Columns {1001 for i := range w.Columns {
883- rects[i] = Rect{X: w.columnX(i) - view, Y: y, W: w.columnWidth(i), H: h}1002+ width := w.columnWidthFor(i, ignoreFullWidth)
1003+ rects[i] = Rect{X: x - view, Y: y, W: width, H: h}
1004+ x += width + g
884 }1005 }
885- if w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth {1006+ if !ignoreFullWidth && w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth {
886 rects[w.Focus].X = w.Usable.X + g1007 rects[w.Focus].X = w.Usable.X + g
887 return rects1008 return rects
888 }1009 }
@@ -1014,23 +1135,48 @@ func (w *Workspace) Layout() []Placement {
1014 }1135 }
1015 }1136 }
1016 setVisibleNeighbors(result, gap, w.Output)1137 setVisibleNeighbors(result, gap, w.Output)
1017- // Floating windows go last: they are drawn and hit on top, native1138+ // The column/stash group sits between demoted covering floats and
1018- // floats (dialogs) over the stash.1139+ // floats above it. Keep the order within each group stable.
1019- result = append(result, w.stashLayout(focusedID, cover)...)1140+ tiles := result
1020- for _, f := range w.Floats {1141+ result = nil
1021- // A covering fullscreen window hides every float, its own dialogs1142+ const (
1022- // too: they show again when it leaves fullscreen.1143+ belowTiles = iota
1023- p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover}1144+ coveringFloats
1024- if p.Hidden {1145+ dialogs
1025- p.Rect = Rect{}1146+ )
1147+ appendFloats := func(group int) {
1148+ for _, f := range w.Floats {
1149+ below := f.below && w.coversFloat(f)
1150+ // Dialogs stay over covering floats even when the latter is
1151+ // selected. Fullscreen floats remain in the upper group.
1152+ order := dialogs
1153+ if below {
1154+ order = belowTiles
1155+ } else if w.coversFloat(f) || w.fullscreen == f.ID {
1156+ order = coveringFloats
1157+ }
1158+ if order != group {
1159+ continue
1160+ }
1161+ // A covering fullscreen window hides every float, its own dialogs
1162+ // too: they show again when it leaves fullscreen.
1163+ p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover}
1164+ if p.Hidden {
1165+ p.Rect = Rect{}
1166+ result = append(result, p)
1167+ continue
1168+ }
1169+ if w.fullscreen == f.ID {
1170+ p.Rect, p.Fullscreen, p.Inset = Rect{W: w.Output.W, H: w.Output.H}, true, 0
1171+ }
1026 result = append(result, p)1172 result = append(result, p)
1027- continue
1028 }1173 }
1029- if w.fullscreen == f.ID {
1030- p.Rect, p.Fullscreen, p.Inset = Rect{W: w.Output.W, H: w.Output.H}, true, 0
1031- }
1032- result = append(result, p)
1033 }1174 }
1175+ appendFloats(belowTiles)
1176+ result = append(result, tiles...)
1177+ result = append(result, w.stashLayout(focusedID, cover)...)
1178+ appendFloats(coveringFloats)
1179+ appendFloats(dialogs)
1034 return result1180 return result
1035 }1181 }
1036 1182
M internal/core/monitor.go +22 -22
@@ -34,19 +34,8 @@ type Monitor struct {
3434 // one its swipe began on); switchOff is then from the current
3535 // workspace's place in it.
3636 switchList []*Workspace
37- // overview is set while the overview is open (overview.go);
38- // overviewFrom and overviewFromID are where it opened, for Escape.
39- overview bool
40- overviewFrom *Workspace
41- overviewFromID WindowID
42- // overviewCard is the selected stash card and overviewCardOf its
43- // workspace; 0 while the selection is on a column. The stash itself
44- // changes only when the overview closes on the card.
45- overviewCard WindowID
46- overviewCardOf *Workspace
47- // scrollX and scrollY add up scrolling in the overview until it makes
48- // a step (overviewScroll); opening the overview starts from zero.
49- scrollX, scrollY float64
37+ // ov holds the overview selection and its Escape snapshot.
38+ ov overviewState
5039 // overviewOpens counts the overview's openings: a swipe sliding when
5140 // one happens is dropped (gesture.go).
5241 overviewOpens int
@@ -149,7 +138,7 @@ func (m *Monitor) Current() *Workspace {
149138 func (m *Monitor) newWorkspace() *Workspace {
150139 w := m.template
151140 w.presets = append([]Width(nil), m.template.presets...)
152- w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{}
141+ w.Columns, w.Floats, w.maximized, w.floatFocus, w.home, w.origin, w.back = nil, nil, nil, false, "", nil, origPlace{}
153142 w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0
154143 (*m.nextID)++
155144 w.ID = *m.nextID
@@ -377,7 +366,7 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {
377366 fs.back.id = id
378367 if f := w.floatIndex(id); f >= 0 {
379368 fl := w.Floats[f]
380- fs.back.float = &fl
369+ fs.back.float, fs.back.floatAt = &fl, f
381370 w.RemoveWindow(id)
382371 fs.AddFloating(id, fl.W, fl.H)
383372 fs.Floats[len(fs.Floats)-1] = fl
@@ -439,12 +428,17 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
439428 origin.stashAt++
440429 }
441430 back.col = len(origin.Columns)
442- case back.float != nil && focus:
443- origin.AddFloating(id, back.float.W, back.float.H)
444- origin.Floats[len(origin.Floats)-1] = *back.float
445431 case back.float != nil:
446- // At the bottom: the top float, maybe focused, stays on top.
447- origin.Floats = slices.Insert(origin.Floats, 0, *back.float)
432+ // Restore the original position before an optional focus-driven raise.
433+ at := min(back.floatAt, len(origin.Floats))
434+ if !focus && origin.floatFocus && len(origin.Floats) > 0 {
435+ at = min(at, len(origin.Floats)-1)
436+ }
437+ origin.Floats = slices.Insert(origin.Floats, at, *back.float)
438+ origin.reconcileFloats()
439+ if focus {
440+ origin.FocusID(id)
441+ }
448442 case slices.ContainsFunc(origin.Columns, back.holdsStack):
449443 back.col = slices.IndexFunc(origin.Columns, back.holdsStack)
450444 c := &origin.Columns[back.col]
@@ -459,6 +453,7 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
459453 c.Windows = slices.Insert(c.Windows, row, id)
460454 if focus {
461455 origin.Focus, c.Focus, origin.floatFocus, origin.stashFocus = back.col, row, false, false
456+ origin.raiseColumns()
462457 } else if row <= c.Focus && len(c.Windows) > 1 {
463458 c.Focus++
464459 }
@@ -470,6 +465,7 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
470465 origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded})
471466 if focus {
472467 origin.floatFocus, origin.stashFocus = false, false
468+ origin.raiseColumns()
473469 } else if len(origin.Columns) > 1 {
474470 origin.Focus = prev
475471 if at <= prev {
@@ -510,6 +506,7 @@ func (m *Monitor) foldInto(fs, origin *Workspace, at int, focus bool, tiled []Wi
510506 // The user is elsewhere: below, the focused float keeps the focus.
511507 origin.Floats = append(floats, origin.Floats...)
512508 }
509+ origin.reconcileFloats()
513510 // Windows stashed there join the end of the stash.
514511 for _, f := range fs.Stash {
515512 origin.Stash = append(origin.Stash, f.rehome())
@@ -661,7 +658,7 @@ func (m *Monitor) Focused() (WindowID, bool) {
661658
662659 // Layout places every window: the workspace on screen laid out, others hidden.
663660 func (m *Monitor) Layout() []Placement {
664- if m.overview {
661+ if m.ov.open {
665662 return m.overviewLayout()
666663 }
667664 var result []Placement
@@ -694,7 +691,9 @@ func (m *Monitor) SetOutput(width, height int) {
694691 }
695692 func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) }
696693 func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) }
697-func (m *Monitor) SetBorder(b int) { m.each(func(w *Workspace) { w.border = max(b, 0) }) }
694+func (m *Monitor) SetBorder(b int) {
695+ m.each(func(w *Workspace) { w.border = max(b, 0); w.reconcileFloats() })
696+}
698697
699698 // SetStash sets the width of a stashed window (stash.width; 0 is the
700699 // default) and the space between it and its neighbors (stash.gap), in
@@ -777,6 +776,7 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) {
777776 m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H)
778777 m.Workspaces[m.Active].Floats[len(m.Workspaces[m.Active].Floats)-1] = f
779778 }
779+ m.Workspaces[m.Active].reconcileFloats()
780780 for _, f := range w.Stash {
781781 m.Workspaces[m.Active].Stash = append(m.Workspaces[m.Active].Stash, f.rehome())
782782 }
@@ -34,19 +34,8 @@ type Monitor struct {
34 // one its swipe began on); switchOff is then from the current34 // one its swipe began on); switchOff is then from the current
35 // workspace's place in it.35 // workspace's place in it.
36 switchList []*Workspace36 switchList []*Workspace
37- // overview is set while the overview is open (overview.go);37+ // ov holds the overview selection and its Escape snapshot.
38- // overviewFrom and overviewFromID are where it opened, for Escape.38+ ov overviewState
39- overview bool
40- overviewFrom *Workspace
41- overviewFromID WindowID
42- // overviewCard is the selected stash card and overviewCardOf its
43- // workspace; 0 while the selection is on a column. The stash itself
44- // changes only when the overview closes on the card.
45- overviewCard WindowID
46- overviewCardOf *Workspace
47- // scrollX and scrollY add up scrolling in the overview until it makes
48- // a step (overviewScroll); opening the overview starts from zero.
49- scrollX, scrollY float64
50 // overviewOpens counts the overview's openings: a swipe sliding when39 // overviewOpens counts the overview's openings: a swipe sliding when
51 // one happens is dropped (gesture.go).40 // one happens is dropped (gesture.go).
52 overviewOpens int41 overviewOpens int
@@ -149,7 +138,7 @@ func (m *Monitor) Current() *Workspace {
149 func (m *Monitor) newWorkspace() *Workspace {138 func (m *Monitor) newWorkspace() *Workspace {
150 w := m.template139 w := m.template
151 w.presets = append([]Width(nil), m.template.presets...)140 w.presets = append([]Width(nil), m.template.presets...)
152- w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{}141+ w.Columns, w.Floats, w.maximized, w.floatFocus, w.home, w.origin, w.back = nil, nil, nil, false, "", nil, origPlace{}
153 w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0142 w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0
154 (*m.nextID)++143 (*m.nextID)++
155 w.ID = *m.nextID144 w.ID = *m.nextID
@@ -377,7 +366,7 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {
377 fs.back.id = id366 fs.back.id = id
378 if f := w.floatIndex(id); f >= 0 {367 if f := w.floatIndex(id); f >= 0 {
379 fl := w.Floats[f]368 fl := w.Floats[f]
380- fs.back.float = &fl369+ fs.back.float, fs.back.floatAt = &fl, f
381 w.RemoveWindow(id)370 w.RemoveWindow(id)
382 fs.AddFloating(id, fl.W, fl.H)371 fs.AddFloating(id, fl.W, fl.H)
383 fs.Floats[len(fs.Floats)-1] = fl372 fs.Floats[len(fs.Floats)-1] = fl
@@ -439,12 +428,17 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
439 origin.stashAt++428 origin.stashAt++
440 }429 }
441 back.col = len(origin.Columns)430 back.col = len(origin.Columns)
442- case back.float != nil && focus:
443- origin.AddFloating(id, back.float.W, back.float.H)
444- origin.Floats[len(origin.Floats)-1] = *back.float
445 case back.float != nil:431 case back.float != nil:
446- // At the bottom: the top float, maybe focused, stays on top.432+ // Restore the original position before an optional focus-driven raise.
447- origin.Floats = slices.Insert(origin.Floats, 0, *back.float)433+ at := min(back.floatAt, len(origin.Floats))
434+ if !focus && origin.floatFocus && len(origin.Floats) > 0 {
435+ at = min(at, len(origin.Floats)-1)
436+ }
437+ origin.Floats = slices.Insert(origin.Floats, at, *back.float)
438+ origin.reconcileFloats()
439+ if focus {
440+ origin.FocusID(id)
441+ }
448 case slices.ContainsFunc(origin.Columns, back.holdsStack):442 case slices.ContainsFunc(origin.Columns, back.holdsStack):
449 back.col = slices.IndexFunc(origin.Columns, back.holdsStack)443 back.col = slices.IndexFunc(origin.Columns, back.holdsStack)
450 c := &origin.Columns[back.col]444 c := &origin.Columns[back.col]
@@ -459,6 +453,7 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
459 c.Windows = slices.Insert(c.Windows, row, id)453 c.Windows = slices.Insert(c.Windows, row, id)
460 if focus {454 if focus {
461 origin.Focus, c.Focus, origin.floatFocus, origin.stashFocus = back.col, row, false, false455 origin.Focus, c.Focus, origin.floatFocus, origin.stashFocus = back.col, row, false, false
456+ origin.raiseColumns()
462 } else if row <= c.Focus && len(c.Windows) > 1 {457 } else if row <= c.Focus && len(c.Windows) > 1 {
463 c.Focus++458 c.Focus++
464 }459 }
@@ -470,6 +465,7 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
470 origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded})465 origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded})
471 if focus {466 if focus {
472 origin.floatFocus, origin.stashFocus = false, false467 origin.floatFocus, origin.stashFocus = false, false
468+ origin.raiseColumns()
473 } else if len(origin.Columns) > 1 {469 } else if len(origin.Columns) > 1 {
474 origin.Focus = prev470 origin.Focus = prev
475 if at <= prev {471 if at <= prev {
@@ -510,6 +506,7 @@ func (m *Monitor) foldInto(fs, origin *Workspace, at int, focus bool, tiled []Wi
510 // The user is elsewhere: below, the focused float keeps the focus.506 // The user is elsewhere: below, the focused float keeps the focus.
511 origin.Floats = append(floats, origin.Floats...)507 origin.Floats = append(floats, origin.Floats...)
512 }508 }
509+ origin.reconcileFloats()
513 // Windows stashed there join the end of the stash.510 // Windows stashed there join the end of the stash.
514 for _, f := range fs.Stash {511 for _, f := range fs.Stash {
515 origin.Stash = append(origin.Stash, f.rehome())512 origin.Stash = append(origin.Stash, f.rehome())
@@ -661,7 +658,7 @@ func (m *Monitor) Focused() (WindowID, bool) {
661 658
662 // Layout places every window: the workspace on screen laid out, others hidden.659 // Layout places every window: the workspace on screen laid out, others hidden.
663 func (m *Monitor) Layout() []Placement {660 func (m *Monitor) Layout() []Placement {
664- if m.overview {661+ if m.ov.open {
665 return m.overviewLayout()662 return m.overviewLayout()
666 }663 }
667 var result []Placement664 var result []Placement
@@ -694,7 +691,9 @@ func (m *Monitor) SetOutput(width, height int) {
694 }691 }
695 func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) }692 func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) }
696 func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) }693 func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) }
697-func (m *Monitor) SetBorder(b int) { m.each(func(w *Workspace) { w.border = max(b, 0) }) }694+func (m *Monitor) SetBorder(b int) {
695+ m.each(func(w *Workspace) { w.border = max(b, 0); w.reconcileFloats() })
696+}
698 697
699 // SetStash sets the width of a stashed window (stash.width; 0 is the698 // SetStash sets the width of a stashed window (stash.width; 0 is the
700 // default) and the space between it and its neighbors (stash.gap), in699 // default) and the space between it and its neighbors (stash.gap), in
@@ -777,6 +776,7 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) {
777 m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H)776 m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H)
778 m.Workspaces[m.Active].Floats[len(m.Workspaces[m.Active].Floats)-1] = f777 m.Workspaces[m.Active].Floats[len(m.Workspaces[m.Active].Floats)-1] = f
779 }778 }
779+ m.Workspaces[m.Active].reconcileFloats()
780 for _, f := range w.Stash {780 for _, f := range w.Stash {
781 m.Workspaces[m.Active].Stash = append(m.Workspaces[m.Active].Stash, f.rehome())781 m.Workspaces[m.Active].Stash = append(m.Workspaces[m.Active].Stash, f.rehome())
782 }782 }
M internal/core/monitor_test.go +1 -1
@@ -440,7 +440,7 @@ func TestMonitorFixedFullscreenStackedExpanded(t *testing.T) {
440440 }
441441 w := m.Current()
442442 w.FocusID(2)
443- w.stack(2, 1) // column {1, 2}, then {3}, {4}
443+ w.stackWindow(2, 1) // column {1, 2}, then {3}, {4}
444444 w.FocusID(2)
445445 m.Apply(ActionCycleColumnWidth)
446446 m.ToggleFullscreen()
@@ -440,7 +440,7 @@ func TestMonitorFixedFullscreenStackedExpanded(t *testing.T) {
440 }440 }
441 w := m.Current()441 w := m.Current()
442 w.FocusID(2)442 w.FocusID(2)
443- w.stack(2, 1) // column {1, 2}, then {3}, {4}443+ w.stackWindow(2, 1) // column {1, 2}, then {3}, {4}
444 w.FocusID(2)444 w.FocusID(2)
445 m.Apply(ActionCycleColumnWidth)445 m.Apply(ActionCycleColumnWidth)
446 m.ToggleFullscreen()446 m.ToggleFullscreen()
M internal/core/overview.go +419 -97
@@ -3,18 +3,35 @@ package core
33 import (
44 "context"
55 "math"
6+ "slices"
67
78 "github.com/bnema/neferwl/internal/ports"
89 )
910
10-// The overview shows every column of the workspace on screen in one row,
11-// each tile scaled by the same factor so windows keep their shape, with
12-// the workspaces above and below it dimmed. A workspace pinned under a
13-// covering fullscreen window (a game) shows only that window, as on
14-// screen. The previews
15-// are the windows' last buffers: clients are not resized. h/l (or left/right) select a
16-// column, j/k (or down/up) a workspace, Return keeps the selection and
17-// Escape returns to where the overview opened.
11+// overviewState owns the provisional selection and opening snapshot. row is
12+// the workspace whose stack has a provisional front; nil uses its real front.
13+type overviewState struct {
14+ open bool
15+ from *Workspace
16+ fromID WindowID
17+ floats []Float
18+ fullWidth WindowID
19+ row *Workspace
20+ front stackItem
21+ frontAt int
22+ cardOf *Workspace
23+ card WindowID
24+ selected WindowID
25+ selectedAt int
26+ // FullWidth history is restored on Escape when active binds changed it.
27+ maximized []WindowID
28+ scrollX, scrollY float64
29+}
30+
31+// The overview scales workspace rows without resizing clients. Covering
32+// floats and the columns form a linear stack of cards; small floats stay
33+// hidden. Up/down traverse the stack before changing workspace. A pinned
34+// fullscreen window remains the only preview on its row.
1835
1936 // A workspace's stash shows as a pile of cards pinned to the left edge of
2037 // its row: the selected stashed window in front at overviewCardZoom, up to
@@ -24,6 +41,7 @@ const (
2441 overviewCardZoom = 0.2
2542 overviewCards = 4
2643 overviewCardStep = 8
44+ overviewPeekStep = 0.07
2745 )
2846
2947 // overviewMaxZoom caps the current row so its neighbors show above and
@@ -35,7 +53,7 @@ const overviewMaxZoom, overviewMinZoom = 0.6, 0.25
3553 // a covering fullscreen window (a game) too: its row shows it, composed
3654 // rather than scanned out while the overview is open.
3755 func (m *Monitor) ToggleOverview() {
38- if m.overview {
56+ if m.ov.open {
3957 m.closeOverview()
4058 return
4159 }
@@ -44,55 +62,118 @@ func (m *Monitor) ToggleOverview() {
4462 // settled state.
4563 m.stopSwitch()
4664 m.each(func(w *Workspace) { w.stopSlide() })
47- m.overview, m.overviewFrom = true, w
48- m.overviewFromID, _ = w.Focused()
49- m.scrollX, m.scrollY = 0, 0
65+ m.ov.open, m.ov.from = true, w
66+ m.ov.fromID, _ = w.Focused()
67+ m.ov.floats = append([]Float(nil), w.Floats...)
68+ m.ov.maximized = slices.Clone(w.maximized)
69+ m.ov.fullWidth = 0
70+ if len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth {
71+ c := w.Columns[w.Focus]
72+ m.ov.fullWidth = c.Windows[c.Focus]
73+ }
74+ m.ov.scrollX, m.ov.scrollY = 0, 0
5075 m.overviewOpens++
5176 m.selectRow()
5277 }
5378
54-// selectRow selects the stash card of a workspace whose stash has the
55-// focus (or only stashed windows), else its focused column. The overview
56-// hides native floats, so a float's focus moves to its column; Escape
57-// gives it back. A pinning fullscreen window is the exception: it is the
58-// row.
79+// selectRow resets provisional stack and stash selection for a new row.
5980 func (m *Monitor) selectRow() {
60- m.overviewCard, m.overviewCardOf = 0, nil
81+ m.ov.card, m.ov.cardOf = 0, nil
82+ m.ov.front, m.ov.row, m.ov.frontAt = stackItem{}, nil, 0
83+ m.ov.selected, m.ov.selectedAt = 0, -1
6184 w := m.Current()
6285 if w.pinned() {
63- // The covering window is the row's only preview: it keeps the focus.
6486 return
6587 }
66- w.floatFocus = false
67- if len(w.Stash) > 0 && (len(w.Columns) == 0 || w.stashFocused()) {
88+ if w.floatFocus && len(w.Columns) > 0 && (len(w.stack()) < 2 || m.stackFront(w).kind == stackColumns) {
89+ w.floatFocus = false
90+ }
91+ if len(w.Stash) > 0 && (len(w.stack()) == 0 || w.stashFocused() && !w.floatFocus) {
6892 m.selectCard(w, w.stashAt)
6993 }
7094 }
7195
7296 // selectCard selects stash entry i of w.
7397 func (m *Monitor) selectCard(w *Workspace, i int) {
74- m.overviewCard, m.overviewCardOf = w.Stash[i].ID, w
98+ m.ov.card, m.ov.cardOf = w.Stash[i].ID, w
7599 }
76100
77101 // card is the selected stash card, 0 when there is none: the overview is
78102 // closed, the selection is on a column, or its window left the stash on
79103 // screen (unmapped, unstashed, or another workspace shown by a bind).
80104 func (m *Monitor) card() WindowID {
81- w := m.overviewCardOf
82- if !m.overview || w == nil || w != m.Current() || w.stashIndex(m.overviewCard) < 0 {
105+ w := m.ov.cardOf
106+ if !m.ov.open || w == nil || w != m.Current() || w.stashIndex(m.ov.card) < 0 {
83107 return 0
84108 }
85- return m.overviewCard
109+ return m.ov.card
86110 }
87111
88112 // cardAt is the pile entry of w that is selected, -1 when none.
89113 func (m *Monitor) cardAt(w *Workspace) int {
90- if id := m.card(); id != 0 && w == m.overviewCardOf {
114+ if id := m.card(); id != 0 && w == m.ov.cardOf {
91115 return w.stashIndex(id)
92116 }
93117 return -1
94118 }
95119
120+// overviewTarget resolves the active preview to one window for validation
121+// and window actions. Presentation state never falls back to a covering float.
122+func (m *Monitor) overviewTarget() WindowID {
123+ if id := m.card(); id != 0 {
124+ return id
125+ }
126+ w := m.Current()
127+ if w.pinned() {
128+ return w.cover()
129+ }
130+ item := m.stackFront(w)
131+ switch item.kind {
132+ case stackFloat:
133+ return item.id
134+ case stackColumn:
135+ i := w.columnOf(item.id)
136+ if i >= 0 {
137+ c := w.Columns[i]
138+ return c.Windows[c.Focus]
139+ }
140+ case stackColumns:
141+ i := w.Focus
142+ if len(w.Columns) == 0 {
143+ return 0
144+ }
145+ if w.overviewMaximized() {
146+ i = w.columnOf(m.ov.selected)
147+ if i < 0 || i == w.Focus {
148+ i = m.hiddenColumn(w)
149+ }
150+ }
151+ if i >= 0 {
152+ c := w.Columns[i]
153+ return c.Windows[c.Focus]
154+ }
155+ }
156+ return 0
157+}
158+
159+// overviewBlocks makes window mutations explicit: they are disabled while
160+// selection is provisional. Workspace and monitor navigation remain active.
161+func overviewBlocks(a Action) bool {
162+ if _, op, ok := WorkspaceArg(a); ok {
163+ return op != FocusWorkspace
164+ }
165+ switch a {
166+ case ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth,
167+ ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash,
168+ ActionToggleStashVisible, ActionMoveColumnToWorkspaceUp,
169+ ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp,
170+ ActionMoveWindowToWorkspaceDown, ActionConsumeOrExpelLeft,
171+ ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight:
172+ return true
173+ }
174+ return false
175+}
176+
96177 // closeOverview closes the overview on the selection: a card shows the
97178 // stash on it. A fullscreen request another stashed window made while
98179 // the stash was hidden is dropped: the picked card stays in front.
@@ -104,32 +185,66 @@ func (m *Monitor) closeOverview() {
104185 }
105186 w.stashAt = i
106187 w.showStash()
188+ } else if !w.pinned() && len(w.stack()) > 0 {
189+ w.apply(m.stackFront(w), m.overviewTarget())
107190 }
108- m.overview, m.overviewFrom = false, nil
109- m.overviewCard, m.overviewCardOf = 0, nil
191+ m.ov = overviewState{}
110192 }
111193
112194 // CancelOverview closes the overview and returns to the workspace and
113195 // window it opened on, if they are still there.
114196 func (m *Monitor) CancelOverview() {
115- from, id := m.overviewFrom, m.overviewFromID
116- m.overview, m.overviewFrom = false, nil
117- m.overviewCard, m.overviewCardOf = 0, nil
118- if from == nil || !m.has(from) {
119- return
120- }
121- m.show(from)
122- if id != 0 {
123- from.FocusID(id)
197+ from, id := m.ov.from, m.ov.fromID
198+ if from != nil && m.has(from) {
199+ m.show(from)
200+ if id != 0 {
201+ from.FocusID(id)
202+ } else {
203+ from.floatFocus, from.stashFocus = false, false
204+ }
205+ // FocusID on a float raises it; restore the initial order and below
206+ // flags for floats still present, leaving new floats in their order.
207+ initial := m.ov.floats
208+ current := from.Floats
209+ from.Floats = nil
210+ for _, f := range initial {
211+ for i, now := range current {
212+ if now.ID == f.ID {
213+ now.below = f.below
214+ from.Floats = append(from.Floats, now)
215+ current = append(current[:i], current[i+1:]...)
216+ break
217+ }
218+ }
219+ }
220+ from.Floats = append(from.Floats, current...)
221+ // Fixed overflow maximizes at most one column: only the one holding
222+ // the anchor window, wherever active binds moved it. Scroll overflow
223+ // keeps each column's own width.
224+ if from.Overflow == OverflowFixed {
225+ from.unmaximize()
226+ for i := range from.Columns {
227+ if m.ov.fullWidth != 0 && slices.Contains(from.Columns[i].Windows, m.ov.fullWidth) {
228+ from.maximize(i)
229+ break
230+ }
231+ }
232+ }
233+ from.maximized = slices.Clone(m.ov.maximized)
234+ from.scroll()
124235 }
236+ m.ov = overviewState{}
125237 }
126238
127-// OverviewMove selects the neighbor column (dx) or numbered workspace
128-// (dy). It stops at the ends and never enters an empty workspace, which
129-// would open a terminal there. Left of the first column is the stash
130-// pile; right of its last entry, the first column again.
239+// OverviewMove routes every vertical input through moveStack, then falls
240+// through to workspace navigation at the stack boundary. Horizontal moves
241+// stay inside a column group; left on a single card sends it behind.
131242 func (m *Monitor) OverviewMove(dx, dy int) {
243+ w := m.Current()
132244 if dy != 0 {
245+ if m.moveStack(-dy) {
246+ return
247+ }
133248 i := m.Active + dy
134249 if m.shown != nil || i < 0 || i >= len(m.Workspaces) || m.Workspaces[i].empty() {
135250 return
@@ -138,7 +253,6 @@ func (m *Monitor) OverviewMove(dx, dy int) {
138253 m.selectRow()
139254 return
140255 }
141- w := m.Current()
142256 if w.pinned() {
143257 return
144258 }
@@ -147,20 +261,62 @@ func (m *Monitor) OverviewMove(dx, dy int) {
147261 case at+dx >= 0 && at+dx < len(w.Stash):
148262 m.selectCard(w, at+dx)
149263 case dx > 0 && len(w.Columns) > 0:
150- m.overviewCard, m.overviewCardOf = 0, nil
151- w.FocusID(w.Columns[0].Windows[w.Columns[0].Focus])
264+ m.ov.card, m.ov.cardOf = 0, nil
265+ item := m.stackFront(w)
266+ if item.kind == stackColumns {
267+ if w.overviewMaximized() {
268+ i := m.hiddenColumn(w)
269+ m.ov.selected, m.ov.selectedAt = w.Columns[i].Windows[w.Columns[i].Focus], i
270+ } else {
271+ w.selectOverviewColumn(0)
272+ m.ov.selected = w.Columns[0].Windows[w.Columns[0].Focus]
273+ }
274+ }
152275 }
153276 return
154277 }
155- if dx < 0 && (len(w.Columns) == 0 || w.Focus == 0) && len(w.Stash) > 0 {
156- m.selectCard(w, w.stashAt)
278+ front := m.stackFront(w)
279+ if front.kind != stackColumns {
280+ if dx < 0 && m.moveStack(1) {
281+ return
282+ }
283+ if dx < 0 && len(w.Stash) > 0 {
284+ m.selectCard(w, w.stashAt)
285+ }
157286 return
158287 }
159288 if len(w.Columns) == 0 {
160289 return
161290 }
162- c := w.Columns[min(max(w.Focus+dx, 0), len(w.Columns)-1)]
163- w.FocusID(c.Windows[c.Focus])
291+ i := w.columnOf(m.ov.selected)
292+ if i < 0 || w.overviewMaximized() && i == w.Focus {
293+ if w.overviewMaximized() {
294+ i = m.hiddenColumn(w)
295+ } else {
296+ i = w.Focus
297+ }
298+ }
299+ first := 0
300+ if w.overviewMaximized() && w.Focus == 0 {
301+ first = 1
302+ }
303+ if dx < 0 && i == first && len(w.Stash) > 0 {
304+ m.selectCard(w, w.stashAt)
305+ return
306+ }
307+ next := min(max(i+dx, 0), len(w.Columns)-1)
308+ if w.overviewMaximized() && next == w.Focus {
309+ next = min(max(next+dx, 0), len(w.Columns)-1)
310+ if next == w.Focus {
311+ return
312+ }
313+ }
314+ m.ov.selected = w.Columns[next].Windows[w.Columns[next].Focus]
315+ m.ov.selectedAt = next
316+ if !w.overviewMaximized() {
317+ // Ordinary group navigation retains its existing scroll-to-selection.
318+ w.selectOverviewColumn(next)
319+ }
164320 }
165321
166322 // OverviewPick closes the overview on the clicked window.
@@ -169,33 +325,52 @@ func (m *Monitor) OverviewPick(id WindowID) {
169325 if w == nil {
170326 return
171327 }
328+ if w.floatIndex(id) >= 0 && w.stashIndex(id) < 0 {
329+ i := w.floatIndex(id)
330+ if !w.coversFloat(w.Floats[i]) && !w.pinned() {
331+ return
332+ }
333+ }
172334 m.show(w)
173- m.overviewCard, m.overviewCardOf = 0, nil
335+ m.ov.card, m.ov.cardOf = 0, nil
174336 if i := w.stashIndex(id); i >= 0 {
175337 m.selectCard(w, i)
176- } else {
338+ } else if w.pinned() {
177339 w.FocusID(id)
340+ } else if item, ok := w.itemOf(id); ok {
341+ m.setFront(w, item)
342+ if i := w.columnOf(id); i >= 0 {
343+ if item.kind == stackColumns && !w.overviewMaximized() {
344+ w.selectOverviewColumn(i)
345+ }
346+ m.ov.selected = id
347+ for j, v := range w.Columns[i].Windows {
348+ if v == id {
349+ w.Columns[i].Focus = j
350+ break
351+ }
352+ }
353+ }
178354 }
179355 m.closeOverview()
180356 }
181357
182358 // overviewLayout places the current workspace's row in the middle of the
183359 // usable area and its numbered neighbors above and below, dimmed, each
184-// with its stash pile. Other workspaces and native floats are hidden.
360+// with its stash pile and covering-float stack. Other workspaces are hidden.
185361 func (m *Monitor) overviewLayout() []Placement {
186362 cur := m.Current()
187363 u := cur.Usable
188364 gap := u.H * 3 / 100
189365 var result []Placement
190- zc := cur.overviewZoom()
191- hc := int(math.Round(float64(u.H) * zc))
366+ hc := cur.rowHeight()
192367 y := u.Y + (u.H-hc)/2
193368 rows := map[*Workspace]int{}
194369 rows[cur] = y
195370 if i := indexOf(m.Workspaces, cur); i >= 0 && m.shown == nil {
196371 if i > 0 {
197372 up := m.Workspaces[i-1]
198- rows[up] = y - gap - int(math.Round(float64(u.H)*up.overviewZoom()))
373+ rows[up] = y - gap - up.rowHeight()
199374 }
200375 if i+1 < len(m.Workspaces) {
201376 rows[m.Workspaces[i+1]] = y + hc + gap
@@ -215,11 +390,13 @@ func (m *Monitor) overviewLayout() []Placement {
215390 }
216391 if shown {
217392 at := m.cardAt(w)
218- result = append(result, w.previewRow(ry, w != cur, at < 0)...)
393+ result = append(result, m.stackRow(w, ry, w != cur, at < 0)...)
219394 result = append(result, w.pile(ry, w != cur, at)...)
220395 }
221396 for _, f := range w.Floats {
222- result = append(result, Placement{ID: f.ID, Hidden: true})
397+ if _, drawn := w.itemOf(f.ID); !shown || !drawn {
398+ result = append(result, Placement{ID: f.ID, Hidden: true})
399+ }
223400 }
224401 if !shown {
225402 for _, id := range w.windows() {
@@ -232,6 +409,72 @@ func (m *Monitor) overviewLayout() []Placement {
232409 return result
233410 }
234411
412+// fan places front and neighboring cards, farthest visible first and front
413+// last. A card may contain several tiles; omitted cards stay in the layout
414+// hidden so their clients are not left showing on screen.
415+func fan(cards [][]Placement, front, maxBehind, maxBefore int, circular bool,
416+ behind, before func(int) (int, int), dim, lit bool) []Placement {
417+ n := len(cards)
418+ if n == 0 {
419+ return nil
420+ }
421+ front = min(max(front, 0), n-1)
422+ out := make([]Placement, 0, n)
423+ add := func(i, distance int, offset func(int) (int, int), peek bool) {
424+ dx, dy := offset(distance)
425+ for _, p := range cards[i] {
426+ if !p.Hidden {
427+ p.Rect.X += dx
428+ p.Rect.Y += dy
429+ p.Peek = p.Peek || peek || dim
430+ p.Focused = p.Focused && !peek && !dim && lit
431+ }
432+ out = append(out, p)
433+ }
434+ }
435+ shown := make([]bool, n)
436+ // Behind and before are in nearest-first order; paint farthest first.
437+ for d := min(maxBehind, n-1); d >= 1; d-- {
438+ i := front + d
439+ if circular {
440+ i = (front + d) % n
441+ }
442+ if i >= n {
443+ continue
444+ }
445+ add(i, d, behind, true)
446+ shown[i] = true
447+ }
448+ for d := min(maxBefore, n-1); d >= 1; d-- {
449+ i := front - d
450+ if circular {
451+ i = (front - d + n) % n
452+ }
453+ if i < 0 {
454+ continue
455+ }
456+ if shown[i] {
457+ continue
458+ }
459+ add(i, d, before, true)
460+ shown[i] = true
461+ }
462+ add(front, 0, behind, false)
463+ shown[front] = true
464+ for i, card := range cards {
465+ if shown[i] {
466+ continue
467+ }
468+ for _, p := range card {
469+ p.Hidden = true
470+ p.Preview = 0
471+ p.Focused = false
472+ out = append(out, p)
473+ }
474+ }
475+ return out
476+}
477+
235478 // pileWidth is the room the stash pile of w takes on the left of its row,
236479 // its margin included; 0 without a stash.
237480 func (w *Workspace) pileWidth() int {
@@ -267,19 +510,17 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement {
267510 cw := int(math.Round(float64(r.W) * overviewCardZoom))
268511 ch := int(math.Round(float64(r.H) * overviewCardZoom))
269512 rowH := int(math.Round(float64(u.H) * w.overviewZoom()))
270- x0, y0 := u.X+u.W/100, y+(rowH-ch)/2
271- shown := min(n, overviewCards)
272- out := make([]Placement, 0, n)
273- // Farthest card first: the front one is drawn and hit on top.
274- for k := shown - 1; k >= 0; k-- {
275- i := (front + k) % n
276- step := k * w.cardStep()
277- out = append(out, Placement{ID: w.Stash[i].ID, Rect: Rect{X: x0 + step, Y: y0 - step, W: cw, H: ch}, Preview: overviewCardZoom, Peek: dim || k > 0, Focused: k == 0 && selected})
278- }
279- for k := shown; k < n; k++ {
280- out = append(out, Placement{ID: w.Stash[(front+k)%n].ID, Hidden: true})
281- }
282- return out
513+ // Stack rows reserve two peek steps above the front card. Align the
514+ // stash with that front band, not with the top of its envelope.
515+ x0, y0 := u.X+u.W/100, w.frontRowY(y)+(rowH-ch)/2
516+ cards := make([][]Placement, n)
517+ for i := range w.Stash {
518+ cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: Rect{X: x0, Y: y0, W: cw, H: ch}, Preview: overviewCardZoom, Focused: selected}}
519+ }
520+ step := w.cardStep()
521+ return fan(cards, front, min(n, overviewCards)-1, 0, true,
522+ func(d int) (int, int) { return d * step, -d * step },
523+ func(int) (int, int) { return 0, 0 }, dim, selected)
283524 }
284525
285526 // overviewZoom is the scale that fits every column of w in the usable
@@ -290,13 +531,41 @@ func (w *Workspace) overviewZoom() float64 {
290531 return overviewMaxZoom
291532 }
292533 room := float64(w.Usable.W-w.pileWidth()) * 0.96
293- return min(max(room/float64(span), overviewMinZoom), overviewMaxZoom)
534+ z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom)
535+ if len(w.stack()) > 1 {
536+ // Reserve two clear peeks on either side of the front row.
537+ z = min(z, overviewMaxZoom/(1+4*overviewPeekStep))
538+ }
539+ return z
540+}
541+
542+// peekStep is the shared step for a stack row and its front-card inset.
543+func (w *Workspace) peekStep() int {
544+ return max(1, int(math.Round(float64(w.Usable.H)*w.overviewZoom()*overviewPeekStep)))
545+}
546+
547+// frontRowY skips the space reserved for two cards peeking above the front.
548+// A single-card row starts at y, as before.
549+func (w *Workspace) frontRowY(y int) int {
550+ if len(w.stack()) > 1 {
551+ return y + 2*w.peekStep()
552+ }
553+ return y
554+}
555+
556+// rowHeight includes the largest possible stack envelope (two peeks above
557+// and two below). Neighbor rows use the same measure as the current row.
558+func (w *Workspace) rowHeight() int {
559+ h := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))
560+ if len(w.stack()) > 1 {
561+ h += 4 * w.peekStep()
562+ }
563+ return h
294564 }
295565
296-// previewTiles lays the tiles of w out unscrolled, relative to the
297-// usable area's corner: every column side by side at its width, as if
298-// the output were wide enough (fixed overflow's spiral included). span is
299-// the row's width; sel is the focused column.
566+// previewTiles lays tiles out unscrolled, relative to the usable area's
567+// corner. Fixed overflow uses its on-screen geometry; scroll overflow uses
568+// an unscrolled row. span is the row's width; sel is the focused column.
300569 func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
301570 g := w.gap()
302571 h := max(w.Usable.H-2*g, 0)
@@ -307,16 +576,36 @@ func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
307576 r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: h}
308577 return []Placement{{ID: w.cover(), Rect: r, Focused: true, Fullscreen: true}}, r.X + r.W + g, r
309578 }
579+ var rects []Rect
580+ if w.Overflow == OverflowFixed {
581+ rects = w.columnRects()
582+ span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.
583+ }
584+ maximized := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth
310585 for i, c := range w.Columns {
311586 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}
312- if w.fullscreenColumn(i) {
587+ if w.Overflow == OverflowFixed {
588+ r = rects[i]
589+ r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y
590+ } else if w.fullscreenColumn(i) {
313591 // A fullscreen column shows at the width it had in the row.
314592 r.W = max(w.Usable.W-2*g, 0)
315593 }
594+ if maximized && i != w.Focus {
595+ // FullWidth hides other columns on screen. Their old buffers
596+ // cannot fit their current spiral rects; show only the maximized
597+ // column until navigation restores the normal layout.
598+ for _, id := range c.Windows {
599+ tiles = append(tiles, Placement{ID: id, Hidden: true})
600+ }
601+ continue
602+ }
316603 for j, t := range stackRects(r, len(c.Windows), g) {
317604 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus})
318605 }
319- span = max(span, r.X+r.W+g)
606+ if w.Overflow != OverflowFixed {
607+ span = max(span, r.X+r.W+g)
608+ }
320609 if i == w.Focus {
321610 sel = r
322611 }
@@ -331,18 +620,27 @@ func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
331620 // false).
332621 func (w *Workspace) previewRow(y int, dim, lit bool) []Placement {
333622 tiles, span, sel := w.previewTiles()
623+ return w.previewRowTiles(y, dim, lit, tiles, span, sel)
624+}
625+
626+func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement {
334627 u := w.Usable
335- left := w.pileWidth()
336- u.X, u.W = u.X+left, u.W-left
337628 z := w.overviewZoom()
338629 scale := func(v int) int { return int(math.Round(float64(v) * z)) }
339630 width := scale(span)
340- x := u.X + (u.W-width)/2
631+ // Rows stay centred on the output like rows without a stash; the pile
632+ // only pushes a row right when they would overlap.
633+ x := max(u.X+(u.W-width)/2, u.X+w.pileWidth())
634+ left := w.pileWidth()
635+ u.X, u.W = u.X+left, u.W-left
341636 if width > u.W {
342637 x = u.X + u.W/2 - scale(sel.X+sel.W/2)
343638 x = min(max(x, u.X+u.W-width), u.X)
344639 }
345640 for i := range tiles {
641+ if tiles[i].Hidden {
642+ continue
643+ }
346644 r := tiles[i].Rect
347645 x0, y0 := scale(r.X), scale(r.Y)
348646 tiles[i].Rect = Rect{X: x + x0, Y: y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0}
@@ -369,9 +667,18 @@ func overviewOutline(layout []Placement, b int) []ports.Separator {
369667 return nil
370668 }
371669
372-// overviewAt is the preview under the output-local point, if any.
670+// overviewAt is the preview under the output-local point, if any. Front
671+// cards take priority over peeks, including peeks appended after a front
672+// tile in another row's layout.
373673 func (m *Monitor) overviewAt(x, y float64) WindowID {
374674 layout := m.Layout()
675+ for i := len(layout) - 1; i >= 0; i-- {
676+ p := layout[i]
677+ r := p.Rect
678+ if p.Preview > 0 && !p.Hidden && !p.Peek && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) {
679+ return p.ID
680+ }
681+ }
375682 for i := len(layout) - 1; i >= 0; i-- {
376683 p := layout[i]
377684 r := p.Rect
@@ -391,7 +698,7 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) {
391698 }
392699 i := c.screenIndex(o.Info.Name)
393700 sc := c.screens[i]
394- if !sc.mon.overview {
701+ if !sc.mon.ov.open {
395702 return false, nil
396703 }
397704 id := sc.mon.overviewAt(c.cursorX-float64(o.X), c.cursorY-float64(o.Y))
@@ -410,16 +717,32 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) {
410717 // overviewSwipe moves the selection like the keys: a workspace swipe
411718 // must not enter the empty workspace below the last one.
412719 func (m *Monitor) overviewSwipe(a Action) {
720+ switch a {
721+ case ActionFocusWorkspaceUp:
722+ a = ActionFocusWindowUp
723+ case ActionFocusWorkspaceDown:
724+ a = ActionFocusWindowDown
725+ }
726+ m.overviewFocus(a)
727+}
728+
729+// overviewFocus runs a focus bind as an overview move, so the focus binds
730+// and the bare keys navigate the overview the same way: true when a was
731+// one of them.
732+func (m *Monitor) overviewFocus(a Action) bool {
413733 switch a {
414734 case ActionFocusColumnLeft:
415735 m.OverviewMove(-1, 0)
416736 case ActionFocusColumnRight:
417737 m.OverviewMove(1, 0)
418- case ActionFocusWorkspaceUp:
738+ case ActionFocusWindowUp:
419739 m.OverviewMove(0, -1)
420- case ActionFocusWorkspaceDown:
740+ case ActionFocusWindowDown:
421741 m.OverviewMove(0, 1)
742+ default:
743+ return false
422744 }
745+ return true
423746 }
424747
425748 // overviewScrollStep is the two-finger scroll distance, in libinput's
@@ -429,13 +752,12 @@ const overviewScrollStep = 60
429752 // overviewScroll moves the overview selection with a scroll frame: two
430753 // fingers step once per overviewScrollStep on the axis they move most
431754 // along, a wheel once per notch (high-resolution wheels add up their
432-// fractions of a notch). Scrolling down or right selects the workspace
433-// below or the column on the right; touchpad.natural-scroll flips both,
434-// as libinput reports. changed is false while the distance adds up.
755+// fractions of a notch). Vertical steps select cards first, then rows;
756+// touchpad.natural-scroll flips both axes as libinput reports.
435757 func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
436758 if a.Vertical.Stop || a.Horizontal.Stop {
437759 // Fingers lifted: the next scroll starts from zero.
438- m.scrollX, m.scrollY = 0, 0
760+ m.ov.scrollX, m.ov.scrollY = 0, 0
439761 return false
440762 }
441763 add := func(acc *float64, ax ports.ScrollAxis) {
@@ -447,18 +769,18 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
447769 *acc += ax.Value
448770 }
449771 }
450- add(&m.scrollY, a.Vertical)
451- add(&m.scrollX, a.Horizontal)
772+ add(&m.ov.scrollY, a.Vertical)
773+ add(&m.ov.scrollX, a.Horizontal)
452774 for {
453775 switch {
454- case math.Abs(m.scrollY) >= overviewScrollStep && math.Abs(m.scrollY) >= math.Abs(m.scrollX):
455- m.OverviewMove(0, sign(m.scrollY))
456- m.scrollY -= float64(sign(m.scrollY)) * overviewScrollStep
457- m.scrollX = 0
458- case math.Abs(m.scrollX) >= overviewScrollStep:
459- m.OverviewMove(sign(m.scrollX), 0)
460- m.scrollX -= float64(sign(m.scrollX)) * overviewScrollStep
461- m.scrollY = 0
776+ case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX):
777+ m.OverviewMove(0, sign(m.ov.scrollY))
778+ m.ov.scrollY -= float64(sign(m.ov.scrollY)) * overviewScrollStep
779+ m.ov.scrollX = 0
780+ case math.Abs(m.ov.scrollX) >= overviewScrollStep:
781+ m.OverviewMove(sign(m.ov.scrollX), 0)
782+ m.ov.scrollX -= float64(sign(m.ov.scrollX)) * overviewScrollStep
783+ m.ov.scrollY = 0
462784 default:
463785 return changed
464786 }
@@ -3,18 +3,35 @@ package core
3 import (3 import (
4 "context"4 "context"
5 "math"5 "math"
6+ "slices"
6 7
7 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
8 )9 )
9 10
10-// The overview shows every column of the workspace on screen in one row,11+// overviewState owns the provisional selection and opening snapshot. row is
11-// each tile scaled by the same factor so windows keep their shape, with12+// the workspace whose stack has a provisional front; nil uses its real front.
12-// the workspaces above and below it dimmed. A workspace pinned under a13+type overviewState struct {
13-// covering fullscreen window (a game) shows only that window, as on14+ open bool
14-// screen. The previews15+ from *Workspace
15-// are the windows' last buffers: clients are not resized. h/l (or left/right) select a16+ fromID WindowID
16-// column, j/k (or down/up) a workspace, Return keeps the selection and17+ floats []Float
17-// Escape returns to where the overview opened.18+ fullWidth WindowID
19+ row *Workspace
20+ front stackItem
21+ frontAt int
22+ cardOf *Workspace
23+ card WindowID
24+ selected WindowID
25+ selectedAt int
26+ // FullWidth history is restored on Escape when active binds changed it.
27+ maximized []WindowID
28+ scrollX, scrollY float64
29+}
30+
31+// The overview scales workspace rows without resizing clients. Covering
32+// floats and the columns form a linear stack of cards; small floats stay
33+// hidden. Up/down traverse the stack before changing workspace. A pinned
34+// fullscreen window remains the only preview on its row.
18 35
19 // A workspace's stash shows as a pile of cards pinned to the left edge of36 // A workspace's stash shows as a pile of cards pinned to the left edge of
20 // its row: the selected stashed window in front at overviewCardZoom, up to37 // its row: the selected stashed window in front at overviewCardZoom, up to
@@ -24,6 +41,7 @@ const (
24 overviewCardZoom = 0.241 overviewCardZoom = 0.2
25 overviewCards = 442 overviewCards = 4
26 overviewCardStep = 843 overviewCardStep = 8
44+ overviewPeekStep = 0.07
27 )45 )
28 46
29 // overviewMaxZoom caps the current row so its neighbors show above and47 // overviewMaxZoom caps the current row so its neighbors show above and
@@ -35,7 +53,7 @@ const overviewMaxZoom, overviewMinZoom = 0.6, 0.25
35 // a covering fullscreen window (a game) too: its row shows it, composed53 // a covering fullscreen window (a game) too: its row shows it, composed
36 // rather than scanned out while the overview is open.54 // rather than scanned out while the overview is open.
37 func (m *Monitor) ToggleOverview() {55 func (m *Monitor) ToggleOverview() {
38- if m.overview {56+ if m.ov.open {
39 m.closeOverview()57 m.closeOverview()
40 return58 return
41 }59 }
@@ -44,55 +62,118 @@ func (m *Monitor) ToggleOverview() {
44 // settled state.62 // settled state.
45 m.stopSwitch()63 m.stopSwitch()
46 m.each(func(w *Workspace) { w.stopSlide() })64 m.each(func(w *Workspace) { w.stopSlide() })
47- m.overview, m.overviewFrom = true, w65+ m.ov.open, m.ov.from = true, w
48- m.overviewFromID, _ = w.Focused()66+ m.ov.fromID, _ = w.Focused()
49- m.scrollX, m.scrollY = 0, 067+ m.ov.floats = append([]Float(nil), w.Floats...)
68+ m.ov.maximized = slices.Clone(w.maximized)
69+ m.ov.fullWidth = 0
70+ if len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth {
71+ c := w.Columns[w.Focus]
72+ m.ov.fullWidth = c.Windows[c.Focus]
73+ }
74+ m.ov.scrollX, m.ov.scrollY = 0, 0
50 m.overviewOpens++75 m.overviewOpens++
51 m.selectRow()76 m.selectRow()
52 }77 }
53 78
54-// selectRow selects the stash card of a workspace whose stash has the79+// selectRow resets provisional stack and stash selection for a new row.
55-// focus (or only stashed windows), else its focused column. The overview
56-// hides native floats, so a float's focus moves to its column; Escape
57-// gives it back. A pinning fullscreen window is the exception: it is the
58-// row.
59 func (m *Monitor) selectRow() {80 func (m *Monitor) selectRow() {
60- m.overviewCard, m.overviewCardOf = 0, nil81+ m.ov.card, m.ov.cardOf = 0, nil
82+ m.ov.front, m.ov.row, m.ov.frontAt = stackItem{}, nil, 0
83+ m.ov.selected, m.ov.selectedAt = 0, -1
61 w := m.Current()84 w := m.Current()
62 if w.pinned() {85 if w.pinned() {
63- // The covering window is the row's only preview: it keeps the focus.
64 return86 return
65 }87 }
66- w.floatFocus = false88+ if w.floatFocus && len(w.Columns) > 0 && (len(w.stack()) < 2 || m.stackFront(w).kind == stackColumns) {
67- if len(w.Stash) > 0 && (len(w.Columns) == 0 || w.stashFocused()) {89+ w.floatFocus = false
90+ }
91+ if len(w.Stash) > 0 && (len(w.stack()) == 0 || w.stashFocused() && !w.floatFocus) {
68 m.selectCard(w, w.stashAt)92 m.selectCard(w, w.stashAt)
69 }93 }
70 }94 }
71 95
72 // selectCard selects stash entry i of w.96 // selectCard selects stash entry i of w.
73 func (m *Monitor) selectCard(w *Workspace, i int) {97 func (m *Monitor) selectCard(w *Workspace, i int) {
74- m.overviewCard, m.overviewCardOf = w.Stash[i].ID, w98+ m.ov.card, m.ov.cardOf = w.Stash[i].ID, w
75 }99 }
76 100
77 // card is the selected stash card, 0 when there is none: the overview is101 // card is the selected stash card, 0 when there is none: the overview is
78 // closed, the selection is on a column, or its window left the stash on102 // closed, the selection is on a column, or its window left the stash on
79 // screen (unmapped, unstashed, or another workspace shown by a bind).103 // screen (unmapped, unstashed, or another workspace shown by a bind).
80 func (m *Monitor) card() WindowID {104 func (m *Monitor) card() WindowID {
81- w := m.overviewCardOf105+ w := m.ov.cardOf
82- if !m.overview || w == nil || w != m.Current() || w.stashIndex(m.overviewCard) < 0 {106+ if !m.ov.open || w == nil || w != m.Current() || w.stashIndex(m.ov.card) < 0 {
83 return 0107 return 0
84 }108 }
85- return m.overviewCard109+ return m.ov.card
86 }110 }
87 111
88 // cardAt is the pile entry of w that is selected, -1 when none.112 // cardAt is the pile entry of w that is selected, -1 when none.
89 func (m *Monitor) cardAt(w *Workspace) int {113 func (m *Monitor) cardAt(w *Workspace) int {
90- if id := m.card(); id != 0 && w == m.overviewCardOf {114+ if id := m.card(); id != 0 && w == m.ov.cardOf {
91 return w.stashIndex(id)115 return w.stashIndex(id)
92 }116 }
93 return -1117 return -1
94 }118 }
95 119
120+// overviewTarget resolves the active preview to one window for validation
121+// and window actions. Presentation state never falls back to a covering float.
122+func (m *Monitor) overviewTarget() WindowID {
123+ if id := m.card(); id != 0 {
124+ return id
125+ }
126+ w := m.Current()
127+ if w.pinned() {
128+ return w.cover()
129+ }
130+ item := m.stackFront(w)
131+ switch item.kind {
132+ case stackFloat:
133+ return item.id
134+ case stackColumn:
135+ i := w.columnOf(item.id)
136+ if i >= 0 {
137+ c := w.Columns[i]
138+ return c.Windows[c.Focus]
139+ }
140+ case stackColumns:
141+ i := w.Focus
142+ if len(w.Columns) == 0 {
143+ return 0
144+ }
145+ if w.overviewMaximized() {
146+ i = w.columnOf(m.ov.selected)
147+ if i < 0 || i == w.Focus {
148+ i = m.hiddenColumn(w)
149+ }
150+ }
151+ if i >= 0 {
152+ c := w.Columns[i]
153+ return c.Windows[c.Focus]
154+ }
155+ }
156+ return 0
157+}
158+
159+// overviewBlocks makes window mutations explicit: they are disabled while
160+// selection is provisional. Workspace and monitor navigation remain active.
161+func overviewBlocks(a Action) bool {
162+ if _, op, ok := WorkspaceArg(a); ok {
163+ return op != FocusWorkspace
164+ }
165+ switch a {
166+ case ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth,
167+ ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash,
168+ ActionToggleStashVisible, ActionMoveColumnToWorkspaceUp,
169+ ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp,
170+ ActionMoveWindowToWorkspaceDown, ActionConsumeOrExpelLeft,
171+ ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight:
172+ return true
173+ }
174+ return false
175+}
176+
96 // closeOverview closes the overview on the selection: a card shows the177 // closeOverview closes the overview on the selection: a card shows the
97 // stash on it. A fullscreen request another stashed window made while178 // stash on it. A fullscreen request another stashed window made while
98 // the stash was hidden is dropped: the picked card stays in front.179 // the stash was hidden is dropped: the picked card stays in front.
@@ -104,32 +185,66 @@ func (m *Monitor) closeOverview() {
104 }185 }
105 w.stashAt = i186 w.stashAt = i
106 w.showStash()187 w.showStash()
188+ } else if !w.pinned() && len(w.stack()) > 0 {
189+ w.apply(m.stackFront(w), m.overviewTarget())
107 }190 }
108- m.overview, m.overviewFrom = false, nil191+ m.ov = overviewState{}
109- m.overviewCard, m.overviewCardOf = 0, nil
110 }192 }
111 193
112 // CancelOverview closes the overview and returns to the workspace and194 // CancelOverview closes the overview and returns to the workspace and
113 // window it opened on, if they are still there.195 // window it opened on, if they are still there.
114 func (m *Monitor) CancelOverview() {196 func (m *Monitor) CancelOverview() {
115- from, id := m.overviewFrom, m.overviewFromID197+ from, id := m.ov.from, m.ov.fromID
116- m.overview, m.overviewFrom = false, nil198+ if from != nil && m.has(from) {
117- m.overviewCard, m.overviewCardOf = 0, nil199+ m.show(from)
118- if from == nil || !m.has(from) {200+ if id != 0 {
119- return201+ from.FocusID(id)
120- }202+ } else {
121- m.show(from)203+ from.floatFocus, from.stashFocus = false, false
122- if id != 0 {204+ }
123- from.FocusID(id)205+ // FocusID on a float raises it; restore the initial order and below
206+ // flags for floats still present, leaving new floats in their order.
207+ initial := m.ov.floats
208+ current := from.Floats
209+ from.Floats = nil
210+ for _, f := range initial {
211+ for i, now := range current {
212+ if now.ID == f.ID {
213+ now.below = f.below
214+ from.Floats = append(from.Floats, now)
215+ current = append(current[:i], current[i+1:]...)
216+ break
217+ }
218+ }
219+ }
220+ from.Floats = append(from.Floats, current...)
221+ // Fixed overflow maximizes at most one column: only the one holding
222+ // the anchor window, wherever active binds moved it. Scroll overflow
223+ // keeps each column's own width.
224+ if from.Overflow == OverflowFixed {
225+ from.unmaximize()
226+ for i := range from.Columns {
227+ if m.ov.fullWidth != 0 && slices.Contains(from.Columns[i].Windows, m.ov.fullWidth) {
228+ from.maximize(i)
229+ break
230+ }
231+ }
232+ }
233+ from.maximized = slices.Clone(m.ov.maximized)
234+ from.scroll()
124 }235 }
236+ m.ov = overviewState{}
125 }237 }
126 238
127-// OverviewMove selects the neighbor column (dx) or numbered workspace239+// OverviewMove routes every vertical input through moveStack, then falls
128-// (dy). It stops at the ends and never enters an empty workspace, which240+// through to workspace navigation at the stack boundary. Horizontal moves
129-// would open a terminal there. Left of the first column is the stash241+// stay inside a column group; left on a single card sends it behind.
130-// pile; right of its last entry, the first column again.
131 func (m *Monitor) OverviewMove(dx, dy int) {242 func (m *Monitor) OverviewMove(dx, dy int) {
243+ w := m.Current()
132 if dy != 0 {244 if dy != 0 {
245+ if m.moveStack(-dy) {
246+ return
247+ }
133 i := m.Active + dy248 i := m.Active + dy
134 if m.shown != nil || i < 0 || i >= len(m.Workspaces) || m.Workspaces[i].empty() {249 if m.shown != nil || i < 0 || i >= len(m.Workspaces) || m.Workspaces[i].empty() {
135 return250 return
@@ -138,7 +253,6 @@ func (m *Monitor) OverviewMove(dx, dy int) {
138 m.selectRow()253 m.selectRow()
139 return254 return
140 }255 }
141- w := m.Current()
142 if w.pinned() {256 if w.pinned() {
143 return257 return
144 }258 }
@@ -147,20 +261,62 @@ func (m *Monitor) OverviewMove(dx, dy int) {
147 case at+dx >= 0 && at+dx < len(w.Stash):261 case at+dx >= 0 && at+dx < len(w.Stash):
148 m.selectCard(w, at+dx)262 m.selectCard(w, at+dx)
149 case dx > 0 && len(w.Columns) > 0:263 case dx > 0 && len(w.Columns) > 0:
150- m.overviewCard, m.overviewCardOf = 0, nil264+ m.ov.card, m.ov.cardOf = 0, nil
151- w.FocusID(w.Columns[0].Windows[w.Columns[0].Focus])265+ item := m.stackFront(w)
266+ if item.kind == stackColumns {
267+ if w.overviewMaximized() {
268+ i := m.hiddenColumn(w)
269+ m.ov.selected, m.ov.selectedAt = w.Columns[i].Windows[w.Columns[i].Focus], i
270+ } else {
271+ w.selectOverviewColumn(0)
272+ m.ov.selected = w.Columns[0].Windows[w.Columns[0].Focus]
273+ }
274+ }
152 }275 }
153 return276 return
154 }277 }
155- if dx < 0 && (len(w.Columns) == 0 || w.Focus == 0) && len(w.Stash) > 0 {278+ front := m.stackFront(w)
156- m.selectCard(w, w.stashAt)279+ if front.kind != stackColumns {
280+ if dx < 0 && m.moveStack(1) {
281+ return
282+ }
283+ if dx < 0 && len(w.Stash) > 0 {
284+ m.selectCard(w, w.stashAt)
285+ }
157 return286 return
158 }287 }
159 if len(w.Columns) == 0 {288 if len(w.Columns) == 0 {
160 return289 return
161 }290 }
162- c := w.Columns[min(max(w.Focus+dx, 0), len(w.Columns)-1)]291+ i := w.columnOf(m.ov.selected)
163- w.FocusID(c.Windows[c.Focus])292+ if i < 0 || w.overviewMaximized() && i == w.Focus {
293+ if w.overviewMaximized() {
294+ i = m.hiddenColumn(w)
295+ } else {
296+ i = w.Focus
297+ }
298+ }
299+ first := 0
300+ if w.overviewMaximized() && w.Focus == 0 {
301+ first = 1
302+ }
303+ if dx < 0 && i == first && len(w.Stash) > 0 {
304+ m.selectCard(w, w.stashAt)
305+ return
306+ }
307+ next := min(max(i+dx, 0), len(w.Columns)-1)
308+ if w.overviewMaximized() && next == w.Focus {
309+ next = min(max(next+dx, 0), len(w.Columns)-1)
310+ if next == w.Focus {
311+ return
312+ }
313+ }
314+ m.ov.selected = w.Columns[next].Windows[w.Columns[next].Focus]
315+ m.ov.selectedAt = next
316+ if !w.overviewMaximized() {
317+ // Ordinary group navigation retains its existing scroll-to-selection.
318+ w.selectOverviewColumn(next)
319+ }
164 }320 }
165 321
166 // OverviewPick closes the overview on the clicked window.322 // OverviewPick closes the overview on the clicked window.
@@ -169,33 +325,52 @@ func (m *Monitor) OverviewPick(id WindowID) {
169 if w == nil {325 if w == nil {
170 return326 return
171 }327 }
328+ if w.floatIndex(id) >= 0 && w.stashIndex(id) < 0 {
329+ i := w.floatIndex(id)
330+ if !w.coversFloat(w.Floats[i]) && !w.pinned() {
331+ return
332+ }
333+ }
172 m.show(w)334 m.show(w)
173- m.overviewCard, m.overviewCardOf = 0, nil335+ m.ov.card, m.ov.cardOf = 0, nil
174 if i := w.stashIndex(id); i >= 0 {336 if i := w.stashIndex(id); i >= 0 {
175 m.selectCard(w, i)337 m.selectCard(w, i)
176- } else {338+ } else if w.pinned() {
177 w.FocusID(id)339 w.FocusID(id)
340+ } else if item, ok := w.itemOf(id); ok {
341+ m.setFront(w, item)
342+ if i := w.columnOf(id); i >= 0 {
343+ if item.kind == stackColumns && !w.overviewMaximized() {
344+ w.selectOverviewColumn(i)
345+ }
346+ m.ov.selected = id
347+ for j, v := range w.Columns[i].Windows {
348+ if v == id {
349+ w.Columns[i].Focus = j
350+ break
351+ }
352+ }
353+ }
178 }354 }
179 m.closeOverview()355 m.closeOverview()
180 }356 }
181 357
182 // overviewLayout places the current workspace's row in the middle of the358 // overviewLayout places the current workspace's row in the middle of the
183 // usable area and its numbered neighbors above and below, dimmed, each359 // usable area and its numbered neighbors above and below, dimmed, each
184-// with its stash pile. Other workspaces and native floats are hidden.360+// with its stash pile and covering-float stack. Other workspaces are hidden.
185 func (m *Monitor) overviewLayout() []Placement {361 func (m *Monitor) overviewLayout() []Placement {
186 cur := m.Current()362 cur := m.Current()
187 u := cur.Usable363 u := cur.Usable
188 gap := u.H * 3 / 100364 gap := u.H * 3 / 100
189 var result []Placement365 var result []Placement
190- zc := cur.overviewZoom()366+ hc := cur.rowHeight()
191- hc := int(math.Round(float64(u.H) * zc))
192 y := u.Y + (u.H-hc)/2367 y := u.Y + (u.H-hc)/2
193 rows := map[*Workspace]int{}368 rows := map[*Workspace]int{}
194 rows[cur] = y369 rows[cur] = y
195 if i := indexOf(m.Workspaces, cur); i >= 0 && m.shown == nil {370 if i := indexOf(m.Workspaces, cur); i >= 0 && m.shown == nil {
196 if i > 0 {371 if i > 0 {
197 up := m.Workspaces[i-1]372 up := m.Workspaces[i-1]
198- rows[up] = y - gap - int(math.Round(float64(u.H)*up.overviewZoom()))373+ rows[up] = y - gap - up.rowHeight()
199 }374 }
200 if i+1 < len(m.Workspaces) {375 if i+1 < len(m.Workspaces) {
201 rows[m.Workspaces[i+1]] = y + hc + gap376 rows[m.Workspaces[i+1]] = y + hc + gap
@@ -215,11 +390,13 @@ func (m *Monitor) overviewLayout() []Placement {
215 }390 }
216 if shown {391 if shown {
217 at := m.cardAt(w)392 at := m.cardAt(w)
218- result = append(result, w.previewRow(ry, w != cur, at < 0)...)393+ result = append(result, m.stackRow(w, ry, w != cur, at < 0)...)
219 result = append(result, w.pile(ry, w != cur, at)...)394 result = append(result, w.pile(ry, w != cur, at)...)
220 }395 }
221 for _, f := range w.Floats {396 for _, f := range w.Floats {
222- result = append(result, Placement{ID: f.ID, Hidden: true})397+ if _, drawn := w.itemOf(f.ID); !shown || !drawn {
398+ result = append(result, Placement{ID: f.ID, Hidden: true})
399+ }
223 }400 }
224 if !shown {401 if !shown {
225 for _, id := range w.windows() {402 for _, id := range w.windows() {
@@ -232,6 +409,72 @@ func (m *Monitor) overviewLayout() []Placement {
232 return result409 return result
233 }410 }
234 411
412+// fan places front and neighboring cards, farthest visible first and front
413+// last. A card may contain several tiles; omitted cards stay in the layout
414+// hidden so their clients are not left showing on screen.
415+func fan(cards [][]Placement, front, maxBehind, maxBefore int, circular bool,
416+ behind, before func(int) (int, int), dim, lit bool) []Placement {
417+ n := len(cards)
418+ if n == 0 {
419+ return nil
420+ }
421+ front = min(max(front, 0), n-1)
422+ out := make([]Placement, 0, n)
423+ add := func(i, distance int, offset func(int) (int, int), peek bool) {
424+ dx, dy := offset(distance)
425+ for _, p := range cards[i] {
426+ if !p.Hidden {
427+ p.Rect.X += dx
428+ p.Rect.Y += dy
429+ p.Peek = p.Peek || peek || dim
430+ p.Focused = p.Focused && !peek && !dim && lit
431+ }
432+ out = append(out, p)
433+ }
434+ }
435+ shown := make([]bool, n)
436+ // Behind and before are in nearest-first order; paint farthest first.
437+ for d := min(maxBehind, n-1); d >= 1; d-- {
438+ i := front + d
439+ if circular {
440+ i = (front + d) % n
441+ }
442+ if i >= n {
443+ continue
444+ }
445+ add(i, d, behind, true)
446+ shown[i] = true
447+ }
448+ for d := min(maxBefore, n-1); d >= 1; d-- {
449+ i := front - d
450+ if circular {
451+ i = (front - d + n) % n
452+ }
453+ if i < 0 {
454+ continue
455+ }
456+ if shown[i] {
457+ continue
458+ }
459+ add(i, d, before, true)
460+ shown[i] = true
461+ }
462+ add(front, 0, behind, false)
463+ shown[front] = true
464+ for i, card := range cards {
465+ if shown[i] {
466+ continue
467+ }
468+ for _, p := range card {
469+ p.Hidden = true
470+ p.Preview = 0
471+ p.Focused = false
472+ out = append(out, p)
473+ }
474+ }
475+ return out
476+}
477+
235 // pileWidth is the room the stash pile of w takes on the left of its row,478 // pileWidth is the room the stash pile of w takes on the left of its row,
236 // its margin included; 0 without a stash.479 // its margin included; 0 without a stash.
237 func (w *Workspace) pileWidth() int {480 func (w *Workspace) pileWidth() int {
@@ -267,19 +510,17 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement {
267 cw := int(math.Round(float64(r.W) * overviewCardZoom))510 cw := int(math.Round(float64(r.W) * overviewCardZoom))
268 ch := int(math.Round(float64(r.H) * overviewCardZoom))511 ch := int(math.Round(float64(r.H) * overviewCardZoom))
269 rowH := int(math.Round(float64(u.H) * w.overviewZoom()))512 rowH := int(math.Round(float64(u.H) * w.overviewZoom()))
270- x0, y0 := u.X+u.W/100, y+(rowH-ch)/2513+ // Stack rows reserve two peek steps above the front card. Align the
271- shown := min(n, overviewCards)514+ // stash with that front band, not with the top of its envelope.
272- out := make([]Placement, 0, n)515+ x0, y0 := u.X+u.W/100, w.frontRowY(y)+(rowH-ch)/2
273- // Farthest card first: the front one is drawn and hit on top.516+ cards := make([][]Placement, n)
274- for k := shown - 1; k >= 0; k-- {517+ for i := range w.Stash {
275- i := (front + k) % n518+ cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: Rect{X: x0, Y: y0, W: cw, H: ch}, Preview: overviewCardZoom, Focused: selected}}
276- step := k * w.cardStep()519+ }
277- out = append(out, Placement{ID: w.Stash[i].ID, Rect: Rect{X: x0 + step, Y: y0 - step, W: cw, H: ch}, Preview: overviewCardZoom, Peek: dim || k > 0, Focused: k == 0 && selected})520+ step := w.cardStep()
278- }521+ return fan(cards, front, min(n, overviewCards)-1, 0, true,
279- for k := shown; k < n; k++ {522+ func(d int) (int, int) { return d * step, -d * step },
280- out = append(out, Placement{ID: w.Stash[(front+k)%n].ID, Hidden: true})523+ func(int) (int, int) { return 0, 0 }, dim, selected)
281- }
282- return out
283 }524 }
284 525
285 // overviewZoom is the scale that fits every column of w in the usable526 // overviewZoom is the scale that fits every column of w in the usable
@@ -290,13 +531,41 @@ func (w *Workspace) overviewZoom() float64 {
290 return overviewMaxZoom531 return overviewMaxZoom
291 }532 }
292 room := float64(w.Usable.W-w.pileWidth()) * 0.96533 room := float64(w.Usable.W-w.pileWidth()) * 0.96
293- return min(max(room/float64(span), overviewMinZoom), overviewMaxZoom)534+ z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom)
535+ if len(w.stack()) > 1 {
536+ // Reserve two clear peeks on either side of the front row.
537+ z = min(z, overviewMaxZoom/(1+4*overviewPeekStep))
538+ }
539+ return z
540+}
541+
542+// peekStep is the shared step for a stack row and its front-card inset.
543+func (w *Workspace) peekStep() int {
544+ return max(1, int(math.Round(float64(w.Usable.H)*w.overviewZoom()*overviewPeekStep)))
545+}
546+
547+// frontRowY skips the space reserved for two cards peeking above the front.
548+// A single-card row starts at y, as before.
549+func (w *Workspace) frontRowY(y int) int {
550+ if len(w.stack()) > 1 {
551+ return y + 2*w.peekStep()
552+ }
553+ return y
554+}
555+
556+// rowHeight includes the largest possible stack envelope (two peeks above
557+// and two below). Neighbor rows use the same measure as the current row.
558+func (w *Workspace) rowHeight() int {
559+ h := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))
560+ if len(w.stack()) > 1 {
561+ h += 4 * w.peekStep()
562+ }
563+ return h
294 }564 }
295 565
296-// previewTiles lays the tiles of w out unscrolled, relative to the566+// previewTiles lays tiles out unscrolled, relative to the usable area's
297-// usable area's corner: every column side by side at its width, as if567+// corner. Fixed overflow uses its on-screen geometry; scroll overflow uses
298-// the output were wide enough (fixed overflow's spiral included). span is568+// an unscrolled row. span is the row's width; sel is the focused column.
299-// the row's width; sel is the focused column.
300 func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {569 func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
301 g := w.gap()570 g := w.gap()
302 h := max(w.Usable.H-2*g, 0)571 h := max(w.Usable.H-2*g, 0)
@@ -307,16 +576,36 @@ func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
307 r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: h}576 r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: h}
308 return []Placement{{ID: w.cover(), Rect: r, Focused: true, Fullscreen: true}}, r.X + r.W + g, r577 return []Placement{{ID: w.cover(), Rect: r, Focused: true, Fullscreen: true}}, r.X + r.W + g, r
309 }578 }
579+ var rects []Rect
580+ if w.Overflow == OverflowFixed {
581+ rects = w.columnRects()
582+ span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.
583+ }
584+ maximized := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth
310 for i, c := range w.Columns {585 for i, c := range w.Columns {
311 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}586 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}
312- if w.fullscreenColumn(i) {587+ if w.Overflow == OverflowFixed {
588+ r = rects[i]
589+ r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y
590+ } else if w.fullscreenColumn(i) {
313 // A fullscreen column shows at the width it had in the row.591 // A fullscreen column shows at the width it had in the row.
314 r.W = max(w.Usable.W-2*g, 0)592 r.W = max(w.Usable.W-2*g, 0)
315 }593 }
594+ if maximized && i != w.Focus {
595+ // FullWidth hides other columns on screen. Their old buffers
596+ // cannot fit their current spiral rects; show only the maximized
597+ // column until navigation restores the normal layout.
598+ for _, id := range c.Windows {
599+ tiles = append(tiles, Placement{ID: id, Hidden: true})
600+ }
601+ continue
602+ }
316 for j, t := range stackRects(r, len(c.Windows), g) {603 for j, t := range stackRects(r, len(c.Windows), g) {
317 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus})604 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus})
318 }605 }
319- span = max(span, r.X+r.W+g)606+ if w.Overflow != OverflowFixed {
607+ span = max(span, r.X+r.W+g)
608+ }
320 if i == w.Focus {609 if i == w.Focus {
321 sel = r610 sel = r
322 }611 }
@@ -331,18 +620,27 @@ func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
331 // false).620 // false).
332 func (w *Workspace) previewRow(y int, dim, lit bool) []Placement {621 func (w *Workspace) previewRow(y int, dim, lit bool) []Placement {
333 tiles, span, sel := w.previewTiles()622 tiles, span, sel := w.previewTiles()
623+ return w.previewRowTiles(y, dim, lit, tiles, span, sel)
624+}
625+
626+func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement {
334 u := w.Usable627 u := w.Usable
335- left := w.pileWidth()
336- u.X, u.W = u.X+left, u.W-left
337 z := w.overviewZoom()628 z := w.overviewZoom()
338 scale := func(v int) int { return int(math.Round(float64(v) * z)) }629 scale := func(v int) int { return int(math.Round(float64(v) * z)) }
339 width := scale(span)630 width := scale(span)
340- x := u.X + (u.W-width)/2631+ // Rows stay centred on the output like rows without a stash; the pile
632+ // only pushes a row right when they would overlap.
633+ x := max(u.X+(u.W-width)/2, u.X+w.pileWidth())
634+ left := w.pileWidth()
635+ u.X, u.W = u.X+left, u.W-left
341 if width > u.W {636 if width > u.W {
342 x = u.X + u.W/2 - scale(sel.X+sel.W/2)637 x = u.X + u.W/2 - scale(sel.X+sel.W/2)
343 x = min(max(x, u.X+u.W-width), u.X)638 x = min(max(x, u.X+u.W-width), u.X)
344 }639 }
345 for i := range tiles {640 for i := range tiles {
641+ if tiles[i].Hidden {
642+ continue
643+ }
346 r := tiles[i].Rect644 r := tiles[i].Rect
347 x0, y0 := scale(r.X), scale(r.Y)645 x0, y0 := scale(r.X), scale(r.Y)
348 tiles[i].Rect = Rect{X: x + x0, Y: y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0}646 tiles[i].Rect = Rect{X: x + x0, Y: y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0}
@@ -369,9 +667,18 @@ func overviewOutline(layout []Placement, b int) []ports.Separator {
369 return nil667 return nil
370 }668 }
371 669
372-// overviewAt is the preview under the output-local point, if any.670+// overviewAt is the preview under the output-local point, if any. Front
671+// cards take priority over peeks, including peeks appended after a front
672+// tile in another row's layout.
373 func (m *Monitor) overviewAt(x, y float64) WindowID {673 func (m *Monitor) overviewAt(x, y float64) WindowID {
374 layout := m.Layout()674 layout := m.Layout()
675+ for i := len(layout) - 1; i >= 0; i-- {
676+ p := layout[i]
677+ r := p.Rect
678+ if p.Preview > 0 && !p.Hidden && !p.Peek && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) {
679+ return p.ID
680+ }
681+ }
375 for i := len(layout) - 1; i >= 0; i-- {682 for i := len(layout) - 1; i >= 0; i-- {
376 p := layout[i]683 p := layout[i]
377 r := p.Rect684 r := p.Rect
@@ -391,7 +698,7 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) {
391 }698 }
392 i := c.screenIndex(o.Info.Name)699 i := c.screenIndex(o.Info.Name)
393 sc := c.screens[i]700 sc := c.screens[i]
394- if !sc.mon.overview {701+ if !sc.mon.ov.open {
395 return false, nil702 return false, nil
396 }703 }
397 id := sc.mon.overviewAt(c.cursorX-float64(o.X), c.cursorY-float64(o.Y))704 id := sc.mon.overviewAt(c.cursorX-float64(o.X), c.cursorY-float64(o.Y))
@@ -410,16 +717,32 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) {
410 // overviewSwipe moves the selection like the keys: a workspace swipe717 // overviewSwipe moves the selection like the keys: a workspace swipe
411 // must not enter the empty workspace below the last one.718 // must not enter the empty workspace below the last one.
412 func (m *Monitor) overviewSwipe(a Action) {719 func (m *Monitor) overviewSwipe(a Action) {
720+ switch a {
721+ case ActionFocusWorkspaceUp:
722+ a = ActionFocusWindowUp
723+ case ActionFocusWorkspaceDown:
724+ a = ActionFocusWindowDown
725+ }
726+ m.overviewFocus(a)
727+}
728+
729+// overviewFocus runs a focus bind as an overview move, so the focus binds
730+// and the bare keys navigate the overview the same way: true when a was
731+// one of them.
732+func (m *Monitor) overviewFocus(a Action) bool {
413 switch a {733 switch a {
414 case ActionFocusColumnLeft:734 case ActionFocusColumnLeft:
415 m.OverviewMove(-1, 0)735 m.OverviewMove(-1, 0)
416 case ActionFocusColumnRight:736 case ActionFocusColumnRight:
417 m.OverviewMove(1, 0)737 m.OverviewMove(1, 0)
418- case ActionFocusWorkspaceUp:738+ case ActionFocusWindowUp:
419 m.OverviewMove(0, -1)739 m.OverviewMove(0, -1)
420- case ActionFocusWorkspaceDown:740+ case ActionFocusWindowDown:
421 m.OverviewMove(0, 1)741 m.OverviewMove(0, 1)
742+ default:
743+ return false
422 }744 }
745+ return true
423 }746 }
424 747
425 // overviewScrollStep is the two-finger scroll distance, in libinput's748 // overviewScrollStep is the two-finger scroll distance, in libinput's
@@ -429,13 +752,12 @@ const overviewScrollStep = 60
429 // overviewScroll moves the overview selection with a scroll frame: two752 // overviewScroll moves the overview selection with a scroll frame: two
430 // fingers step once per overviewScrollStep on the axis they move most753 // fingers step once per overviewScrollStep on the axis they move most
431 // along, a wheel once per notch (high-resolution wheels add up their754 // along, a wheel once per notch (high-resolution wheels add up their
432-// fractions of a notch). Scrolling down or right selects the workspace755+// fractions of a notch). Vertical steps select cards first, then rows;
433-// below or the column on the right; touchpad.natural-scroll flips both,756+// touchpad.natural-scroll flips both axes as libinput reports.
434-// as libinput reports. changed is false while the distance adds up.
435 func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {757 func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
436 if a.Vertical.Stop || a.Horizontal.Stop {758 if a.Vertical.Stop || a.Horizontal.Stop {
437 // Fingers lifted: the next scroll starts from zero.759 // Fingers lifted: the next scroll starts from zero.
438- m.scrollX, m.scrollY = 0, 0760+ m.ov.scrollX, m.ov.scrollY = 0, 0
439 return false761 return false
440 }762 }
441 add := func(acc *float64, ax ports.ScrollAxis) {763 add := func(acc *float64, ax ports.ScrollAxis) {
@@ -447,18 +769,18 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
447 *acc += ax.Value769 *acc += ax.Value
448 }770 }
449 }771 }
450- add(&m.scrollY, a.Vertical)772+ add(&m.ov.scrollY, a.Vertical)
451- add(&m.scrollX, a.Horizontal)773+ add(&m.ov.scrollX, a.Horizontal)
452 for {774 for {
453 switch {775 switch {
454- case math.Abs(m.scrollY) >= overviewScrollStep && math.Abs(m.scrollY) >= math.Abs(m.scrollX):776+ case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX):
455- m.OverviewMove(0, sign(m.scrollY))777+ m.OverviewMove(0, sign(m.ov.scrollY))
456- m.scrollY -= float64(sign(m.scrollY)) * overviewScrollStep778+ m.ov.scrollY -= float64(sign(m.ov.scrollY)) * overviewScrollStep
457- m.scrollX = 0779+ m.ov.scrollX = 0
458- case math.Abs(m.scrollX) >= overviewScrollStep:780+ case math.Abs(m.ov.scrollX) >= overviewScrollStep:
459- m.OverviewMove(sign(m.scrollX), 0)781+ m.OverviewMove(sign(m.ov.scrollX), 0)
460- m.scrollX -= float64(sign(m.scrollX)) * overviewScrollStep782+ m.ov.scrollX -= float64(sign(m.ov.scrollX)) * overviewScrollStep
461- m.scrollY = 0783+ m.ov.scrollY = 0
462 default:784 default:
463 return changed785 return changed
464 }786 }
A internal/core/overview_behavior_test.go +130 -0
@@ -0,0 +1,130 @@
1+package core
2+
3+import (
4+ "math"
5+ "slices"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+func TestOverviewVerticalBoundariesAndInputs(t *testing.T) {
12+ m := stackMonitor()
13+ w := m.Current()
14+ m.ToggleOverview()
15+ if m.stackFront(w) != (stackItem{stackFloat, 9}) {
16+ t.Fatal("screen front")
17+ }
18+ m.overviewSwipe(ActionFocusWorkspaceUp)
19+ if m.stackFront(w).kind != stackColumns || m.Active != 0 {
20+ t.Fatal("swipe did not visit card")
21+ }
22+ m.overviewSwipe(ActionFocusWorkspaceUp)
23+ if m.Active != 0 {
24+ t.Fatal("up at top crossed workspace boundary")
25+ }
26+ m.overviewSwipe(ActionFocusWorkspaceDown)
27+ if m.stackFront(w).kind != stackFloat || m.Active != 0 {
28+ t.Fatal("down did not return to front")
29+ }
30+ m.OverviewMove(0, 1)
31+ if m.Active != 1 {
32+ t.Fatal("down did not reach workspace below")
33+ }
34+ m.OverviewMove(0, -1)
35+ if m.Active != 0 || m.stackFront(w).kind != stackFloat {
36+ t.Fatal("row change kept stack cursor")
37+ }
38+ if !m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, V120: -120}}) || m.stackFront(w).kind != stackColumns {
39+ t.Fatal("wheel did not visit card")
40+ }
41+ m.CancelOverview()
42+}
43+
44+func TestOverviewPeekEnvelopeAndSingleCardHorizontal(t *testing.T) {
45+ m := stackMonitor()
46+ w := m.Current()
47+ w.AddFloating(8, 300, 200)
48+ w.AddFloating(7, 300, 200)
49+ m.ToggleOverview()
50+ front := previewOf(t, m.Layout(), 7)
51+ back := previewOf(t, m.Layout(), 8)
52+ step := float64(front.Rect.Y - back.Rect.Y)
53+ if step < float64(w.Usable.H)*w.overviewZoom()*0.06 {
54+ t.Fatalf("peek step %.1f", step)
55+ }
56+ y := w.Usable.Y + (w.Usable.H-w.rowHeight())/2
57+ if back.Rect.Y < y || front.Rect.Y+front.Rect.H > y+w.rowHeight() {
58+ t.Fatalf("outside row front %+v back %+v row %d..%d", front, back, y, y+w.rowHeight())
59+ }
60+ m.OverviewMove(1, 0)
61+ if m.stackFront(w) != (stackItem{stackFloat, 7}) {
62+ t.Fatal("right moved single card")
63+ }
64+ m.OverviewMove(-1, 0)
65+ if m.stackFront(w) != (stackItem{stackFloat, 8}) {
66+ t.Fatal("left did not send single card behind")
67+ }
68+ m.CancelOverview()
69+}
70+
71+// The stash front is centred on the same row band as the stack's front,
72+// whether that row has peeking cards or just one column-group card.
73+func TestOverviewPileAlignedWithRowFront(t *testing.T) {
74+ for _, stacked := range []bool{false, true} {
75+ m := pileMonitor()
76+ w := m.Current()
77+ if stacked {
78+ m.SetBorder(2)
79+ w.AddFloating(9, 300, 200)
80+ }
81+ m.ToggleOverview()
82+ ps := m.Layout()
83+ pile := previewOf(t, ps, 7)
84+ frontID := WindowID(1)
85+ if stacked {
86+ frontID = 9
87+ }
88+ front := previewOf(t, ps, frontID)
89+ pileCentre := float64(pile.Rect.Y) + float64(pile.Rect.H)/2
90+ frontBandCentre := float64(front.Rect.Y) + float64(w.Usable.H)*front.Preview/2
91+ if math.Abs(pileCentre-frontBandCentre) > 1.5 {
92+ t.Fatalf("stacked %v: pile centre %.1f, front band centre %.1f: pile %+v front %+v", stacked, pileCentre, frontBandCentre, pile, front)
93+ }
94+ }
95+}
96+
97+func TestOverviewSingleCardLeftFallsBackToStash(t *testing.T) {
98+ m := stackMonitor()
99+ w := m.Current()
100+ w.FocusID(1)
101+ w.ToggleWindowStash()
102+ w.RemoveWindow(2)
103+ w.RemoveWindow(3)
104+ // A covering float is the only stack item; stash stays independently reachable.
105+ m.ToggleOverview()
106+ m.OverviewMove(-1, 0)
107+ if m.cardAt(w) < 0 {
108+ t.Fatal("left with no card behind did not enter stash")
109+ }
110+ m.CancelOverview()
111+}
112+
113+func TestOverviewStackMRUAndSize(t *testing.T) {
114+ m := maximizedOverview()
115+ w := m.Current()
116+ m.ToggleOverview()
117+ m.OverviewMove(0, -1)
118+ m.ToggleOverview()
119+ m.ToggleOverview()
120+ if got := w.stack(); !slices.Equal(got, []stackItem{{stackColumn, 1}, {kind: stackColumns}}) {
121+ t.Fatalf("stack %v", got)
122+ }
123+ if m.hiddenColumn(w) != 1 {
124+ t.Fatalf("MRU preselection %d, want Firefox column", m.hiddenColumn(w))
125+ }
126+ if p := previewOf(t, m.Layout(), 2); math.Abs(float64(p.Rect.W)-float64(w.columnRectsFor(true)[1].W)*p.Preview) > 1 {
127+ t.Fatalf("prior maximized buffer not fitted to cell: %+v", p)
128+ }
129+ m.CancelOverview()
130+}
@@ -0,0 +1,130 @@
1+package core
2+
3+import (
4+ "math"
5+ "slices"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+func TestOverviewVerticalBoundariesAndInputs(t *testing.T) {
12+ m := stackMonitor()
13+ w := m.Current()
14+ m.ToggleOverview()
15+ if m.stackFront(w) != (stackItem{stackFloat, 9}) {
16+ t.Fatal("screen front")
17+ }
18+ m.overviewSwipe(ActionFocusWorkspaceUp)
19+ if m.stackFront(w).kind != stackColumns || m.Active != 0 {
20+ t.Fatal("swipe did not visit card")
21+ }
22+ m.overviewSwipe(ActionFocusWorkspaceUp)
23+ if m.Active != 0 {
24+ t.Fatal("up at top crossed workspace boundary")
25+ }
26+ m.overviewSwipe(ActionFocusWorkspaceDown)
27+ if m.stackFront(w).kind != stackFloat || m.Active != 0 {
28+ t.Fatal("down did not return to front")
29+ }
30+ m.OverviewMove(0, 1)
31+ if m.Active != 1 {
32+ t.Fatal("down did not reach workspace below")
33+ }
34+ m.OverviewMove(0, -1)
35+ if m.Active != 0 || m.stackFront(w).kind != stackFloat {
36+ t.Fatal("row change kept stack cursor")
37+ }
38+ if !m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, V120: -120}}) || m.stackFront(w).kind != stackColumns {
39+ t.Fatal("wheel did not visit card")
40+ }
41+ m.CancelOverview()
42+}
43+
44+func TestOverviewPeekEnvelopeAndSingleCardHorizontal(t *testing.T) {
45+ m := stackMonitor()
46+ w := m.Current()
47+ w.AddFloating(8, 300, 200)
48+ w.AddFloating(7, 300, 200)
49+ m.ToggleOverview()
50+ front := previewOf(t, m.Layout(), 7)
51+ back := previewOf(t, m.Layout(), 8)
52+ step := float64(front.Rect.Y - back.Rect.Y)
53+ if step < float64(w.Usable.H)*w.overviewZoom()*0.06 {
54+ t.Fatalf("peek step %.1f", step)
55+ }
56+ y := w.Usable.Y + (w.Usable.H-w.rowHeight())/2
57+ if back.Rect.Y < y || front.Rect.Y+front.Rect.H > y+w.rowHeight() {
58+ t.Fatalf("outside row front %+v back %+v row %d..%d", front, back, y, y+w.rowHeight())
59+ }
60+ m.OverviewMove(1, 0)
61+ if m.stackFront(w) != (stackItem{stackFloat, 7}) {
62+ t.Fatal("right moved single card")
63+ }
64+ m.OverviewMove(-1, 0)
65+ if m.stackFront(w) != (stackItem{stackFloat, 8}) {
66+ t.Fatal("left did not send single card behind")
67+ }
68+ m.CancelOverview()
69+}
70+
71+// The stash front is centred on the same row band as the stack's front,
72+// whether that row has peeking cards or just one column-group card.
73+func TestOverviewPileAlignedWithRowFront(t *testing.T) {
74+ for _, stacked := range []bool{false, true} {
75+ m := pileMonitor()
76+ w := m.Current()
77+ if stacked {
78+ m.SetBorder(2)
79+ w.AddFloating(9, 300, 200)
80+ }
81+ m.ToggleOverview()
82+ ps := m.Layout()
83+ pile := previewOf(t, ps, 7)
84+ frontID := WindowID(1)
85+ if stacked {
86+ frontID = 9
87+ }
88+ front := previewOf(t, ps, frontID)
89+ pileCentre := float64(pile.Rect.Y) + float64(pile.Rect.H)/2
90+ frontBandCentre := float64(front.Rect.Y) + float64(w.Usable.H)*front.Preview/2
91+ if math.Abs(pileCentre-frontBandCentre) > 1.5 {
92+ t.Fatalf("stacked %v: pile centre %.1f, front band centre %.1f: pile %+v front %+v", stacked, pileCentre, frontBandCentre, pile, front)
93+ }
94+ }
95+}
96+
97+func TestOverviewSingleCardLeftFallsBackToStash(t *testing.T) {
98+ m := stackMonitor()
99+ w := m.Current()
100+ w.FocusID(1)
101+ w.ToggleWindowStash()
102+ w.RemoveWindow(2)
103+ w.RemoveWindow(3)
104+ // A covering float is the only stack item; stash stays independently reachable.
105+ m.ToggleOverview()
106+ m.OverviewMove(-1, 0)
107+ if m.cardAt(w) < 0 {
108+ t.Fatal("left with no card behind did not enter stash")
109+ }
110+ m.CancelOverview()
111+}
112+
113+func TestOverviewStackMRUAndSize(t *testing.T) {
114+ m := maximizedOverview()
115+ w := m.Current()
116+ m.ToggleOverview()
117+ m.OverviewMove(0, -1)
118+ m.ToggleOverview()
119+ m.ToggleOverview()
120+ if got := w.stack(); !slices.Equal(got, []stackItem{{stackColumn, 1}, {kind: stackColumns}}) {
121+ t.Fatalf("stack %v", got)
122+ }
123+ if m.hiddenColumn(w) != 1 {
124+ t.Fatalf("MRU preselection %d, want Firefox column", m.hiddenColumn(w))
125+ }
126+ if p := previewOf(t, m.Layout(), 2); math.Abs(float64(p.Rect.W)-float64(w.columnRectsFor(true)[1].W)*p.Preview) > 1 {
127+ t.Fatalf("prior maximized buffer not fitted to cell: %+v", p)
128+ }
129+ m.CancelOverview()
130+}
A internal/core/overview_configure_test.go +124 -0
@@ -0,0 +1,124 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// A neighbor workspace is visible only as an overview preview; its real
11+// placement on the output is hidden. Its last configure must not be resized.
12+func TestOverviewNeighborPreviewKeepsHiddenConfigureSize(t *testing.T) {
13+ cfg := ports.Config{}
14+ cfg.Keyboard.CmdKey = "super"
15+ cfg.Layout.MaxColumns = 3
16+ commands := make(chan ports.ClientCommand, 256)
17+ scenes := make(chan []ports.Scene, 1)
18+ c, err := New(cfg, Channels{Commands: commands, Scenes: scenes})
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 600, Height: 400})
23+ m := c.cur().mon
24+ m.AddWindow(1)
25+ m.Focus(1)
26+ m.AddWindow(2)
27+ ctx := context.Background()
28+ publish := func() []ports.ConfigureWindow {
29+ t.Helper()
30+ if err := c.publish(ctx); err != nil {
31+ t.Fatal(err)
32+ }
33+ <-scenes
34+ var sent []ports.ConfigureWindow
35+ for len(commands) > 0 {
36+ if v, ok := (<-commands).(ports.ConfigureWindow); ok {
37+ sent = append(sent, v)
38+ }
39+ }
40+ return sent
41+ }
42+ publish() // establish a sized configure while workspace 2 is on screen
43+ before := c.configures.sent[2]
44+ if before.Width == 0 || before.Height == 0 {
45+ t.Fatalf("missing prior size: %+v", before)
46+ }
47+ m.Focus(0)
48+ publish() // the workspace is hidden on the output
49+ if c.configures.sent[2].Visible {
50+ t.Fatal("neighbor was not hidden before overview")
51+ }
52+ m.ToggleOverview()
53+ preview := previewOf(t, m.Layout(), 2)
54+ if preview.Hidden || preview.Preview <= 0 {
55+ t.Fatalf("neighbor not shown as preview: %+v", preview)
56+ }
57+ for _, v := range publish() {
58+ if v.ID == 2 && (v.Width != before.Width || v.Height != before.Height) {
59+ t.Fatalf("hidden neighbor resized from %+v to %+v", before, v)
60+ }
61+ }
62+ got := c.configures.sent[2]
63+ if got.Width != before.Width || got.Height != before.Height {
64+ t.Fatalf("neighbor size from %+v to %+v", before, got)
65+ }
66+}
67+
68+func TestOverviewConfigureUsesRealLayoutAfterMaximize(t *testing.T) {
69+ cfg := ports.Config{}
70+ cfg.Keyboard.CmdKey = "super"
71+ cfg.Layout.Overflow = "fixed"
72+ cfg.Layout.MaxColumns = 3
73+ commands := make(chan ports.ClientCommand, 256)
74+ scenes := make(chan []ports.Scene, 1)
75+ c, err := New(cfg, Channels{Commands: commands, Scenes: scenes})
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 600, Height: 400})
80+ m := c.cur().mon
81+ for id := WindowID(1); id <= 3; id++ {
82+ m.AddWindow(id)
83+ }
84+ ctx := context.Background()
85+ publish := func() []ports.ConfigureWindow {
86+ t.Helper()
87+ if err := c.publish(ctx); err != nil {
88+ t.Fatal(err)
89+ }
90+ <-scenes
91+ var out []ports.ConfigureWindow
92+ for len(commands) > 0 {
93+ if v, ok := (<-commands).(ports.ConfigureWindow); ok {
94+ out = append(out, v)
95+ }
96+ }
97+ return out
98+ }
99+ publish()
100+ before := c.configures.sent[3]
101+ m.ToggleOverview()
102+ publish()
103+ m.Current().ToggleFullWidth() // a real workspace mutation, not an overview bind
104+ real := previewOf(t, m.Current().Layout(), 3)
105+ expected := c.clientRect(real)
106+ ps := m.Layout()
107+ preview := previewOf(t, ps, 3)
108+ if preview.Preview == 0 || expected.W <= before.Width {
109+ t.Fatalf("setup before %+v expected %+v preview %+v", before, expected, preview)
110+ }
111+ got := publish()
112+ found := false
113+ for _, v := range got {
114+ if v.ID == 3 {
115+ found = true
116+ if v.Width != expected.W || v.Height != expected.H {
117+ t.Fatalf("preview sized configure %+v want %dx%d", v, expected.W, expected.H)
118+ }
119+ }
120+ }
121+ if !found {
122+ t.Fatalf("no immediate resize while overview open; configures %v", got)
123+ }
124+}
@@ -0,0 +1,124 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// A neighbor workspace is visible only as an overview preview; its real
11+// placement on the output is hidden. Its last configure must not be resized.
12+func TestOverviewNeighborPreviewKeepsHiddenConfigureSize(t *testing.T) {
13+ cfg := ports.Config{}
14+ cfg.Keyboard.CmdKey = "super"
15+ cfg.Layout.MaxColumns = 3
16+ commands := make(chan ports.ClientCommand, 256)
17+ scenes := make(chan []ports.Scene, 1)
18+ c, err := New(cfg, Channels{Commands: commands, Scenes: scenes})
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 600, Height: 400})
23+ m := c.cur().mon
24+ m.AddWindow(1)
25+ m.Focus(1)
26+ m.AddWindow(2)
27+ ctx := context.Background()
28+ publish := func() []ports.ConfigureWindow {
29+ t.Helper()
30+ if err := c.publish(ctx); err != nil {
31+ t.Fatal(err)
32+ }
33+ <-scenes
34+ var sent []ports.ConfigureWindow
35+ for len(commands) > 0 {
36+ if v, ok := (<-commands).(ports.ConfigureWindow); ok {
37+ sent = append(sent, v)
38+ }
39+ }
40+ return sent
41+ }
42+ publish() // establish a sized configure while workspace 2 is on screen
43+ before := c.configures.sent[2]
44+ if before.Width == 0 || before.Height == 0 {
45+ t.Fatalf("missing prior size: %+v", before)
46+ }
47+ m.Focus(0)
48+ publish() // the workspace is hidden on the output
49+ if c.configures.sent[2].Visible {
50+ t.Fatal("neighbor was not hidden before overview")
51+ }
52+ m.ToggleOverview()
53+ preview := previewOf(t, m.Layout(), 2)
54+ if preview.Hidden || preview.Preview <= 0 {
55+ t.Fatalf("neighbor not shown as preview: %+v", preview)
56+ }
57+ for _, v := range publish() {
58+ if v.ID == 2 && (v.Width != before.Width || v.Height != before.Height) {
59+ t.Fatalf("hidden neighbor resized from %+v to %+v", before, v)
60+ }
61+ }
62+ got := c.configures.sent[2]
63+ if got.Width != before.Width || got.Height != before.Height {
64+ t.Fatalf("neighbor size from %+v to %+v", before, got)
65+ }
66+}
67+
68+func TestOverviewConfigureUsesRealLayoutAfterMaximize(t *testing.T) {
69+ cfg := ports.Config{}
70+ cfg.Keyboard.CmdKey = "super"
71+ cfg.Layout.Overflow = "fixed"
72+ cfg.Layout.MaxColumns = 3
73+ commands := make(chan ports.ClientCommand, 256)
74+ scenes := make(chan []ports.Scene, 1)
75+ c, err := New(cfg, Channels{Commands: commands, Scenes: scenes})
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 600, Height: 400})
80+ m := c.cur().mon
81+ for id := WindowID(1); id <= 3; id++ {
82+ m.AddWindow(id)
83+ }
84+ ctx := context.Background()
85+ publish := func() []ports.ConfigureWindow {
86+ t.Helper()
87+ if err := c.publish(ctx); err != nil {
88+ t.Fatal(err)
89+ }
90+ <-scenes
91+ var out []ports.ConfigureWindow
92+ for len(commands) > 0 {
93+ if v, ok := (<-commands).(ports.ConfigureWindow); ok {
94+ out = append(out, v)
95+ }
96+ }
97+ return out
98+ }
99+ publish()
100+ before := c.configures.sent[3]
101+ m.ToggleOverview()
102+ publish()
103+ m.Current().ToggleFullWidth() // a real workspace mutation, not an overview bind
104+ real := previewOf(t, m.Current().Layout(), 3)
105+ expected := c.clientRect(real)
106+ ps := m.Layout()
107+ preview := previewOf(t, ps, 3)
108+ if preview.Preview == 0 || expected.W <= before.Width {
109+ t.Fatalf("setup before %+v expected %+v preview %+v", before, expected, preview)
110+ }
111+ got := publish()
112+ found := false
113+ for _, v := range got {
114+ if v.ID == 3 {
115+ found = true
116+ if v.Width != expected.W || v.Height != expected.H {
117+ t.Fatalf("preview sized configure %+v want %dx%d", v, expected.W, expected.H)
118+ }
119+ }
120+ }
121+ if !found {
122+ t.Fatalf("no immediate resize while overview open; configures %v", got)
123+ }
124+}
M internal/core/overview_input_test.go +125 -0
@@ -61,6 +61,131 @@ func TestOverviewLauncherKeepsKeys(t *testing.T) {
6161 })
6262 }
6363
64+// Focus binds (cmd+arrows and their Vim twins) navigate the overview over
65+// a covering float: cmd+k/j round-trips through the columns card, then
66+// cmd+up brings it forward, cmd+left selects a column and cmd+down returns.
67+func TestOverviewFocusBindsNavigate(t *testing.T) {
68+ cfg := config.Defaults()
69+ client := make(chan ports.ClientEvent, 8)
70+ input := make(chan ports.InputEvent, 8)
71+ output := make(chan ports.OutputEvent, 8)
72+ commands := make(chan ports.ClientCommand, 256)
73+ scenes := make(chan []ports.Scene, 1)
74+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ ctx, cancel := context.WithCancel(context.Background())
79+ defer cancel()
80+ go c.Run(ctx)
81+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200}}
82+ scene(t, scenes)
83+ for id := ports.WindowID(1); id <= 2; id++ {
84+ client <- ports.WindowMapped{ID: id}
85+ scene(t, scenes)
86+ }
87+ client <- ports.WindowMapped{ID: 9, Floating: true, Width: 300, Height: 200}
88+ scene(t, scenes)
89+ focused := func(id ports.WindowID) func(ports.Scene) bool {
90+ return func(s ports.Scene) bool {
91+ for _, w := range s.Windows {
92+ if w.ID == id && w.Focused && w.Preview > 0 && w.Dim == 0 {
93+ return true
94+ }
95+ }
96+ return false
97+ }
98+ }
99+ press := func(key string) {
100+ input <- ports.KeyEvent{Keysym: key, Mods: ports.ModSuper, Pressed: true}
101+ input <- ports.KeyEvent{Keysym: key, Mods: ports.ModSuper}
102+ }
103+ press("o")
104+ sceneMatch(t, scenes, focused(9))
105+ press("k")
106+ sceneMatch(t, scenes, focused(2))
107+ press("j")
108+ sceneMatch(t, scenes, focused(9))
109+ press("Up")
110+ sceneMatch(t, scenes, focused(2))
111+ press("Left")
112+ sceneMatch(t, scenes, focused(1))
113+ press("Down")
114+ sceneMatch(t, scenes, focused(9))
115+}
116+
117+// Closing through a real bind follows the preview, not the covering float.
118+func TestOverviewCloseBindTargetsPreview(t *testing.T) {
119+ cfg := config.Defaults()
120+ client := make(chan ports.ClientEvent, 8)
121+ input := make(chan ports.InputEvent, 8)
122+ output := make(chan ports.OutputEvent, 8)
123+ commands := make(chan ports.ClientCommand, 256)
124+ scenes := make(chan []ports.Scene, 1)
125+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
126+ if err != nil {
127+ t.Fatal(err)
128+ }
129+ ctx, cancel := context.WithCancel(context.Background())
130+ defer cancel()
131+ go c.Run(ctx)
132+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200}}
133+ scene(t, scenes)
134+ for _, id := range []ports.WindowID{1, 2} {
135+ client <- ports.WindowMapped{ID: id}
136+ scene(t, scenes)
137+ }
138+ client <- ports.WindowMapped{ID: 9, Floating: true, Width: 300, Height: 200}
139+ scene(t, scenes)
140+ press := func(key string, mods ports.Mods) {
141+ input <- ports.KeyEvent{Keysym: key, Mods: mods, Pressed: true}
142+ input <- ports.KeyEvent{Keysym: key, Mods: mods}
143+ }
144+ press("o", ports.ModSuper)
145+ sceneMatch(t, scenes, func(s ports.Scene) bool {
146+ for _, w := range s.Windows {
147+ if w.ID == 9 && w.Focused && w.Preview > 0 {
148+ return true
149+ }
150+ }
151+ return false
152+ })
153+ press("Up", 0)
154+ sceneMatch(t, scenes, func(s ports.Scene) bool {
155+ for _, w := range s.Windows {
156+ if w.ID == 2 && w.Focused && w.Preview > 0 {
157+ return true
158+ }
159+ }
160+ return false
161+ })
162+ press("q", ports.ModSuper)
163+ if got := next(t, commands, anyOf[ports.CloseWindow]); got.ID != 2 {
164+ t.Fatalf("close target %d, want preview 2", got.ID)
165+ }
166+}
167+
168+func TestFloatOnlyMonitorDirectionalExit(t *testing.T) {
169+ for _, dir := range []int{-1, 1} {
170+ r := startMulti(t, nil, left, right)
171+ if dir < 0 {
172+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
173+ }
174+ r.client <- ports.WindowMapped{ID: 9, Floating: true, Width: 20, Height: 20}
175+ receive(t, r.scenes)
176+ key := "Right"
177+ want := right.Name
178+ if dir < 0 {
179+ key, want = "Left", left.Name
180+ }
181+ r.key(t, key, ports.ModAlt)
182+ out := lastOutputs(t, r.commands)
183+ if out.Focused != want {
184+ t.Fatalf("direction %d: focused output %q, want %q", dir, out.Focused, want)
185+ }
186+ }
187+}
188+
64189 // Two-finger scrolling moves the overview selection one column per step,
65190 // along the axis the fingers move most; a small scroll does nothing and
66191 // lifting the fingers starts over. Outside the overview it goes to the
@@ -61,6 +61,131 @@ func TestOverviewLauncherKeepsKeys(t *testing.T) {
61 })61 })
62 }62 }
63 63
64+// Focus binds (cmd+arrows and their Vim twins) navigate the overview over
65+// a covering float: cmd+k/j round-trips through the columns card, then
66+// cmd+up brings it forward, cmd+left selects a column and cmd+down returns.
67+func TestOverviewFocusBindsNavigate(t *testing.T) {
68+ cfg := config.Defaults()
69+ client := make(chan ports.ClientEvent, 8)
70+ input := make(chan ports.InputEvent, 8)
71+ output := make(chan ports.OutputEvent, 8)
72+ commands := make(chan ports.ClientCommand, 256)
73+ scenes := make(chan []ports.Scene, 1)
74+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ ctx, cancel := context.WithCancel(context.Background())
79+ defer cancel()
80+ go c.Run(ctx)
81+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200}}
82+ scene(t, scenes)
83+ for id := ports.WindowID(1); id <= 2; id++ {
84+ client <- ports.WindowMapped{ID: id}
85+ scene(t, scenes)
86+ }
87+ client <- ports.WindowMapped{ID: 9, Floating: true, Width: 300, Height: 200}
88+ scene(t, scenes)
89+ focused := func(id ports.WindowID) func(ports.Scene) bool {
90+ return func(s ports.Scene) bool {
91+ for _, w := range s.Windows {
92+ if w.ID == id && w.Focused && w.Preview > 0 && w.Dim == 0 {
93+ return true
94+ }
95+ }
96+ return false
97+ }
98+ }
99+ press := func(key string) {
100+ input <- ports.KeyEvent{Keysym: key, Mods: ports.ModSuper, Pressed: true}
101+ input <- ports.KeyEvent{Keysym: key, Mods: ports.ModSuper}
102+ }
103+ press("o")
104+ sceneMatch(t, scenes, focused(9))
105+ press("k")
106+ sceneMatch(t, scenes, focused(2))
107+ press("j")
108+ sceneMatch(t, scenes, focused(9))
109+ press("Up")
110+ sceneMatch(t, scenes, focused(2))
111+ press("Left")
112+ sceneMatch(t, scenes, focused(1))
113+ press("Down")
114+ sceneMatch(t, scenes, focused(9))
115+}
116+
117+// Closing through a real bind follows the preview, not the covering float.
118+func TestOverviewCloseBindTargetsPreview(t *testing.T) {
119+ cfg := config.Defaults()
120+ client := make(chan ports.ClientEvent, 8)
121+ input := make(chan ports.InputEvent, 8)
122+ output := make(chan ports.OutputEvent, 8)
123+ commands := make(chan ports.ClientCommand, 256)
124+ scenes := make(chan []ports.Scene, 1)
125+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
126+ if err != nil {
127+ t.Fatal(err)
128+ }
129+ ctx, cancel := context.WithCancel(context.Background())
130+ defer cancel()
131+ go c.Run(ctx)
132+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200}}
133+ scene(t, scenes)
134+ for _, id := range []ports.WindowID{1, 2} {
135+ client <- ports.WindowMapped{ID: id}
136+ scene(t, scenes)
137+ }
138+ client <- ports.WindowMapped{ID: 9, Floating: true, Width: 300, Height: 200}
139+ scene(t, scenes)
140+ press := func(key string, mods ports.Mods) {
141+ input <- ports.KeyEvent{Keysym: key, Mods: mods, Pressed: true}
142+ input <- ports.KeyEvent{Keysym: key, Mods: mods}
143+ }
144+ press("o", ports.ModSuper)
145+ sceneMatch(t, scenes, func(s ports.Scene) bool {
146+ for _, w := range s.Windows {
147+ if w.ID == 9 && w.Focused && w.Preview > 0 {
148+ return true
149+ }
150+ }
151+ return false
152+ })
153+ press("Up", 0)
154+ sceneMatch(t, scenes, func(s ports.Scene) bool {
155+ for _, w := range s.Windows {
156+ if w.ID == 2 && w.Focused && w.Preview > 0 {
157+ return true
158+ }
159+ }
160+ return false
161+ })
162+ press("q", ports.ModSuper)
163+ if got := next(t, commands, anyOf[ports.CloseWindow]); got.ID != 2 {
164+ t.Fatalf("close target %d, want preview 2", got.ID)
165+ }
166+}
167+
168+func TestFloatOnlyMonitorDirectionalExit(t *testing.T) {
169+ for _, dir := range []int{-1, 1} {
170+ r := startMulti(t, nil, left, right)
171+ if dir < 0 {
172+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
173+ }
174+ r.client <- ports.WindowMapped{ID: 9, Floating: true, Width: 20, Height: 20}
175+ receive(t, r.scenes)
176+ key := "Right"
177+ want := right.Name
178+ if dir < 0 {
179+ key, want = "Left", left.Name
180+ }
181+ r.key(t, key, ports.ModAlt)
182+ out := lastOutputs(t, r.commands)
183+ if out.Focused != want {
184+ t.Fatalf("direction %d: focused output %q, want %q", dir, out.Focused, want)
185+ }
186+ }
187+}
188+
64 // Two-finger scrolling moves the overview selection one column per step,189 // Two-finger scrolling moves the overview selection one column per step,
65 // along the axis the fingers move most; a small scroll does nothing and190 // along the axis the fingers move most; a small scroll does nothing and
66 // lifting the fingers starts over. Outside the overview it goes to the191 // lifting the fingers starts over. Outside the overview it goes to the
A internal/core/overview_maximized_test.go +306 -0
@@ -0,0 +1,306 @@
1+package core
2+
3+import (
4+ "math"
5+ "slices"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+func maximizedOverview() *Monitor {
12+ m := newMonitor("", "")
13+ m.SetOutput(600, 400)
14+ m.SetOverflow(OverflowFixed)
15+ m.SetMaxColumns(3)
16+ for id := WindowID(1); id <= 4; id++ {
17+ m.AddWindow(id)
18+ }
19+ m.Current().FocusID(2)
20+ m.Current().ToggleFullWidth()
21+ return m
22+}
23+
24+func TestOverviewMaximizedGeometryAndNavigation(t *testing.T) {
25+ m := maximizedOverview()
26+ w := m.Current()
27+ w.Columns[2].Windows = append(w.Columns[2].Windows, 5)
28+ rects := w.hiddenColumnRects()
29+ m.ToggleOverview()
30+ if got := w.stack(); !slices.Equal(got, []stackItem{{stackColumn, 2}, {kind: stackColumns}}) {
31+ t.Fatalf("items %v", got)
32+ }
33+ if p := previewOf(t, m.Layout(), 2); p.Hidden || p.Peek || !p.Focused {
34+ t.Fatalf("maximized front %+v", p)
35+ }
36+ ps := m.Layout()
37+ for _, id := range []WindowID{1, 3, 4, 5} {
38+ p := previewOf(t, ps, id)
39+ i := w.columnOf(id)
40+ r := rects[i]
41+ if id == 3 || id == 5 {
42+ r = stackRects(r, 2, w.gap())[map[WindowID]int{3: 0, 5: 1}[id]]
43+ }
44+ if p.Hidden || !p.Peek || p.Focused || math.Abs(float64(p.Rect.W)-float64(r.W)*p.Preview) > 1 || math.Abs(float64(p.Rect.H)-float64(r.H)*p.Preview) > 1 {
45+ t.Fatalf("hidden tile %d: %+v cell %+v", id, p, r)
46+ }
47+ for _, other := range []WindowID{1, 3, 4, 5} {
48+ if id < other && p.Rect.Overlaps(previewOf(t, ps, other).Rect) {
49+ t.Fatalf("hidden tiles %d/%d overlap", id, other)
50+ }
51+ }
52+ }
53+ // The hidden columns fill the card as if the maximized column were
54+ // gone: no hole where it was.
55+ area := 0
56+ for _, id := range []WindowID{1, 3, 4, 5} {
57+ r := previewOf(t, ps, id).Rect
58+ area += r.W * r.H
59+ }
60+ z := previewOf(t, ps, 1).Preview
61+ full := float64(w.Usable.W) * float64(w.Usable.H) * z * z
62+ if float64(area) < 0.8*full {
63+ t.Fatalf("hidden tiles cover %d of %.0f: hole left by the maximized column", area, full)
64+ }
65+ m.OverviewMove(0, -1)
66+ if m.stackFront(w).kind != stackColumns || m.hiddenColumn(w) != 0 || !previewOf(t, m.Layout(), 1).Focused || w.Focus != 1 || !w.Columns[1].FullWidth {
67+ t.Fatal("up changed real maximization or failed to select hidden card")
68+ }
69+ m.OverviewMove(1, 0)
70+ if m.hiddenColumn(w) != 2 || !previewOf(t, m.Layout(), 3).Focused || w.Focus != 1 {
71+ t.Fatal("right did not select column 3 provisionally")
72+ }
73+ m.OverviewMove(-1, 0)
74+ if m.hiddenColumn(w) != 0 {
75+ t.Fatal("left did not skip maximized slot")
76+ }
77+ m.OverviewMove(1, 0)
78+ m.ToggleOverview()
79+ if id, _ := w.Focused(); id != 3 || !w.Columns[2].FullWidth || w.Columns[1].FullWidth || !previewOf(t, w.Layout(), 1).Hidden {
80+ t.Fatalf("confirm focus %d columns %+v", id, w.Columns)
81+ }
82+ m.ToggleOverview()
83+ if m.hiddenColumn(w) != 1 || m.stackFront(w).kind != stackColumn {
84+ t.Fatal("previously maximized column not selected behind front")
85+ }
86+ m.OverviewMove(0, -1)
87+ if p := previewOf(t, m.Layout(), 2); !p.Focused || p.Peek {
88+ t.Fatalf("MRU selection %+v", p)
89+ }
90+ m.CancelOverview()
91+}
92+
93+func TestOverviewMaximizedCancelAndClick(t *testing.T) {
94+ m := maximizedOverview()
95+ w := m.Current()
96+ w.AddFloating(9, 600, 400)
97+ w.FocusID(2)
98+ before := slices.Clone(order(w))
99+ m.ToggleOverview()
100+ m.OverviewMove(0, -1)
101+ m.OverviewMove(0, -1)
102+ m.CancelOverview()
103+ if id, _ := w.Focused(); id != 2 || !w.Columns[1].FullWidth || !slices.Equal(order(w), before) {
104+ t.Fatalf("cancel focus %d order %v", id, order(w))
105+ }
106+ m.ToggleOverview()
107+ p := previewOf(t, m.Layout(), 3)
108+ if !p.Peek {
109+ t.Fatalf("peek %+v", p)
110+ }
111+ m.OverviewPick(3)
112+ if id, _ := w.Focused(); id != 3 || !w.Columns[2].FullWidth {
113+ t.Fatalf("click focus %d max %+v", id, w.Columns)
114+ }
115+}
116+
117+func TestOverviewMaximizedFloatWrapAndNeighbor(t *testing.T) {
118+ m := maximizedOverview()
119+ w := m.Current()
120+ w.AddFloating(9, 600, 400)
121+ m.Focus(1)
122+ m.AddWindow(10)
123+ m.AddWindow(11)
124+ m.Current().FocusID(10)
125+ m.Current().ToggleFullWidth()
126+ m.Focus(0)
127+ m.ToggleOverview()
128+ if got := w.stack(); !slices.Equal(got, []stackItem{{stackFloat, 9}, {stackColumn, 2}, {kind: stackColumns}}) {
129+ t.Fatalf("stack %v", got)
130+ }
131+ if p, q := previewOf(t, m.Layout(), 10), previewOf(t, m.Layout(), 11); !p.Peek || !q.Peek || p.Focused || q.Focused {
132+ t.Fatalf("neighbor %+v %+v", p, q)
133+ }
134+ m.OverviewMove(0, -1)
135+ if m.stackFront(w) != (stackItem{stackColumn, 2}) {
136+ t.Fatalf("front %v", m.stackFront(w))
137+ }
138+ m.OverviewMove(0, 1)
139+ if m.stackFront(w) != (stackItem{stackFloat, 9}) {
140+ t.Fatalf("back %v", m.stackFront(w))
141+ }
142+ m.CancelOverview()
143+}
144+
145+func TestOverviewMaximizedLeftAndStash(t *testing.T) {
146+ m := maximizedOverview()
147+ w := m.Current()
148+ w.FocusID(4)
149+ w.ToggleWindowStash()
150+ w.FocusID(2)
151+ w.ToggleFullWidth()
152+ m.ToggleOverview()
153+ m.OverviewMove(-1, 0)
154+ if m.cardAt(w) >= 0 || m.stackFront(w).kind != stackColumns {
155+ t.Fatalf("left should send card behind: %v", m.stackFront(w))
156+ }
157+ m.OverviewMove(0, 1)
158+ if m.stackFront(w) != (stackItem{stackColumn, 2}) {
159+ t.Fatalf("front %v", m.stackFront(w))
160+ }
161+ m.CancelOverview()
162+}
163+
164+// Escape restores the opening column state after a real workspace mutation.
165+func TestOverviewCancelRestoresFullWidthAfterMutation(t *testing.T) {
166+ m := maximizedOverview()
167+ w := m.Current()
168+ m.ToggleOverview()
169+ w.ToggleFullWidth()
170+ if w.Columns[1].FullWidth {
171+ t.Fatal("workspace mutation did not toggle maximization")
172+ }
173+ m.CancelOverview()
174+ if w.Focus != 1 || !w.Columns[1].FullWidth {
175+ t.Fatalf("cancel lost maximization: focus %d columns %+v", w.Focus, w.Columns)
176+ }
177+ // The snapshot follows a surviving column if another column disappears.
178+ m.ToggleOverview()
179+ w.RemoveWindow(1)
180+ w.ToggleFullWidth()
181+ m.CancelOverview()
182+ if id, _ := w.Focused(); id != 2 || !w.Columns[0].FullWidth {
183+ t.Fatalf("cancel after removal: focus %d columns %+v", id, w.Columns)
184+ }
185+}
186+
187+// Escape maximizes only the column holding the window focused at opening,
188+// where the focus returns, even when binds split its column meanwhile.
189+func TestOverviewCancelFullWidthAfterSplit(t *testing.T) {
190+ m := maximizedOverview()
191+ w := m.Current()
192+ w.FocusID(3)
193+ w.ConsumeOrExpel(-1) // column 2 holds [2 3]
194+ w.FocusID(3)
195+ w.ToggleFullWidth()
196+ m.ToggleOverview()
197+ w.FocusID(3)
198+ w.ConsumeOrExpel(1) // 3 leaves for another column
199+ w.FocusID(2)
200+ w.ToggleFullWidth() // 2's column maximized instead
201+ m.CancelOverview()
202+ maxed := 0
203+ for _, c := range w.Columns {
204+ if c.FullWidth {
205+ maxed++
206+ if !slices.Contains(c.Windows, 3) {
207+ t.Fatalf("wrong column maximized: %+v", w.Columns)
208+ }
209+ }
210+ }
211+ if id, _ := w.Focused(); maxed != 1 || id != 3 {
212+ t.Fatalf("%d maximized, focus %d: %+v", maxed, id, w.Columns)
213+ }
214+}
215+
216+// In scroll overflow, Escape leaves a maximized column the focus left.
217+func TestOverviewCancelKeepsScrollFullWidth(t *testing.T) {
218+ m := maximizedOverview()
219+ w := m.Current()
220+ w.Overflow = OverflowScroll
221+ w.FocusID(3) // column 2 stays maximized in scroll overflow
222+ if !w.Columns[1].FullWidth {
223+ t.Fatal("setup: column 2 not maximized")
224+ }
225+ m.ToggleOverview()
226+ m.CancelOverview()
227+ if !w.Columns[1].FullWidth {
228+ t.Fatalf("escape cleared column 2: %+v", w.Columns)
229+ }
230+}
231+
232+// A selected single-column card follows its anchor when earlier columns go.
233+func TestOverviewHiddenSelectionFollowsWindowAfterRemoval(t *testing.T) {
234+ m := maximizedOverview()
235+ w := m.Current()
236+ m.ToggleOverview()
237+ m.OverviewMove(0, -1)
238+ m.OverviewMove(1, 0) // select column 3 inside the group
239+ w.RemoveWindow(1)
240+ if p := previewOf(t, m.Layout(), 3); !p.Focused || p.Peek {
241+ t.Fatalf("selection moved: %+v", p)
242+ }
243+ m.ToggleOverview()
244+ if id, _ := w.Focused(); id != 3 || !w.Columns[1].FullWidth {
245+ t.Fatalf("confirm %d %+v", id, w.Columns)
246+ }
247+}
248+
249+func TestOverviewHiddenSelectionRemovedFallsBack(t *testing.T) {
250+ m := maximizedOverview()
251+ w := m.Current()
252+ m.ToggleOverview()
253+ m.OverviewMove(0, -1)
254+ m.OverviewMove(1, 0) // select column 3 inside the group
255+ w.RemoveWindow(3)
256+ if p := previewOf(t, m.Layout(), 4); !p.Focused {
257+ t.Fatalf("fallback %+v", p)
258+ }
259+ m.ToggleOverview()
260+ if id, _ := w.Focused(); id != 4 {
261+ t.Fatalf("fallback %d", id)
262+ }
263+}
264+
265+func TestOverviewMaximizedPreviewsKeepConfigures(t *testing.T) {
266+ m := maximizedOverview()
267+ w := m.Current()
268+ s := newConfigures()
269+ before := map[WindowID]ports.ConfigureWindow{}
270+ for _, p := range w.Layout() {
271+ // Hidden clients retain their last unmaximized configure.
272+ if p.Hidden {
273+ r := w.columnRectsFor(true)[int(p.ID)-1]
274+ p.Rect, p.Hidden = r, false
275+ }
276+ v, _ := s.next(p, configureTarget{output: "test", client: p.Rect, area: w.Usable})
277+ s.mark(v)
278+ before[p.ID] = v
279+ }
280+ m.ToggleOverview()
281+ m.OverviewMove(0, -1)
282+ for _, p := range m.Layout() {
283+ if p.ID < 1 || p.ID > 4 {
284+ continue
285+ }
286+ v, _ := s.next(p, configureTarget{output: "test", area: w.Usable})
287+ if v.Width != before[p.ID].Width || v.Height != before[p.ID].Height {
288+ t.Fatalf("%d resized from %+v to %+v", p.ID, before[p.ID], v)
289+ }
290+ }
291+}
292+
293+func TestOverviewScrollFullWidthUnchanged(t *testing.T) {
294+ m := maximizedOverview()
295+ w := m.Current()
296+ w.Overflow = OverflowScroll
297+ m.ToggleOverview()
298+ if got := w.stack(); !slices.Equal(got, []stackItem{{kind: stackColumns}}) {
299+ t.Fatalf("scroll items %v", got)
300+ }
301+ for _, id := range []WindowID{1, 3, 4} {
302+ if p := previewOf(t, m.Layout(), id); p.Hidden || p.Peek {
303+ t.Fatalf("scroll column %d %+v", id, p)
304+ }
305+ }
306+}
@@ -0,0 +1,306 @@
1+package core
2+
3+import (
4+ "math"
5+ "slices"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+func maximizedOverview() *Monitor {
12+ m := newMonitor("", "")
13+ m.SetOutput(600, 400)
14+ m.SetOverflow(OverflowFixed)
15+ m.SetMaxColumns(3)
16+ for id := WindowID(1); id <= 4; id++ {
17+ m.AddWindow(id)
18+ }
19+ m.Current().FocusID(2)
20+ m.Current().ToggleFullWidth()
21+ return m
22+}
23+
24+func TestOverviewMaximizedGeometryAndNavigation(t *testing.T) {
25+ m := maximizedOverview()
26+ w := m.Current()
27+ w.Columns[2].Windows = append(w.Columns[2].Windows, 5)
28+ rects := w.hiddenColumnRects()
29+ m.ToggleOverview()
30+ if got := w.stack(); !slices.Equal(got, []stackItem{{stackColumn, 2}, {kind: stackColumns}}) {
31+ t.Fatalf("items %v", got)
32+ }
33+ if p := previewOf(t, m.Layout(), 2); p.Hidden || p.Peek || !p.Focused {
34+ t.Fatalf("maximized front %+v", p)
35+ }
36+ ps := m.Layout()
37+ for _, id := range []WindowID{1, 3, 4, 5} {
38+ p := previewOf(t, ps, id)
39+ i := w.columnOf(id)
40+ r := rects[i]
41+ if id == 3 || id == 5 {
42+ r = stackRects(r, 2, w.gap())[map[WindowID]int{3: 0, 5: 1}[id]]
43+ }
44+ if p.Hidden || !p.Peek || p.Focused || math.Abs(float64(p.Rect.W)-float64(r.W)*p.Preview) > 1 || math.Abs(float64(p.Rect.H)-float64(r.H)*p.Preview) > 1 {
45+ t.Fatalf("hidden tile %d: %+v cell %+v", id, p, r)
46+ }
47+ for _, other := range []WindowID{1, 3, 4, 5} {
48+ if id < other && p.Rect.Overlaps(previewOf(t, ps, other).Rect) {
49+ t.Fatalf("hidden tiles %d/%d overlap", id, other)
50+ }
51+ }
52+ }
53+ // The hidden columns fill the card as if the maximized column were
54+ // gone: no hole where it was.
55+ area := 0
56+ for _, id := range []WindowID{1, 3, 4, 5} {
57+ r := previewOf(t, ps, id).Rect
58+ area += r.W * r.H
59+ }
60+ z := previewOf(t, ps, 1).Preview
61+ full := float64(w.Usable.W) * float64(w.Usable.H) * z * z
62+ if float64(area) < 0.8*full {
63+ t.Fatalf("hidden tiles cover %d of %.0f: hole left by the maximized column", area, full)
64+ }
65+ m.OverviewMove(0, -1)
66+ if m.stackFront(w).kind != stackColumns || m.hiddenColumn(w) != 0 || !previewOf(t, m.Layout(), 1).Focused || w.Focus != 1 || !w.Columns[1].FullWidth {
67+ t.Fatal("up changed real maximization or failed to select hidden card")
68+ }
69+ m.OverviewMove(1, 0)
70+ if m.hiddenColumn(w) != 2 || !previewOf(t, m.Layout(), 3).Focused || w.Focus != 1 {
71+ t.Fatal("right did not select column 3 provisionally")
72+ }
73+ m.OverviewMove(-1, 0)
74+ if m.hiddenColumn(w) != 0 {
75+ t.Fatal("left did not skip maximized slot")
76+ }
77+ m.OverviewMove(1, 0)
78+ m.ToggleOverview()
79+ if id, _ := w.Focused(); id != 3 || !w.Columns[2].FullWidth || w.Columns[1].FullWidth || !previewOf(t, w.Layout(), 1).Hidden {
80+ t.Fatalf("confirm focus %d columns %+v", id, w.Columns)
81+ }
82+ m.ToggleOverview()
83+ if m.hiddenColumn(w) != 1 || m.stackFront(w).kind != stackColumn {
84+ t.Fatal("previously maximized column not selected behind front")
85+ }
86+ m.OverviewMove(0, -1)
87+ if p := previewOf(t, m.Layout(), 2); !p.Focused || p.Peek {
88+ t.Fatalf("MRU selection %+v", p)
89+ }
90+ m.CancelOverview()
91+}
92+
93+func TestOverviewMaximizedCancelAndClick(t *testing.T) {
94+ m := maximizedOverview()
95+ w := m.Current()
96+ w.AddFloating(9, 600, 400)
97+ w.FocusID(2)
98+ before := slices.Clone(order(w))
99+ m.ToggleOverview()
100+ m.OverviewMove(0, -1)
101+ m.OverviewMove(0, -1)
102+ m.CancelOverview()
103+ if id, _ := w.Focused(); id != 2 || !w.Columns[1].FullWidth || !slices.Equal(order(w), before) {
104+ t.Fatalf("cancel focus %d order %v", id, order(w))
105+ }
106+ m.ToggleOverview()
107+ p := previewOf(t, m.Layout(), 3)
108+ if !p.Peek {
109+ t.Fatalf("peek %+v", p)
110+ }
111+ m.OverviewPick(3)
112+ if id, _ := w.Focused(); id != 3 || !w.Columns[2].FullWidth {
113+ t.Fatalf("click focus %d max %+v", id, w.Columns)
114+ }
115+}
116+
117+func TestOverviewMaximizedFloatWrapAndNeighbor(t *testing.T) {
118+ m := maximizedOverview()
119+ w := m.Current()
120+ w.AddFloating(9, 600, 400)
121+ m.Focus(1)
122+ m.AddWindow(10)
123+ m.AddWindow(11)
124+ m.Current().FocusID(10)
125+ m.Current().ToggleFullWidth()
126+ m.Focus(0)
127+ m.ToggleOverview()
128+ if got := w.stack(); !slices.Equal(got, []stackItem{{stackFloat, 9}, {stackColumn, 2}, {kind: stackColumns}}) {
129+ t.Fatalf("stack %v", got)
130+ }
131+ if p, q := previewOf(t, m.Layout(), 10), previewOf(t, m.Layout(), 11); !p.Peek || !q.Peek || p.Focused || q.Focused {
132+ t.Fatalf("neighbor %+v %+v", p, q)
133+ }
134+ m.OverviewMove(0, -1)
135+ if m.stackFront(w) != (stackItem{stackColumn, 2}) {
136+ t.Fatalf("front %v", m.stackFront(w))
137+ }
138+ m.OverviewMove(0, 1)
139+ if m.stackFront(w) != (stackItem{stackFloat, 9}) {
140+ t.Fatalf("back %v", m.stackFront(w))
141+ }
142+ m.CancelOverview()
143+}
144+
145+func TestOverviewMaximizedLeftAndStash(t *testing.T) {
146+ m := maximizedOverview()
147+ w := m.Current()
148+ w.FocusID(4)
149+ w.ToggleWindowStash()
150+ w.FocusID(2)
151+ w.ToggleFullWidth()
152+ m.ToggleOverview()
153+ m.OverviewMove(-1, 0)
154+ if m.cardAt(w) >= 0 || m.stackFront(w).kind != stackColumns {
155+ t.Fatalf("left should send card behind: %v", m.stackFront(w))
156+ }
157+ m.OverviewMove(0, 1)
158+ if m.stackFront(w) != (stackItem{stackColumn, 2}) {
159+ t.Fatalf("front %v", m.stackFront(w))
160+ }
161+ m.CancelOverview()
162+}
163+
164+// Escape restores the opening column state after a real workspace mutation.
165+func TestOverviewCancelRestoresFullWidthAfterMutation(t *testing.T) {
166+ m := maximizedOverview()
167+ w := m.Current()
168+ m.ToggleOverview()
169+ w.ToggleFullWidth()
170+ if w.Columns[1].FullWidth {
171+ t.Fatal("workspace mutation did not toggle maximization")
172+ }
173+ m.CancelOverview()
174+ if w.Focus != 1 || !w.Columns[1].FullWidth {
175+ t.Fatalf("cancel lost maximization: focus %d columns %+v", w.Focus, w.Columns)
176+ }
177+ // The snapshot follows a surviving column if another column disappears.
178+ m.ToggleOverview()
179+ w.RemoveWindow(1)
180+ w.ToggleFullWidth()
181+ m.CancelOverview()
182+ if id, _ := w.Focused(); id != 2 || !w.Columns[0].FullWidth {
183+ t.Fatalf("cancel after removal: focus %d columns %+v", id, w.Columns)
184+ }
185+}
186+
187+// Escape maximizes only the column holding the window focused at opening,
188+// where the focus returns, even when binds split its column meanwhile.
189+func TestOverviewCancelFullWidthAfterSplit(t *testing.T) {
190+ m := maximizedOverview()
191+ w := m.Current()
192+ w.FocusID(3)
193+ w.ConsumeOrExpel(-1) // column 2 holds [2 3]
194+ w.FocusID(3)
195+ w.ToggleFullWidth()
196+ m.ToggleOverview()
197+ w.FocusID(3)
198+ w.ConsumeOrExpel(1) // 3 leaves for another column
199+ w.FocusID(2)
200+ w.ToggleFullWidth() // 2's column maximized instead
201+ m.CancelOverview()
202+ maxed := 0
203+ for _, c := range w.Columns {
204+ if c.FullWidth {
205+ maxed++
206+ if !slices.Contains(c.Windows, 3) {
207+ t.Fatalf("wrong column maximized: %+v", w.Columns)
208+ }
209+ }
210+ }
211+ if id, _ := w.Focused(); maxed != 1 || id != 3 {
212+ t.Fatalf("%d maximized, focus %d: %+v", maxed, id, w.Columns)
213+ }
214+}
215+
216+// In scroll overflow, Escape leaves a maximized column the focus left.
217+func TestOverviewCancelKeepsScrollFullWidth(t *testing.T) {
218+ m := maximizedOverview()
219+ w := m.Current()
220+ w.Overflow = OverflowScroll
221+ w.FocusID(3) // column 2 stays maximized in scroll overflow
222+ if !w.Columns[1].FullWidth {
223+ t.Fatal("setup: column 2 not maximized")
224+ }
225+ m.ToggleOverview()
226+ m.CancelOverview()
227+ if !w.Columns[1].FullWidth {
228+ t.Fatalf("escape cleared column 2: %+v", w.Columns)
229+ }
230+}
231+
232+// A selected single-column card follows its anchor when earlier columns go.
233+func TestOverviewHiddenSelectionFollowsWindowAfterRemoval(t *testing.T) {
234+ m := maximizedOverview()
235+ w := m.Current()
236+ m.ToggleOverview()
237+ m.OverviewMove(0, -1)
238+ m.OverviewMove(1, 0) // select column 3 inside the group
239+ w.RemoveWindow(1)
240+ if p := previewOf(t, m.Layout(), 3); !p.Focused || p.Peek {
241+ t.Fatalf("selection moved: %+v", p)
242+ }
243+ m.ToggleOverview()
244+ if id, _ := w.Focused(); id != 3 || !w.Columns[1].FullWidth {
245+ t.Fatalf("confirm %d %+v", id, w.Columns)
246+ }
247+}
248+
249+func TestOverviewHiddenSelectionRemovedFallsBack(t *testing.T) {
250+ m := maximizedOverview()
251+ w := m.Current()
252+ m.ToggleOverview()
253+ m.OverviewMove(0, -1)
254+ m.OverviewMove(1, 0) // select column 3 inside the group
255+ w.RemoveWindow(3)
256+ if p := previewOf(t, m.Layout(), 4); !p.Focused {
257+ t.Fatalf("fallback %+v", p)
258+ }
259+ m.ToggleOverview()
260+ if id, _ := w.Focused(); id != 4 {
261+ t.Fatalf("fallback %d", id)
262+ }
263+}
264+
265+func TestOverviewMaximizedPreviewsKeepConfigures(t *testing.T) {
266+ m := maximizedOverview()
267+ w := m.Current()
268+ s := newConfigures()
269+ before := map[WindowID]ports.ConfigureWindow{}
270+ for _, p := range w.Layout() {
271+ // Hidden clients retain their last unmaximized configure.
272+ if p.Hidden {
273+ r := w.columnRectsFor(true)[int(p.ID)-1]
274+ p.Rect, p.Hidden = r, false
275+ }
276+ v, _ := s.next(p, configureTarget{output: "test", client: p.Rect, area: w.Usable})
277+ s.mark(v)
278+ before[p.ID] = v
279+ }
280+ m.ToggleOverview()
281+ m.OverviewMove(0, -1)
282+ for _, p := range m.Layout() {
283+ if p.ID < 1 || p.ID > 4 {
284+ continue
285+ }
286+ v, _ := s.next(p, configureTarget{output: "test", area: w.Usable})
287+ if v.Width != before[p.ID].Width || v.Height != before[p.ID].Height {
288+ t.Fatalf("%d resized from %+v to %+v", p.ID, before[p.ID], v)
289+ }
290+ }
291+}
292+
293+func TestOverviewScrollFullWidthUnchanged(t *testing.T) {
294+ m := maximizedOverview()
295+ w := m.Current()
296+ w.Overflow = OverflowScroll
297+ m.ToggleOverview()
298+ if got := w.stack(); !slices.Equal(got, []stackItem{{kind: stackColumns}}) {
299+ t.Fatalf("scroll items %v", got)
300+ }
301+ for _, id := range []WindowID{1, 3, 4} {
302+ if p := previewOf(t, m.Layout(), id); p.Hidden || p.Peek {
303+ t.Fatalf("scroll column %d %+v", id, p)
304+ }
305+ }
306+}
A internal/core/overview_regression_test.go +152 -0
@@ -0,0 +1,152 @@
1+package core
2+
3+import "testing"
4+
5+func TestOverviewStashVerticalSelectsFront(t *testing.T) {
6+ for _, accept := range []bool{false, true} {
7+ m := stackMonitor()
8+ w := m.Current()
9+ w.FocusID(1)
10+ w.ToggleWindowStash()
11+ w.FocusID(9)
12+ m.ToggleOverview()
13+ m.OverviewMove(-1, 0) // float to columns
14+ m.OverviewMove(-1, 0) // first column to stash
15+ if m.cardAt(w) < 0 {
16+ t.Fatal("not in stash")
17+ }
18+ m.OverviewMove(0, 1)
19+ p := previewOf(t, m.Layout(), 9)
20+ if !p.Focused || p.Peek || m.cardAt(w) >= 0 {
21+ t.Fatalf("vertical selection: %+v, stash %d", p, m.cardAt(w))
22+ }
23+ if e := m.Apply(ActionCloseWindow); e.Close != 9 {
24+ t.Fatalf("close target %d, want 9", e.Close)
25+ }
26+ if accept {
27+ m.ToggleOverview()
28+ if id, _ := w.Focused(); id != 9 || !w.floatFocus {
29+ t.Fatalf("accepted focus %d, stash hidden %v", id, w.stashHidden)
30+ }
31+ }
32+ }
33+}
34+
35+func TestOverviewSingleCoveringFloat(t *testing.T) {
36+ for _, stash := range []bool{false, true} {
37+ for _, neighbor := range []bool{false, true} {
38+ for _, click := range []bool{false, true} {
39+ m := monitor()
40+ m.SetOutput(300, 200)
41+ w := m.Current()
42+ if stash {
43+ w.AddWindow(1)
44+ w.ToggleWindowStash()
45+ w.ToggleStashVisible()
46+ }
47+ m.AddFloating(9, 300, 200)
48+ if neighbor {
49+ m.Focus(1)
50+ m.AddFloating(8, 300, 200)
51+ m.Focus(0)
52+ }
53+ m.ToggleOverview()
54+ p := previewOf(t, m.Layout(), 9)
55+ if p.Hidden || p.Preview <= 0 || p.Rect.W <= 0 || p.Rect.H <= 0 || !p.Focused {
56+ t.Fatalf("single float preview %+v", p)
57+ }
58+ if neighbor {
59+ q := previewOf(t, m.Layout(), 8)
60+ if q.Hidden || q.Preview <= 0 || q.Focused || !q.Peek {
61+ t.Fatalf("neighbor preview %+v", q)
62+ }
63+ }
64+ if click {
65+ id := m.overviewAt(float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2))
66+ if id != 9 {
67+ t.Fatalf("hit %d, want 9", id)
68+ }
69+ m.OverviewPick(id)
70+ } else {
71+ m.ToggleOverview()
72+ }
73+ if id, _ := w.Focused(); id != 9 || m.ov.open {
74+ t.Fatalf("accepted focus %d, overview %v", id, m.ov.open)
75+ }
76+ }
77+ }
78+ }
79+}
80+
81+func TestOverviewCloseTargetsFront(t *testing.T) {
82+ m := stackMonitor()
83+ m.ToggleOverview()
84+ m.OverviewMove(0, -1)
85+ if e := m.Apply(ActionCloseWindow); e.Close != 3 {
86+ t.Fatalf("close target %d, want selected column 3", e.Close)
87+ }
88+}
89+
90+func TestOverviewTargetAfterUnmaximizeMutation(t *testing.T) {
91+ for _, add := range []bool{false, true} {
92+ m := maximizedOverview()
93+ w := m.Current()
94+ m.ToggleOverview()
95+ m.OverviewMove(0, -1)
96+ m.OverviewMove(1, 0)
97+ if m.ov.selected != 3 {
98+ t.Fatalf("setup selection %d, want 3", m.ov.selected)
99+ }
100+ if add {
101+ w.AddWindow(7)
102+ } else {
103+ w.RemoveWindow(2)
104+ }
105+ var highlighted WindowID
106+ for _, p := range m.Layout() {
107+ if p.Focused && !p.Hidden {
108+ if highlighted != 0 {
109+ t.Fatal("multiple highlighted previews")
110+ }
111+ highlighted = p.ID
112+ }
113+ }
114+ if highlighted == 0 || m.overviewTarget() != highlighted || m.Apply(ActionCloseWindow).Close != highlighted {
115+ t.Fatalf("add %v: highlighted %d, target %d, close %d", add, highlighted, m.overviewTarget(), m.Apply(ActionCloseWindow).Close)
116+ }
117+ m.ToggleOverview()
118+ if id, _ := w.Focused(); id != highlighted {
119+ t.Fatalf("accepted %d, want highlighted %d", id, highlighted)
120+ }
121+ }
122+}
123+
124+func TestOverviewWindowMutationsDisabled(t *testing.T) {
125+ actions := []Action{
126+ ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth,
127+ ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash,
128+ ActionToggleStashVisible, ActionMoveColumnToWorkspaceUp,
129+ ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp,
130+ ActionMoveWindowToWorkspaceDown, ActionConsumeOrExpelLeft,
131+ ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight,
132+ "move-column-to-workspace 2", "move-window-to-workspace 2",
133+ }
134+ for _, a := range actions {
135+ t.Run(string(a), func(t *testing.T) {
136+ m := stackMonitor()
137+ m.ToggleOverview()
138+ m.OverviewMove(0, -1)
139+ before := m.Layout()
140+ m.Apply(a)
141+ after := m.Layout()
142+ if len(before) != len(after) {
143+ t.Fatalf("layout length changed: %d -> %d", len(before), len(after))
144+ }
145+ for i := range before {
146+ if before[i] != after[i] {
147+ t.Fatalf("layout changed: %+v -> %+v", before[i], after[i])
148+ }
149+ }
150+ })
151+ }
152+}
@@ -0,0 +1,152 @@
1+package core
2+
3+import "testing"
4+
5+func TestOverviewStashVerticalSelectsFront(t *testing.T) {
6+ for _, accept := range []bool{false, true} {
7+ m := stackMonitor()
8+ w := m.Current()
9+ w.FocusID(1)
10+ w.ToggleWindowStash()
11+ w.FocusID(9)
12+ m.ToggleOverview()
13+ m.OverviewMove(-1, 0) // float to columns
14+ m.OverviewMove(-1, 0) // first column to stash
15+ if m.cardAt(w) < 0 {
16+ t.Fatal("not in stash")
17+ }
18+ m.OverviewMove(0, 1)
19+ p := previewOf(t, m.Layout(), 9)
20+ if !p.Focused || p.Peek || m.cardAt(w) >= 0 {
21+ t.Fatalf("vertical selection: %+v, stash %d", p, m.cardAt(w))
22+ }
23+ if e := m.Apply(ActionCloseWindow); e.Close != 9 {
24+ t.Fatalf("close target %d, want 9", e.Close)
25+ }
26+ if accept {
27+ m.ToggleOverview()
28+ if id, _ := w.Focused(); id != 9 || !w.floatFocus {
29+ t.Fatalf("accepted focus %d, stash hidden %v", id, w.stashHidden)
30+ }
31+ }
32+ }
33+}
34+
35+func TestOverviewSingleCoveringFloat(t *testing.T) {
36+ for _, stash := range []bool{false, true} {
37+ for _, neighbor := range []bool{false, true} {
38+ for _, click := range []bool{false, true} {
39+ m := monitor()
40+ m.SetOutput(300, 200)
41+ w := m.Current()
42+ if stash {
43+ w.AddWindow(1)
44+ w.ToggleWindowStash()
45+ w.ToggleStashVisible()
46+ }
47+ m.AddFloating(9, 300, 200)
48+ if neighbor {
49+ m.Focus(1)
50+ m.AddFloating(8, 300, 200)
51+ m.Focus(0)
52+ }
53+ m.ToggleOverview()
54+ p := previewOf(t, m.Layout(), 9)
55+ if p.Hidden || p.Preview <= 0 || p.Rect.W <= 0 || p.Rect.H <= 0 || !p.Focused {
56+ t.Fatalf("single float preview %+v", p)
57+ }
58+ if neighbor {
59+ q := previewOf(t, m.Layout(), 8)
60+ if q.Hidden || q.Preview <= 0 || q.Focused || !q.Peek {
61+ t.Fatalf("neighbor preview %+v", q)
62+ }
63+ }
64+ if click {
65+ id := m.overviewAt(float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2))
66+ if id != 9 {
67+ t.Fatalf("hit %d, want 9", id)
68+ }
69+ m.OverviewPick(id)
70+ } else {
71+ m.ToggleOverview()
72+ }
73+ if id, _ := w.Focused(); id != 9 || m.ov.open {
74+ t.Fatalf("accepted focus %d, overview %v", id, m.ov.open)
75+ }
76+ }
77+ }
78+ }
79+}
80+
81+func TestOverviewCloseTargetsFront(t *testing.T) {
82+ m := stackMonitor()
83+ m.ToggleOverview()
84+ m.OverviewMove(0, -1)
85+ if e := m.Apply(ActionCloseWindow); e.Close != 3 {
86+ t.Fatalf("close target %d, want selected column 3", e.Close)
87+ }
88+}
89+
90+func TestOverviewTargetAfterUnmaximizeMutation(t *testing.T) {
91+ for _, add := range []bool{false, true} {
92+ m := maximizedOverview()
93+ w := m.Current()
94+ m.ToggleOverview()
95+ m.OverviewMove(0, -1)
96+ m.OverviewMove(1, 0)
97+ if m.ov.selected != 3 {
98+ t.Fatalf("setup selection %d, want 3", m.ov.selected)
99+ }
100+ if add {
101+ w.AddWindow(7)
102+ } else {
103+ w.RemoveWindow(2)
104+ }
105+ var highlighted WindowID
106+ for _, p := range m.Layout() {
107+ if p.Focused && !p.Hidden {
108+ if highlighted != 0 {
109+ t.Fatal("multiple highlighted previews")
110+ }
111+ highlighted = p.ID
112+ }
113+ }
114+ if highlighted == 0 || m.overviewTarget() != highlighted || m.Apply(ActionCloseWindow).Close != highlighted {
115+ t.Fatalf("add %v: highlighted %d, target %d, close %d", add, highlighted, m.overviewTarget(), m.Apply(ActionCloseWindow).Close)
116+ }
117+ m.ToggleOverview()
118+ if id, _ := w.Focused(); id != highlighted {
119+ t.Fatalf("accepted %d, want highlighted %d", id, highlighted)
120+ }
121+ }
122+}
123+
124+func TestOverviewWindowMutationsDisabled(t *testing.T) {
125+ actions := []Action{
126+ ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth,
127+ ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash,
128+ ActionToggleStashVisible, ActionMoveColumnToWorkspaceUp,
129+ ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp,
130+ ActionMoveWindowToWorkspaceDown, ActionConsumeOrExpelLeft,
131+ ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight,
132+ "move-column-to-workspace 2", "move-window-to-workspace 2",
133+ }
134+ for _, a := range actions {
135+ t.Run(string(a), func(t *testing.T) {
136+ m := stackMonitor()
137+ m.ToggleOverview()
138+ m.OverviewMove(0, -1)
139+ before := m.Layout()
140+ m.Apply(a)
141+ after := m.Layout()
142+ if len(before) != len(after) {
143+ t.Fatalf("layout length changed: %d -> %d", len(before), len(after))
144+ }
145+ for i := range before {
146+ if before[i] != after[i] {
147+ t.Fatalf("layout changed: %+v -> %+v", before[i], after[i])
148+ }
149+ }
150+ })
151+ }
152+}
A internal/core/overview_stack.go +300 -0
@@ -0,0 +1,300 @@
1+package core
2+
3+import "slices"
4+
5+type stackKind uint8
6+
7+const (
8+ stackFloat stackKind = iota
9+ stackColumns
10+ stackColumn
11+)
12+
13+// A stackItem identifies a covering float, a column group (all columns, or
14+// all except the maximized column), or the maximized column by one of its
15+// windows. Only stackColumns has a zero ID.
16+type stackItem struct {
17+ kind stackKind
18+ id WindowID
19+}
20+
21+func (w *Workspace) overviewMaximized() bool {
22+ return w.Overflow == OverflowFixed && len(w.Columns) > 1 && w.Focus >= 0 && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth && !w.pinned()
23+}
24+
25+func (w *Workspace) columnOf(id WindowID) int {
26+ for i, c := range w.Columns {
27+ if slices.Contains(c.Windows, id) {
28+ return i
29+ }
30+ }
31+ return -1
32+}
33+
34+// stack lists what is on screen followed by what it hides, top to bottom.
35+func (w *Workspace) stack() []stackItem {
36+ if w.pinned() {
37+ return nil
38+ }
39+ var items []stackItem
40+ for i := len(w.Floats) - 1; i >= 0; i-- {
41+ f := w.Floats[i]
42+ if !f.below && w.coversFloat(f) {
43+ items = append(items, stackItem{stackFloat, f.ID})
44+ }
45+ }
46+ if w.overviewMaximized() {
47+ items = append(items, stackItem{stackColumn, w.Columns[w.Focus].Windows[0]})
48+ }
49+ if len(w.Columns) > 0 {
50+ items = append(items, stackItem{kind: stackColumns})
51+ }
52+ for i := len(w.Floats) - 1; i >= 0; i-- {
53+ f := w.Floats[i]
54+ if f.below && w.coversFloat(f) {
55+ items = append(items, stackItem{stackFloat, f.ID})
56+ }
57+ }
58+ return items
59+}
60+
61+// itemOf resolves a clicked tile to the card currently containing it.
62+func (w *Workspace) itemOf(id WindowID) (stackItem, bool) {
63+ for _, item := range w.stack() {
64+ switch item.kind {
65+ case stackFloat:
66+ if item.id == id {
67+ return item, true
68+ }
69+ case stackColumn:
70+ if i := w.columnOf(id); i >= 0 && i == w.columnOf(item.id) {
71+ return item, true
72+ }
73+ case stackColumns:
74+ if i := w.columnOf(id); i >= 0 && (!w.overviewMaximized() || i != w.Focus) {
75+ return item, true
76+ }
77+ }
78+ }
79+ return stackItem{}, false
80+}
81+
82+// stackFront resolves the provisional item against fresh workspace contents;
83+// an invalidated selection falls back to the nearest surviving stack index.
84+func (m *Monitor) stackFront(w *Workspace) stackItem {
85+ items := w.stack()
86+ if len(items) == 0 {
87+ return stackItem{}
88+ }
89+ if m.ov.row == w {
90+ for _, item := range items {
91+ if item == m.ov.front || item.kind == stackColumn && m.ov.front.kind == stackColumn && w.columnOf(item.id) >= 0 && w.columnOf(item.id) == w.columnOf(m.ov.front.id) {
92+ return item
93+ }
94+ }
95+ return items[min(max(m.ov.frontAt, 0), len(items)-1)]
96+ }
97+ return items[0]
98+}
99+
100+// setFront records a provisional card and its index for removal fallback.
101+func (m *Monitor) setFront(w *Workspace, item stackItem) {
102+ m.ov.row, m.ov.front = w, item
103+ m.ov.frontAt = slices.Index(w.stack(), item)
104+}
105+
106+// moveStack moves only within the current row. The caller changes workspace
107+// when the linear stack has no item in that direction.
108+func (m *Monitor) moveStack(d int) bool {
109+ w := m.Current()
110+ items := w.stack()
111+ if len(items) < 2 {
112+ return false
113+ }
114+ at := slices.Index(items, m.stackFront(w)) + d
115+ if at < 0 || at >= len(items) {
116+ return false
117+ }
118+ m.setFront(w, items[at])
119+ m.ov.cardOf, m.ov.card = nil, 0
120+ return true
121+}
122+
123+// hiddenColumn resolves the provisional selection by window, not column
124+// index. With no surviving selection, prefer the most recently maximized
125+// hidden column, then the nearest prior non-maximized focus, then first.
126+func (m *Monitor) hiddenColumn(w *Workspace) int {
127+ if !w.overviewMaximized() {
128+ return -1
129+ }
130+ if i := w.columnOf(m.ov.selected); i >= 0 && i != w.Focus && m.ov.row == w {
131+ return i
132+ }
133+ if m.ov.row == w && m.ov.selectedAt >= 0 {
134+ i := min(m.ov.selectedAt, len(w.Columns)-1)
135+ if i == w.Focus {
136+ if i > 0 {
137+ i--
138+ } else {
139+ i++
140+ }
141+ }
142+ return i
143+ }
144+ for _, id := range w.maximized {
145+ if i := w.columnOf(id); i >= 0 && i != w.Focus {
146+ return i
147+ }
148+ }
149+ if i := w.columnOf(m.ov.fromID); w == m.ov.from && i >= 0 && i != w.Focus {
150+ return i
151+ }
152+ for i := range w.Columns {
153+ if i != w.Focus {
154+ return i
155+ }
156+ }
157+ return -1
158+}
159+
160+// selectOverviewColumn retains the existing ordinary-row live focus semantics.
161+func (w *Workspace) selectOverviewColumn(i int) {
162+ w.floatFocus, w.stashFocus = false, false
163+ if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) {
164+ w.unmaximize()
165+ }
166+ w.Focus = i
167+ w.scroll()
168+}
169+
170+// apply commits the selected front card without changing other stack items.
171+func (w *Workspace) apply(item stackItem, selected WindowID) {
172+ switch item.kind {
173+ case stackFloat:
174+ w.FocusID(item.id)
175+ case stackColumns:
176+ i := w.columnOf(selected)
177+ if w.overviewMaximized() && i == w.Focus {
178+ i = -1
179+ }
180+ if i < 0 && len(w.Columns) > 0 {
181+ i = w.Focus
182+ if w.overviewMaximized() {
183+ for next := range w.Columns {
184+ if next != w.Focus {
185+ i = next
186+ break
187+ }
188+ }
189+ }
190+ c := w.Columns[i]
191+ selected = c.Windows[c.Focus]
192+ }
193+ if selected != 0 {
194+ if w.overviewMaximized() && i != w.Focus {
195+ w.transferMaximization(i)
196+ }
197+ w.FocusID(selected)
198+ }
199+ case stackColumn:
200+ i := w.columnOf(item.id)
201+ if i < 0 {
202+ return
203+ }
204+ if w.columnOf(selected) != i {
205+ c := w.Columns[i]
206+ selected = c.Windows[c.Focus]
207+ }
208+ if w.overviewMaximized() && i != w.Focus {
209+ w.transferMaximization(i)
210+ }
211+ w.FocusID(selected)
212+ }
213+}
214+
215+// previewItem draws a group using screen geometry and a single card at its
216+// configured size. A prior maximized column retains its full-width buffer.
217+func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool) []Placement {
218+ if item.kind == stackColumns {
219+ if !w.overviewMaximized() {
220+ return w.previewRow(y, dim, lit)
221+ }
222+ // The hidden columns fill the screen as if the maximized column,
223+ // shown on its own card, were not there. In particular, an earlier
224+ // maximized buffer scales into its unmaximized cell here.
225+ g := w.gap()
226+ rects := w.hiddenColumnRects()
227+ var tiles []Placement
228+ selected := m.hiddenColumn(w)
229+ for i, c := range w.Columns {
230+ if i == w.Focus {
231+ continue
232+ }
233+ r := rects[i]
234+ r.X -= w.Usable.X
235+ r.Y -= w.Usable.Y
236+ for j, t := range stackRects(r, len(c.Windows), g) {
237+ tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == selected && j == c.Focus})
238+ }
239+ }
240+ sel := rects[selected]
241+ sel.X -= w.Usable.X
242+ return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, sel)
243+ }
244+ g := w.gap()
245+ if item.kind == stackFloat {
246+ f := w.Floats[w.floatIndex(item.id)]
247+ r := w.floatRect(f)
248+ r.X -= w.Usable.X
249+ r.Y -= w.Usable.Y
250+ return w.previewRowTiles(y, dim, lit, []Placement{{ID: item.id, Rect: r, Floating: true, Focused: true}}, w.Usable.W, r)
251+ }
252+ i := w.columnOf(item.id)
253+ if i < 0 {
254+ return nil
255+ }
256+ c := w.Columns[i]
257+ r := Rect{W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)}
258+ r.X = (w.Usable.W - r.W) / 2
259+ r.Y = (w.Usable.H - r.H) / 2
260+ tiles := make([]Placement, 0, len(c.Windows))
261+ for j, t := range stackRects(r, len(c.Windows), g) {
262+ tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus})
263+ }
264+ return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, r)
265+}
266+
267+// hiddenColumnRects lays out the columns behind the maximized one with the
268+// fixed layout rules, without the maximized column, so no hole is left
269+// where it was. The maximized column's own entry is an empty Rect.
270+func (w *Workspace) hiddenColumnRects() []Rect {
271+ v := *w
272+ v.Columns = slices.Delete(slices.Clone(w.Columns), w.Focus, w.Focus+1)
273+ v.Focus = 0
274+ return slices.Insert(v.columnRectsFor(true), w.Focus, Rect{})
275+}
276+
277+// stackRow draws up to two cards on each side of the provisional front.
278+func (m *Monitor) stackRow(w *Workspace, y int, dim, lit bool) []Placement {
279+ items := w.stack()
280+ if len(items) == 0 {
281+ return w.previewRow(y, dim, lit)
282+ }
283+ if len(items) == 1 {
284+ return m.previewItem(w, items[0], y, dim, lit)
285+ }
286+ at := 0
287+ if w == m.Current() {
288+ at = slices.Index(items, m.stackFront(w))
289+ }
290+ cards := make([][]Placement, len(items))
291+ step := w.peekStep()
292+ for i, item := range items {
293+ cards[i] = m.previewItem(w, item, w.frontRowY(y), dim, lit)
294+ }
295+
296+ // fan's behind side advances through the linear stack.
297+ return fan(cards, at, 2, 2, false,
298+ func(d int) (int, int) { return 0, -d * step },
299+ func(d int) (int, int) { return 0, d * step }, dim, lit)
300+}
@@ -0,0 +1,300 @@
1+package core
2+
3+import "slices"
4+
5+type stackKind uint8
6+
7+const (
8+ stackFloat stackKind = iota
9+ stackColumns
10+ stackColumn
11+)
12+
13+// A stackItem identifies a covering float, a column group (all columns, or
14+// all except the maximized column), or the maximized column by one of its
15+// windows. Only stackColumns has a zero ID.
16+type stackItem struct {
17+ kind stackKind
18+ id WindowID
19+}
20+
21+func (w *Workspace) overviewMaximized() bool {
22+ return w.Overflow == OverflowFixed && len(w.Columns) > 1 && w.Focus >= 0 && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth && !w.pinned()
23+}
24+
25+func (w *Workspace) columnOf(id WindowID) int {
26+ for i, c := range w.Columns {
27+ if slices.Contains(c.Windows, id) {
28+ return i
29+ }
30+ }
31+ return -1
32+}
33+
34+// stack lists what is on screen followed by what it hides, top to bottom.
35+func (w *Workspace) stack() []stackItem {
36+ if w.pinned() {
37+ return nil
38+ }
39+ var items []stackItem
40+ for i := len(w.Floats) - 1; i >= 0; i-- {
41+ f := w.Floats[i]
42+ if !f.below && w.coversFloat(f) {
43+ items = append(items, stackItem{stackFloat, f.ID})
44+ }
45+ }
46+ if w.overviewMaximized() {
47+ items = append(items, stackItem{stackColumn, w.Columns[w.Focus].Windows[0]})
48+ }
49+ if len(w.Columns) > 0 {
50+ items = append(items, stackItem{kind: stackColumns})
51+ }
52+ for i := len(w.Floats) - 1; i >= 0; i-- {
53+ f := w.Floats[i]
54+ if f.below && w.coversFloat(f) {
55+ items = append(items, stackItem{stackFloat, f.ID})
56+ }
57+ }
58+ return items
59+}
60+
61+// itemOf resolves a clicked tile to the card currently containing it.
62+func (w *Workspace) itemOf(id WindowID) (stackItem, bool) {
63+ for _, item := range w.stack() {
64+ switch item.kind {
65+ case stackFloat:
66+ if item.id == id {
67+ return item, true
68+ }
69+ case stackColumn:
70+ if i := w.columnOf(id); i >= 0 && i == w.columnOf(item.id) {
71+ return item, true
72+ }
73+ case stackColumns:
74+ if i := w.columnOf(id); i >= 0 && (!w.overviewMaximized() || i != w.Focus) {
75+ return item, true
76+ }
77+ }
78+ }
79+ return stackItem{}, false
80+}
81+
82+// stackFront resolves the provisional item against fresh workspace contents;
83+// an invalidated selection falls back to the nearest surviving stack index.
84+func (m *Monitor) stackFront(w *Workspace) stackItem {
85+ items := w.stack()
86+ if len(items) == 0 {
87+ return stackItem{}
88+ }
89+ if m.ov.row == w {
90+ for _, item := range items {
91+ if item == m.ov.front || item.kind == stackColumn && m.ov.front.kind == stackColumn && w.columnOf(item.id) >= 0 && w.columnOf(item.id) == w.columnOf(m.ov.front.id) {
92+ return item
93+ }
94+ }
95+ return items[min(max(m.ov.frontAt, 0), len(items)-1)]
96+ }
97+ return items[0]
98+}
99+
100+// setFront records a provisional card and its index for removal fallback.
101+func (m *Monitor) setFront(w *Workspace, item stackItem) {
102+ m.ov.row, m.ov.front = w, item
103+ m.ov.frontAt = slices.Index(w.stack(), item)
104+}
105+
106+// moveStack moves only within the current row. The caller changes workspace
107+// when the linear stack has no item in that direction.
108+func (m *Monitor) moveStack(d int) bool {
109+ w := m.Current()
110+ items := w.stack()
111+ if len(items) < 2 {
112+ return false
113+ }
114+ at := slices.Index(items, m.stackFront(w)) + d
115+ if at < 0 || at >= len(items) {
116+ return false
117+ }
118+ m.setFront(w, items[at])
119+ m.ov.cardOf, m.ov.card = nil, 0
120+ return true
121+}
122+
123+// hiddenColumn resolves the provisional selection by window, not column
124+// index. With no surviving selection, prefer the most recently maximized
125+// hidden column, then the nearest prior non-maximized focus, then first.
126+func (m *Monitor) hiddenColumn(w *Workspace) int {
127+ if !w.overviewMaximized() {
128+ return -1
129+ }
130+ if i := w.columnOf(m.ov.selected); i >= 0 && i != w.Focus && m.ov.row == w {
131+ return i
132+ }
133+ if m.ov.row == w && m.ov.selectedAt >= 0 {
134+ i := min(m.ov.selectedAt, len(w.Columns)-1)
135+ if i == w.Focus {
136+ if i > 0 {
137+ i--
138+ } else {
139+ i++
140+ }
141+ }
142+ return i
143+ }
144+ for _, id := range w.maximized {
145+ if i := w.columnOf(id); i >= 0 && i != w.Focus {
146+ return i
147+ }
148+ }
149+ if i := w.columnOf(m.ov.fromID); w == m.ov.from && i >= 0 && i != w.Focus {
150+ return i
151+ }
152+ for i := range w.Columns {
153+ if i != w.Focus {
154+ return i
155+ }
156+ }
157+ return -1
158+}
159+
160+// selectOverviewColumn retains the existing ordinary-row live focus semantics.
161+func (w *Workspace) selectOverviewColumn(i int) {
162+ w.floatFocus, w.stashFocus = false, false
163+ if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) {
164+ w.unmaximize()
165+ }
166+ w.Focus = i
167+ w.scroll()
168+}
169+
170+// apply commits the selected front card without changing other stack items.
171+func (w *Workspace) apply(item stackItem, selected WindowID) {
172+ switch item.kind {
173+ case stackFloat:
174+ w.FocusID(item.id)
175+ case stackColumns:
176+ i := w.columnOf(selected)
177+ if w.overviewMaximized() && i == w.Focus {
178+ i = -1
179+ }
180+ if i < 0 && len(w.Columns) > 0 {
181+ i = w.Focus
182+ if w.overviewMaximized() {
183+ for next := range w.Columns {
184+ if next != w.Focus {
185+ i = next
186+ break
187+ }
188+ }
189+ }
190+ c := w.Columns[i]
191+ selected = c.Windows[c.Focus]
192+ }
193+ if selected != 0 {
194+ if w.overviewMaximized() && i != w.Focus {
195+ w.transferMaximization(i)
196+ }
197+ w.FocusID(selected)
198+ }
199+ case stackColumn:
200+ i := w.columnOf(item.id)
201+ if i < 0 {
202+ return
203+ }
204+ if w.columnOf(selected) != i {
205+ c := w.Columns[i]
206+ selected = c.Windows[c.Focus]
207+ }
208+ if w.overviewMaximized() && i != w.Focus {
209+ w.transferMaximization(i)
210+ }
211+ w.FocusID(selected)
212+ }
213+}
214+
215+// previewItem draws a group using screen geometry and a single card at its
216+// configured size. A prior maximized column retains its full-width buffer.
217+func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool) []Placement {
218+ if item.kind == stackColumns {
219+ if !w.overviewMaximized() {
220+ return w.previewRow(y, dim, lit)
221+ }
222+ // The hidden columns fill the screen as if the maximized column,
223+ // shown on its own card, were not there. In particular, an earlier
224+ // maximized buffer scales into its unmaximized cell here.
225+ g := w.gap()
226+ rects := w.hiddenColumnRects()
227+ var tiles []Placement
228+ selected := m.hiddenColumn(w)
229+ for i, c := range w.Columns {
230+ if i == w.Focus {
231+ continue
232+ }
233+ r := rects[i]
234+ r.X -= w.Usable.X
235+ r.Y -= w.Usable.Y
236+ for j, t := range stackRects(r, len(c.Windows), g) {
237+ tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == selected && j == c.Focus})
238+ }
239+ }
240+ sel := rects[selected]
241+ sel.X -= w.Usable.X
242+ return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, sel)
243+ }
244+ g := w.gap()
245+ if item.kind == stackFloat {
246+ f := w.Floats[w.floatIndex(item.id)]
247+ r := w.floatRect(f)
248+ r.X -= w.Usable.X
249+ r.Y -= w.Usable.Y
250+ return w.previewRowTiles(y, dim, lit, []Placement{{ID: item.id, Rect: r, Floating: true, Focused: true}}, w.Usable.W, r)
251+ }
252+ i := w.columnOf(item.id)
253+ if i < 0 {
254+ return nil
255+ }
256+ c := w.Columns[i]
257+ r := Rect{W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)}
258+ r.X = (w.Usable.W - r.W) / 2
259+ r.Y = (w.Usable.H - r.H) / 2
260+ tiles := make([]Placement, 0, len(c.Windows))
261+ for j, t := range stackRects(r, len(c.Windows), g) {
262+ tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus})
263+ }
264+ return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, r)
265+}
266+
267+// hiddenColumnRects lays out the columns behind the maximized one with the
268+// fixed layout rules, without the maximized column, so no hole is left
269+// where it was. The maximized column's own entry is an empty Rect.
270+func (w *Workspace) hiddenColumnRects() []Rect {
271+ v := *w
272+ v.Columns = slices.Delete(slices.Clone(w.Columns), w.Focus, w.Focus+1)
273+ v.Focus = 0
274+ return slices.Insert(v.columnRectsFor(true), w.Focus, Rect{})
275+}
276+
277+// stackRow draws up to two cards on each side of the provisional front.
278+func (m *Monitor) stackRow(w *Workspace, y int, dim, lit bool) []Placement {
279+ items := w.stack()
280+ if len(items) == 0 {
281+ return w.previewRow(y, dim, lit)
282+ }
283+ if len(items) == 1 {
284+ return m.previewItem(w, items[0], y, dim, lit)
285+ }
286+ at := 0
287+ if w == m.Current() {
288+ at = slices.Index(items, m.stackFront(w))
289+ }
290+ cards := make([][]Placement, len(items))
291+ step := w.peekStep()
292+ for i, item := range items {
293+ cards[i] = m.previewItem(w, item, w.frontRowY(y), dim, lit)
294+ }
295+
296+ // fan's behind side advances through the linear stack.
297+ return fan(cards, at, 2, 2, false,
298+ func(d int) (int, int) { return 0, -d * step },
299+ func(d int) (int, int) { return 0, d * step }, dim, lit)
300+}
A internal/core/overview_stack_test.go +279 -0
@@ -0,0 +1,279 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+func stackMonitor() *Monitor {
9+ m := overviewMonitor()
10+ m.SetBorder(2)
11+ m.AddFloating(9, 300, 200)
12+ return m
13+}
14+
15+func TestOverviewCoveringStackNavigation(t *testing.T) {
16+ for _, columns := range []bool{false, true} {
17+ m := stackMonitor()
18+ w := m.Current()
19+ if columns {
20+ w.FocusID(3)
21+ }
22+ before := slices.Clone(order(w))
23+ m.ToggleOverview()
24+ ps := m.Layout()
25+ f, c := previewOf(t, ps, 9), previewOf(t, ps, 3)
26+ if f.Peek != columns || c.Peek == columns {
27+ t.Fatalf("front float %+v columns %+v", f, c)
28+ }
29+ if !slices.Equal(order(w), before) {
30+ t.Fatal("opening changed float order")
31+ }
32+ if columns {
33+ m.OverviewMove(0, -1)
34+ } else {
35+ m.OverviewMove(-1, 0)
36+ }
37+ if columns {
38+ if p := previewOf(t, m.Layout(), 9); p.Peek || !p.Focused {
39+ t.Fatalf("float %+v", p)
40+ }
41+ } else {
42+ if p := previewOf(t, m.Layout(), 3); p.Peek || !p.Focused {
43+ t.Fatalf("column %+v", p)
44+ }
45+ }
46+ m.ToggleOverview()
47+ if columns {
48+ wantOrder(t, w, 1, 2, 3, 9)
49+ } else {
50+ wantOrder(t, w, 9, 1, 2, 3)
51+ }
52+ }
53+}
54+
55+func TestOverviewStackLeftWithoutStashRotatesBackward(t *testing.T) {
56+ m := stackMonitor()
57+ w := m.Current()
58+ m.ToggleOverview()
59+ m.OverviewMove(-1, 0)
60+ if p := previewOf(t, m.Layout(), 3); p.Peek || !p.Focused {
61+ t.Fatalf("left did not rotate to columns: %+v", p)
62+ }
63+ m.CancelOverview()
64+ if id, _ := w.Focused(); id != 9 {
65+ t.Fatalf("cancel focus %d", id)
66+ }
67+}
68+
69+func TestOverviewStackEscapePreservesFocusAndOrder(t *testing.T) {
70+ for _, columns := range []bool{false, true} {
71+ m := stackMonitor()
72+ w := m.Current()
73+ if columns {
74+ w.FocusID(3)
75+ }
76+ before := slices.Clone(order(w))
77+ focused, _ := w.Focused()
78+ m.ToggleOverview()
79+ m.OverviewMove(1, 0)
80+ m.CancelOverview()
81+ if id, _ := w.Focused(); id != focused || !slices.Equal(order(w), before) {
82+ t.Fatalf("columns %v: focus %d want %d, order %v want %v", columns, id, focused, order(w), before)
83+ }
84+ }
85+ // When the focused float is not last (a dialog sits over it), Escape
86+ // must restore its position too; FocusID alone would raise it.
87+ m := stackMonitor()
88+ w := m.Current()
89+ w.AddFloating(10, 20, 20)
90+ w.FocusID(9)
91+ before := slices.Clone(order(w))
92+ m.ToggleOverview()
93+ m.OverviewMove(1, 0)
94+ m.CancelOverview()
95+ if id, _ := w.Focused(); id != 9 || !slices.Equal(order(w), before) {
96+ t.Fatalf("float focus %d order %v want %v", id, order(w), before)
97+ }
98+}
99+
100+func TestOverviewStackClickAndHitPriority(t *testing.T) {
101+ for _, frontColumn := range []bool{false, true} {
102+ m := stackMonitor()
103+ w := m.Current()
104+ if frontColumn {
105+ w.FocusID(3)
106+ }
107+ m.ToggleOverview()
108+ ps := m.Layout()
109+ front, back := previewOf(t, ps, 9), previewOf(t, ps, 3)
110+ if frontColumn {
111+ front, back = back, front
112+ }
113+ // The overlap with the peeking column tile must hit the front.
114+ x, y := back.Rect.X+back.Rect.W/2, max(front.Rect.Y, back.Rect.Y)+back.Rect.H/2
115+ if frontColumn {
116+ x = front.Rect.X + front.Rect.W/2
117+ }
118+ if got := m.overviewAt(float64(x), float64(y)); got != front.ID {
119+ t.Fatalf("overlap hit %d want %d (front %+v back %+v)", got, front.ID, front.Rect, back.Rect)
120+ }
121+ // The up/right exposed corner belongs to the peeking card.
122+ x, y = back.Rect.X+back.Rect.W-1, back.Rect.Y+1
123+ if got := m.overviewAt(float64(x), float64(y)); got != back.ID {
124+ t.Fatalf("peek hit %d want %d", got, back.ID)
125+ }
126+ m.OverviewPick(back.ID)
127+ if id, _ := w.Focused(); id != back.ID || m.ov.open {
128+ t.Fatalf("picked %d, focus %d", back.ID, id)
129+ }
130+ }
131+}
132+
133+func TestOverviewStackStashDialogNeighbor(t *testing.T) {
134+ m := stackMonitor()
135+ w := m.Current()
136+ w.AddFloating(10, 20, 20)
137+ w.FocusID(9)
138+ w.AddWindow(11)
139+ w.FocusID(11)
140+ w.ToggleWindowStash()
141+ w.FocusID(9)
142+ m.ToggleOverview()
143+ if p := previewOf(t, m.Layout(), 10); !p.Hidden {
144+ t.Fatalf("dialog %+v", p)
145+ }
146+ m.OverviewMove(-1, 0) // single-window float sends behind before stash
147+ if m.stackFront(w).kind != stackColumns || m.cardAt(w) >= 0 {
148+ t.Fatal("float did not send behind")
149+ }
150+ m.OverviewMove(-1, 0)
151+ m.OverviewMove(-1, 0)
152+ m.OverviewMove(-1, 0)
153+ if m.cardAt(w) < 0 {
154+ t.Fatal("left from first column did not enter stash")
155+ }
156+ m.OverviewMove(1, 0)
157+ if m.cardAt(w) >= 0 {
158+ t.Fatal("stash exit did not return to card")
159+ }
160+ m.CancelOverview()
161+ m = stackMonitor()
162+ m.Focus(1)
163+ m.AddFloating(12, 300, 200)
164+ m.Focus(0)
165+ m.ToggleOverview()
166+ if p, q := previewOf(t, m.Layout(), 12), previewOf(t, m.Layout(), 4); p.Hidden || q.Hidden || !p.Peek || !q.Peek || p.Focused || q.Focused {
167+ t.Fatalf("neighbor %+v %+v", p, q)
168+ }
169+}
170+
171+func TestOverviewMultipleCoveringCards(t *testing.T) {
172+ m := stackMonitor()
173+ w := m.Current()
174+ w.AddFloating(8, 300, 200)
175+ w.AddFloating(7, 300, 200)
176+ w.AddFloating(6, 300, 200)
177+ m.ToggleOverview()
178+ if p := previewOf(t, m.Layout(), 6); p.Peek {
179+ t.Fatalf("front %+v", p)
180+ }
181+ if p := previewOf(t, m.Layout(), 7); !p.Peek {
182+ t.Fatalf("peek %+v", p)
183+ }
184+ if p := previewOf(t, m.Layout(), 9); !p.Hidden {
185+ t.Fatalf("far card %+v", p)
186+ }
187+ m.OverviewMove(0, -1)
188+ if p := previewOf(t, m.Layout(), 7); p.Peek || !p.Focused {
189+ t.Fatalf("up %+v", p)
190+ }
191+ if p := previewOf(t, m.Layout(), 6); !p.Peek || p.Rect.Y <= previewOf(t, m.Layout(), 7).Rect.Y {
192+ t.Fatalf("passed card %+v", p)
193+ }
194+ m.OverviewMove(0, 1)
195+ if p := previewOf(t, m.Layout(), 6); p.Peek {
196+ t.Fatal("down did not return to screen front")
197+ }
198+ m.CancelOverview()
199+}
200+
201+func TestOverviewFloatWithoutColumns(t *testing.T) {
202+ m := monitor()
203+ m.SetOutput(300, 200)
204+ m.SetBorder(2)
205+ w := m.Current()
206+ w.AddFloating(9, 300, 200)
207+ w.AddFloating(8, 300, 200)
208+ m.ToggleOverview()
209+ if p := previewOf(t, m.Layout(), 8); p.Peek || p.Hidden {
210+ t.Fatalf("front float %+v", p)
211+ }
212+ m.OverviewMove(-1, 0)
213+ if p := previewOf(t, m.Layout(), 9); p.Peek || p.Hidden {
214+ t.Fatalf("back float %+v", p)
215+ }
216+ m.ToggleOverview()
217+ if id, _ := w.Focused(); id != 9 {
218+ t.Fatalf("focus %d", id)
219+ }
220+}
221+
222+func TestOverviewStackRowChangeResetsRotation(t *testing.T) {
223+ m := stackMonitor()
224+ m.ToggleOverview()
225+ m.OverviewMove(0, -1)
226+ if m.stackFront(m.Current()).kind != stackColumns {
227+ t.Fatal("stack did not move to columns")
228+ }
229+ m.OverviewMove(0, -1) // at stack end: workspace above, if present
230+ m.OverviewMove(0, 1)
231+ if m.stackFront(m.Current()) != (stackItem{stackFloat, 9}) {
232+ t.Fatal("row change retained cursor")
233+ }
234+ m.CancelOverview()
235+}
236+
237+func TestOverviewStackInvalidatedOnRemoval(t *testing.T) {
238+ m := stackMonitor()
239+ w := m.Current()
240+ m.ToggleOverview()
241+ m.OverviewMove(1, 0)
242+ w.RemoveWindow(9)
243+ m.ToggleOverview()
244+ if id, _ := w.Focused(); id != 3 {
245+ t.Fatalf("removed selection focused %d", id)
246+ }
247+}
248+
249+// Stash navigation returns to the current front card without changing its cursor.
250+func TestOverviewStashRightBringsColumnsFront(t *testing.T) {
251+ m := stackMonitor()
252+ w := m.Current()
253+ w.FocusID(1)
254+ w.ToggleWindowStash()
255+ w.FocusID(9)
256+ m.ToggleOverview()
257+ m.OverviewMove(-1, 0) // float to columns
258+ m.OverviewMove(-1, 0) // first column to stash
259+ if m.cardAt(w) < 0 {
260+ t.Fatal("not in stash")
261+ }
262+ m.OverviewMove(1, 0)
263+ if m.cardAt(w) >= 0 || m.stackFront(w).kind != stackColumns {
264+ t.Fatal("not returned to front column card")
265+ }
266+ m.ToggleOverview()
267+ if id, _ := w.Focused(); id != 2 {
268+ t.Fatalf("focus %d", id)
269+ }
270+}
271+
272+// Stack previews are not floats: the overview draws no float veil.
273+func TestOverviewStackNoFloatVeil(t *testing.T) {
274+ m := stackMonitor()
275+ m.ToggleOverview()
276+ if d := floatDim(m.Layout(), Rect{W: 300, H: 200}, 0.3); d != 0 {
277+ t.Fatalf("veil %v in the overview", d)
278+ }
279+}
@@ -0,0 +1,279 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+func stackMonitor() *Monitor {
9+ m := overviewMonitor()
10+ m.SetBorder(2)
11+ m.AddFloating(9, 300, 200)
12+ return m
13+}
14+
15+func TestOverviewCoveringStackNavigation(t *testing.T) {
16+ for _, columns := range []bool{false, true} {
17+ m := stackMonitor()
18+ w := m.Current()
19+ if columns {
20+ w.FocusID(3)
21+ }
22+ before := slices.Clone(order(w))
23+ m.ToggleOverview()
24+ ps := m.Layout()
25+ f, c := previewOf(t, ps, 9), previewOf(t, ps, 3)
26+ if f.Peek != columns || c.Peek == columns {
27+ t.Fatalf("front float %+v columns %+v", f, c)
28+ }
29+ if !slices.Equal(order(w), before) {
30+ t.Fatal("opening changed float order")
31+ }
32+ if columns {
33+ m.OverviewMove(0, -1)
34+ } else {
35+ m.OverviewMove(-1, 0)
36+ }
37+ if columns {
38+ if p := previewOf(t, m.Layout(), 9); p.Peek || !p.Focused {
39+ t.Fatalf("float %+v", p)
40+ }
41+ } else {
42+ if p := previewOf(t, m.Layout(), 3); p.Peek || !p.Focused {
43+ t.Fatalf("column %+v", p)
44+ }
45+ }
46+ m.ToggleOverview()
47+ if columns {
48+ wantOrder(t, w, 1, 2, 3, 9)
49+ } else {
50+ wantOrder(t, w, 9, 1, 2, 3)
51+ }
52+ }
53+}
54+
55+func TestOverviewStackLeftWithoutStashRotatesBackward(t *testing.T) {
56+ m := stackMonitor()
57+ w := m.Current()
58+ m.ToggleOverview()
59+ m.OverviewMove(-1, 0)
60+ if p := previewOf(t, m.Layout(), 3); p.Peek || !p.Focused {
61+ t.Fatalf("left did not rotate to columns: %+v", p)
62+ }
63+ m.CancelOverview()
64+ if id, _ := w.Focused(); id != 9 {
65+ t.Fatalf("cancel focus %d", id)
66+ }
67+}
68+
69+func TestOverviewStackEscapePreservesFocusAndOrder(t *testing.T) {
70+ for _, columns := range []bool{false, true} {
71+ m := stackMonitor()
72+ w := m.Current()
73+ if columns {
74+ w.FocusID(3)
75+ }
76+ before := slices.Clone(order(w))
77+ focused, _ := w.Focused()
78+ m.ToggleOverview()
79+ m.OverviewMove(1, 0)
80+ m.CancelOverview()
81+ if id, _ := w.Focused(); id != focused || !slices.Equal(order(w), before) {
82+ t.Fatalf("columns %v: focus %d want %d, order %v want %v", columns, id, focused, order(w), before)
83+ }
84+ }
85+ // When the focused float is not last (a dialog sits over it), Escape
86+ // must restore its position too; FocusID alone would raise it.
87+ m := stackMonitor()
88+ w := m.Current()
89+ w.AddFloating(10, 20, 20)
90+ w.FocusID(9)
91+ before := slices.Clone(order(w))
92+ m.ToggleOverview()
93+ m.OverviewMove(1, 0)
94+ m.CancelOverview()
95+ if id, _ := w.Focused(); id != 9 || !slices.Equal(order(w), before) {
96+ t.Fatalf("float focus %d order %v want %v", id, order(w), before)
97+ }
98+}
99+
100+func TestOverviewStackClickAndHitPriority(t *testing.T) {
101+ for _, frontColumn := range []bool{false, true} {
102+ m := stackMonitor()
103+ w := m.Current()
104+ if frontColumn {
105+ w.FocusID(3)
106+ }
107+ m.ToggleOverview()
108+ ps := m.Layout()
109+ front, back := previewOf(t, ps, 9), previewOf(t, ps, 3)
110+ if frontColumn {
111+ front, back = back, front
112+ }
113+ // The overlap with the peeking column tile must hit the front.
114+ x, y := back.Rect.X+back.Rect.W/2, max(front.Rect.Y, back.Rect.Y)+back.Rect.H/2
115+ if frontColumn {
116+ x = front.Rect.X + front.Rect.W/2
117+ }
118+ if got := m.overviewAt(float64(x), float64(y)); got != front.ID {
119+ t.Fatalf("overlap hit %d want %d (front %+v back %+v)", got, front.ID, front.Rect, back.Rect)
120+ }
121+ // The up/right exposed corner belongs to the peeking card.
122+ x, y = back.Rect.X+back.Rect.W-1, back.Rect.Y+1
123+ if got := m.overviewAt(float64(x), float64(y)); got != back.ID {
124+ t.Fatalf("peek hit %d want %d", got, back.ID)
125+ }
126+ m.OverviewPick(back.ID)
127+ if id, _ := w.Focused(); id != back.ID || m.ov.open {
128+ t.Fatalf("picked %d, focus %d", back.ID, id)
129+ }
130+ }
131+}
132+
133+func TestOverviewStackStashDialogNeighbor(t *testing.T) {
134+ m := stackMonitor()
135+ w := m.Current()
136+ w.AddFloating(10, 20, 20)
137+ w.FocusID(9)
138+ w.AddWindow(11)
139+ w.FocusID(11)
140+ w.ToggleWindowStash()
141+ w.FocusID(9)
142+ m.ToggleOverview()
143+ if p := previewOf(t, m.Layout(), 10); !p.Hidden {
144+ t.Fatalf("dialog %+v", p)
145+ }
146+ m.OverviewMove(-1, 0) // single-window float sends behind before stash
147+ if m.stackFront(w).kind != stackColumns || m.cardAt(w) >= 0 {
148+ t.Fatal("float did not send behind")
149+ }
150+ m.OverviewMove(-1, 0)
151+ m.OverviewMove(-1, 0)
152+ m.OverviewMove(-1, 0)
153+ if m.cardAt(w) < 0 {
154+ t.Fatal("left from first column did not enter stash")
155+ }
156+ m.OverviewMove(1, 0)
157+ if m.cardAt(w) >= 0 {
158+ t.Fatal("stash exit did not return to card")
159+ }
160+ m.CancelOverview()
161+ m = stackMonitor()
162+ m.Focus(1)
163+ m.AddFloating(12, 300, 200)
164+ m.Focus(0)
165+ m.ToggleOverview()
166+ if p, q := previewOf(t, m.Layout(), 12), previewOf(t, m.Layout(), 4); p.Hidden || q.Hidden || !p.Peek || !q.Peek || p.Focused || q.Focused {
167+ t.Fatalf("neighbor %+v %+v", p, q)
168+ }
169+}
170+
171+func TestOverviewMultipleCoveringCards(t *testing.T) {
172+ m := stackMonitor()
173+ w := m.Current()
174+ w.AddFloating(8, 300, 200)
175+ w.AddFloating(7, 300, 200)
176+ w.AddFloating(6, 300, 200)
177+ m.ToggleOverview()
178+ if p := previewOf(t, m.Layout(), 6); p.Peek {
179+ t.Fatalf("front %+v", p)
180+ }
181+ if p := previewOf(t, m.Layout(), 7); !p.Peek {
182+ t.Fatalf("peek %+v", p)
183+ }
184+ if p := previewOf(t, m.Layout(), 9); !p.Hidden {
185+ t.Fatalf("far card %+v", p)
186+ }
187+ m.OverviewMove(0, -1)
188+ if p := previewOf(t, m.Layout(), 7); p.Peek || !p.Focused {
189+ t.Fatalf("up %+v", p)
190+ }
191+ if p := previewOf(t, m.Layout(), 6); !p.Peek || p.Rect.Y <= previewOf(t, m.Layout(), 7).Rect.Y {
192+ t.Fatalf("passed card %+v", p)
193+ }
194+ m.OverviewMove(0, 1)
195+ if p := previewOf(t, m.Layout(), 6); p.Peek {
196+ t.Fatal("down did not return to screen front")
197+ }
198+ m.CancelOverview()
199+}
200+
201+func TestOverviewFloatWithoutColumns(t *testing.T) {
202+ m := monitor()
203+ m.SetOutput(300, 200)
204+ m.SetBorder(2)
205+ w := m.Current()
206+ w.AddFloating(9, 300, 200)
207+ w.AddFloating(8, 300, 200)
208+ m.ToggleOverview()
209+ if p := previewOf(t, m.Layout(), 8); p.Peek || p.Hidden {
210+ t.Fatalf("front float %+v", p)
211+ }
212+ m.OverviewMove(-1, 0)
213+ if p := previewOf(t, m.Layout(), 9); p.Peek || p.Hidden {
214+ t.Fatalf("back float %+v", p)
215+ }
216+ m.ToggleOverview()
217+ if id, _ := w.Focused(); id != 9 {
218+ t.Fatalf("focus %d", id)
219+ }
220+}
221+
222+func TestOverviewStackRowChangeResetsRotation(t *testing.T) {
223+ m := stackMonitor()
224+ m.ToggleOverview()
225+ m.OverviewMove(0, -1)
226+ if m.stackFront(m.Current()).kind != stackColumns {
227+ t.Fatal("stack did not move to columns")
228+ }
229+ m.OverviewMove(0, -1) // at stack end: workspace above, if present
230+ m.OverviewMove(0, 1)
231+ if m.stackFront(m.Current()) != (stackItem{stackFloat, 9}) {
232+ t.Fatal("row change retained cursor")
233+ }
234+ m.CancelOverview()
235+}
236+
237+func TestOverviewStackInvalidatedOnRemoval(t *testing.T) {
238+ m := stackMonitor()
239+ w := m.Current()
240+ m.ToggleOverview()
241+ m.OverviewMove(1, 0)
242+ w.RemoveWindow(9)
243+ m.ToggleOverview()
244+ if id, _ := w.Focused(); id != 3 {
245+ t.Fatalf("removed selection focused %d", id)
246+ }
247+}
248+
249+// Stash navigation returns to the current front card without changing its cursor.
250+func TestOverviewStashRightBringsColumnsFront(t *testing.T) {
251+ m := stackMonitor()
252+ w := m.Current()
253+ w.FocusID(1)
254+ w.ToggleWindowStash()
255+ w.FocusID(9)
256+ m.ToggleOverview()
257+ m.OverviewMove(-1, 0) // float to columns
258+ m.OverviewMove(-1, 0) // first column to stash
259+ if m.cardAt(w) < 0 {
260+ t.Fatal("not in stash")
261+ }
262+ m.OverviewMove(1, 0)
263+ if m.cardAt(w) >= 0 || m.stackFront(w).kind != stackColumns {
264+ t.Fatal("not returned to front column card")
265+ }
266+ m.ToggleOverview()
267+ if id, _ := w.Focused(); id != 2 {
268+ t.Fatalf("focus %d", id)
269+ }
270+}
271+
272+// Stack previews are not floats: the overview draws no float veil.
273+func TestOverviewStackNoFloatVeil(t *testing.T) {
274+ m := stackMonitor()
275+ m.ToggleOverview()
276+ if d := floatDim(m.Layout(), Rect{W: 300, H: 200}, 0.3); d != 0 {
277+ t.Fatalf("veil %v in the overview", d)
278+ }
279+}
M internal/core/overview_test.go +110 -20
@@ -100,13 +100,13 @@ func TestOverviewNavigation(t *testing.T) {
100100 t.Fatalf("entered empty workspace %d", m.Active)
101101 }
102102 m.CancelOverview()
103- if id, _ := m.Focused(); id != 3 || m.Active != 0 || m.overview {
103+ if id, _ := m.Focused(); id != 3 || m.Active != 0 || m.ov.open {
104104 t.Fatalf("cancel: %d on %d", id, m.Active)
105105 }
106106 m.ToggleOverview()
107107 m.OverviewMove(0, 1)
108108 m.ToggleOverview()
109- if id, _ := m.Focused(); id != 4 || m.Active != 1 || m.overview {
109+ if id, _ := m.Focused(); id != 4 || m.Active != 1 || m.ov.open {
110110 t.Fatalf("confirm: %d on %d", id, m.Active)
111111 }
112112 }
@@ -121,7 +121,7 @@ func TestOverviewPick(t *testing.T) {
121121 t.Fatalf("hit %d", id)
122122 }
123123 m.OverviewPick(id)
124- if f, _ := m.Focused(); f != 4 || m.overview {
124+ if f, _ := m.Focused(); f != 4 || m.ov.open {
125125 t.Fatalf("pick: %d", f)
126126 }
127127 }
@@ -170,7 +170,7 @@ func TestOverviewFullscreenFloat(t *testing.T) {
170170 }
171171 m.OverviewMove(-1, 0) // nothing else on its row
172172 m.ToggleOverview()
173- if f, _ := m.Focused(); f != 2 || m.overview || m.Active != 1 || !previewOf(t, m.Layout(), 2).Fullscreen {
173+ if f, _ := m.Focused(); f != 2 || m.ov.open || m.Active != 1 || !previewOf(t, m.Layout(), 2).Fullscreen {
174174 t.Fatalf("confirm: %d on %d", f, m.Active)
175175 }
176176 })
@@ -193,7 +193,7 @@ func TestOverviewFullscreenFloatPickAndCancel(t *testing.T) {
193193 }
194194 m.ToggleOverview()
195195 m.OverviewPick(2)
196- if f, _ := m.Focused(); f != 2 || m.overview {
196+ if f, _ := m.Focused(); f != 2 || m.ov.open {
197197 t.Fatalf("pick: %d", f)
198198 }
199199 }
@@ -204,7 +204,7 @@ func TestOverviewOpensOverFullscreenFloat(t *testing.T) {
204204 m := gameMonitor(OverflowFixed, false)
205205 m.Focus(1)
206206 m.ToggleOverview()
207- if !m.overview {
207+ if !m.ov.open {
208208 t.Fatal("overview did not open")
209209 }
210210 ps := m.Layout()
@@ -213,7 +213,7 @@ func TestOverviewOpensOverFullscreenFloat(t *testing.T) {
213213 t.Fatalf("game %+v, above %+v", game, above)
214214 }
215215 m.CancelOverview()
216- if f, _ := m.Focused(); f != 2 || m.Active != 1 || m.overview || !previewOf(t, m.Layout(), 2).Fullscreen {
216+ if f, _ := m.Focused(); f != 2 || m.Active != 1 || m.ov.open || !previewOf(t, m.Layout(), 2).Fullscreen {
217217 t.Fatalf("cancel: %d on %d", f, m.Active)
218218 }
219219 }
@@ -230,7 +230,7 @@ func TestOverviewOpensOverFullscreenTile(t *testing.T) {
230230 m.ToggleFullscreen() // 2 to its own workspace
231231 m.ToggleOverview()
232232 game := previewOf(t, m.Layout(), 2)
233- if !m.overview || !game.Focused || game.Peek || !game.Fullscreen {
233+ if !m.ov.open || !game.Focused || game.Peek || !game.Fullscreen {
234234 t.Fatalf("game %+v", game)
235235 }
236236 m.OverviewMove(-1, 0)
@@ -330,6 +330,30 @@ func TestOverviewPileLayout(t *testing.T) {
330330 }
331331 }
332332
333+// A stash pile does not shift a row that fits beside it: the row stays
334+// centred like the same workspace without a stash.
335+func TestOverviewPileKeepsRowCentred(t *testing.T) {
336+ m := newMonitor("", "")
337+ m.SetOutput(3000, 1200)
338+ m.SetOverflow(OverflowFixed)
339+ m.SetMaxColumns(3)
340+ for id := WindowID(1); id <= 3; id++ {
341+ m.AddWindow(id)
342+ }
343+ m.ToggleOverview()
344+ want := previewOf(t, m.Layout(), 1).Rect
345+ m.CancelOverview()
346+ w := m.Current()
347+ w.AddWindow(4)
348+ w.FocusID(4)
349+ w.ToggleWindowStash()
350+ w.FocusID(1)
351+ m.ToggleOverview()
352+ if got := previewOf(t, m.Layout(), 1).Rect; got != want {
353+ t.Fatalf("row with a stash at %+v, without at %+v", got, want)
354+ }
355+}
356+
333357 // h from column 1 enters the pile on the stash's selection, h/l browse
334358 // it, l past its end returns to column 1; Return shows the stash on the
335359 // chosen window.
@@ -355,7 +379,7 @@ func TestOverviewPileNavigation(t *testing.T) {
355379 }
356380 m.OverviewMove(1, 0)
357381 m.ToggleOverview()
358- if f, _ := m.Focused(); f != 6 || w.stashHidden || m.overview {
382+ if f, _ := m.Focused(); f != 6 || w.stashHidden || m.ov.open {
359383 t.Fatalf("confirm on %d, hidden %v", f, w.stashHidden)
360384 }
361385 // Back in on the stash's selection (6), then l past the last entry
@@ -420,24 +444,90 @@ func TestOverviewPileOnly(t *testing.T) {
420444 }
421445 }
422446
423-// Fixed overflow's spiral is laid out as a row: no preview overlaps.
424-func TestOverviewFixedOverflowRow(t *testing.T) {
447+// Fixed overflow previews use the actual buffer geometry, including the
448+// spiral and window stacks, rather than laying full-height columns in a row.
449+func TestOverviewFixedOverflowGeometry(t *testing.T) {
450+ for _, expanded := range []bool{false, true} {
451+ t.Run(map[bool]string{false: "spiral", true: "expanded"}[expanded], func(t *testing.T) {
452+ m := newMonitor("", "")
453+ m.SetOutput(600, 400)
454+ m.SetOverflow(OverflowFixed)
455+ m.SetMaxColumns(3)
456+ for id := WindowID(1); id <= 5; id++ {
457+ m.AddWindow(id)
458+ }
459+ w := m.Current()
460+ if expanded {
461+ w.Columns[2].Expanded = true
462+ }
463+ w.Columns[3].Windows = append(w.Columns[3].Windows, 6)
464+ w.Columns[3].Focus = 1
465+ w.FocusID(6)
466+ screen := w.Layout()
467+ m.ToggleOverview()
468+ if _, span, _ := w.previewTiles(); span != w.Usable.W {
469+ t.Fatalf("span %d, usable %d", span, w.Usable.W)
470+ }
471+ ps := m.Layout()
472+ z := previewOf(t, ps, 6).Preview
473+ scale := func(v int) int { return int(math.Round(float64(v) * z)) }
474+ anchor, previewAnchor := previewOf(t, screen, 1).Rect, previewOf(t, ps, 1).Rect
475+ for _, id := range []WindowID{1, 2, 3, 4, 5, 6} {
476+ r, p := previewOf(t, screen, id).Rect, previewOf(t, ps, id)
477+ if p.Hidden || p.Preview != z || math.Abs(float64(p.Rect.W-scale(r.W))) > 1 || math.Abs(float64(p.Rect.H-scale(r.H))) > 1 || math.Abs(float64(p.Rect.X-previewAnchor.X-scale(r.X-anchor.X))) > 1 || math.Abs(float64(p.Rect.Y-previewAnchor.Y-scale(r.Y-anchor.Y))) > 1 {
478+ t.Fatalf("%d: screen %+v preview %+v anchor %+v %+v", id, r, p, anchor, previewAnchor)
479+ }
480+ if p.Focused != (id == 6) {
481+ t.Fatalf("%d focus: %+v", id, p)
482+ }
483+ for _, other := range []WindowID{1, 2, 3, 4, 5, 6} {
484+ if other > id && p.Rect.Overlaps(previewOf(t, ps, other).Rect) {
485+ t.Fatalf("%d overlaps %d", id, other)
486+ }
487+ }
488+ if p.Rect.W > 0 && p.Rect.H > 0 && m.overviewAt(float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2)) != id {
489+ t.Fatalf("%d not hit at %+v", id, p.Rect)
490+ }
491+ }
492+ if len(overviewOutline(ps, 2)) != 4 {
493+ t.Fatal("missing selection outline")
494+ }
495+ m.OverviewMove(-1, 0)
496+ if id, _ := m.Focused(); id != 3 {
497+ t.Fatalf("left selected %d", id)
498+ }
499+ m.OverviewMove(1, 0)
500+ if id, _ := m.Focused(); id != 6 {
501+ t.Fatalf("right selected %d", id)
502+ }
503+ })
504+ }
505+}
506+
507+func TestOverviewFixedFullWidthShowsHiddenColumnsCard(t *testing.T) {
425508 m := newMonitor("", "")
426- m.SetOutput(300, 200)
509+ m.SetOutput(600, 400)
427510 m.SetOverflow(OverflowFixed)
428- m.SetMaxColumns(2)
429- for id := WindowID(1); id <= 4; id++ {
511+ m.SetMaxColumns(3)
512+ for id := WindowID(1); id <= 5; id++ {
430513 m.AddWindow(id)
431514 }
515+ w := m.Current()
516+ w.ToggleFullWidth()
432517 m.ToggleOverview()
433- ps := m.Layout()
434- for a := WindowID(1); a <= 4; a++ {
435- for b := a + 1; b <= 4; b++ {
436- if ra, rb := previewOf(t, ps, a).Rect, previewOf(t, ps, b).Rect; ra.Overlaps(rb) {
437- t.Fatalf("%d %+v overlaps %d %+v", a, ra, b, rb)
438- }
518+ if p := previewOf(t, m.Layout(), 5); p.Hidden || p.Peek || !p.Focused {
519+ t.Fatalf("front %+v", p)
520+ }
521+ for _, id := range []WindowID{1, 2} {
522+ if p := previewOf(t, m.Layout(), id); p.Hidden || !p.Peek {
523+ t.Fatalf("peek %d %+v", id, p)
439524 }
440525 }
526+ m.OverviewMove(0, -1)
527+ m.ToggleOverview()
528+ if id, _ := w.Focused(); id != 1 || !w.Columns[0].FullWidth || w.Columns[4].FullWidth {
529+ t.Fatalf("transfer %d %+v", id, w.Columns)
530+ }
441531 }
442532
443533 // The selected card follows its window: removing an earlier stash entry
@@ -100,13 +100,13 @@ func TestOverviewNavigation(t *testing.T) {
100 t.Fatalf("entered empty workspace %d", m.Active)100 t.Fatalf("entered empty workspace %d", m.Active)
101 }101 }
102 m.CancelOverview()102 m.CancelOverview()
103- if id, _ := m.Focused(); id != 3 || m.Active != 0 || m.overview {103+ if id, _ := m.Focused(); id != 3 || m.Active != 0 || m.ov.open {
104 t.Fatalf("cancel: %d on %d", id, m.Active)104 t.Fatalf("cancel: %d on %d", id, m.Active)
105 }105 }
106 m.ToggleOverview()106 m.ToggleOverview()
107 m.OverviewMove(0, 1)107 m.OverviewMove(0, 1)
108 m.ToggleOverview()108 m.ToggleOverview()
109- if id, _ := m.Focused(); id != 4 || m.Active != 1 || m.overview {109+ if id, _ := m.Focused(); id != 4 || m.Active != 1 || m.ov.open {
110 t.Fatalf("confirm: %d on %d", id, m.Active)110 t.Fatalf("confirm: %d on %d", id, m.Active)
111 }111 }
112 }112 }
@@ -121,7 +121,7 @@ func TestOverviewPick(t *testing.T) {
121 t.Fatalf("hit %d", id)121 t.Fatalf("hit %d", id)
122 }122 }
123 m.OverviewPick(id)123 m.OverviewPick(id)
124- if f, _ := m.Focused(); f != 4 || m.overview {124+ if f, _ := m.Focused(); f != 4 || m.ov.open {
125 t.Fatalf("pick: %d", f)125 t.Fatalf("pick: %d", f)
126 }126 }
127 }127 }
@@ -170,7 +170,7 @@ func TestOverviewFullscreenFloat(t *testing.T) {
170 }170 }
171 m.OverviewMove(-1, 0) // nothing else on its row171 m.OverviewMove(-1, 0) // nothing else on its row
172 m.ToggleOverview()172 m.ToggleOverview()
173- if f, _ := m.Focused(); f != 2 || m.overview || m.Active != 1 || !previewOf(t, m.Layout(), 2).Fullscreen {173+ if f, _ := m.Focused(); f != 2 || m.ov.open || m.Active != 1 || !previewOf(t, m.Layout(), 2).Fullscreen {
174 t.Fatalf("confirm: %d on %d", f, m.Active)174 t.Fatalf("confirm: %d on %d", f, m.Active)
175 }175 }
176 })176 })
@@ -193,7 +193,7 @@ func TestOverviewFullscreenFloatPickAndCancel(t *testing.T) {
193 }193 }
194 m.ToggleOverview()194 m.ToggleOverview()
195 m.OverviewPick(2)195 m.OverviewPick(2)
196- if f, _ := m.Focused(); f != 2 || m.overview {196+ if f, _ := m.Focused(); f != 2 || m.ov.open {
197 t.Fatalf("pick: %d", f)197 t.Fatalf("pick: %d", f)
198 }198 }
199 }199 }
@@ -204,7 +204,7 @@ func TestOverviewOpensOverFullscreenFloat(t *testing.T) {
204 m := gameMonitor(OverflowFixed, false)204 m := gameMonitor(OverflowFixed, false)
205 m.Focus(1)205 m.Focus(1)
206 m.ToggleOverview()206 m.ToggleOverview()
207- if !m.overview {207+ if !m.ov.open {
208 t.Fatal("overview did not open")208 t.Fatal("overview did not open")
209 }209 }
210 ps := m.Layout()210 ps := m.Layout()
@@ -213,7 +213,7 @@ func TestOverviewOpensOverFullscreenFloat(t *testing.T) {
213 t.Fatalf("game %+v, above %+v", game, above)213 t.Fatalf("game %+v, above %+v", game, above)
214 }214 }
215 m.CancelOverview()215 m.CancelOverview()
216- if f, _ := m.Focused(); f != 2 || m.Active != 1 || m.overview || !previewOf(t, m.Layout(), 2).Fullscreen {216+ if f, _ := m.Focused(); f != 2 || m.Active != 1 || m.ov.open || !previewOf(t, m.Layout(), 2).Fullscreen {
217 t.Fatalf("cancel: %d on %d", f, m.Active)217 t.Fatalf("cancel: %d on %d", f, m.Active)
218 }218 }
219 }219 }
@@ -230,7 +230,7 @@ func TestOverviewOpensOverFullscreenTile(t *testing.T) {
230 m.ToggleFullscreen() // 2 to its own workspace230 m.ToggleFullscreen() // 2 to its own workspace
231 m.ToggleOverview()231 m.ToggleOverview()
232 game := previewOf(t, m.Layout(), 2)232 game := previewOf(t, m.Layout(), 2)
233- if !m.overview || !game.Focused || game.Peek || !game.Fullscreen {233+ if !m.ov.open || !game.Focused || game.Peek || !game.Fullscreen {
234 t.Fatalf("game %+v", game)234 t.Fatalf("game %+v", game)
235 }235 }
236 m.OverviewMove(-1, 0)236 m.OverviewMove(-1, 0)
@@ -330,6 +330,30 @@ func TestOverviewPileLayout(t *testing.T) {
330 }330 }
331 }331 }
332 332
333+// A stash pile does not shift a row that fits beside it: the row stays
334+// centred like the same workspace without a stash.
335+func TestOverviewPileKeepsRowCentred(t *testing.T) {
336+ m := newMonitor("", "")
337+ m.SetOutput(3000, 1200)
338+ m.SetOverflow(OverflowFixed)
339+ m.SetMaxColumns(3)
340+ for id := WindowID(1); id <= 3; id++ {
341+ m.AddWindow(id)
342+ }
343+ m.ToggleOverview()
344+ want := previewOf(t, m.Layout(), 1).Rect
345+ m.CancelOverview()
346+ w := m.Current()
347+ w.AddWindow(4)
348+ w.FocusID(4)
349+ w.ToggleWindowStash()
350+ w.FocusID(1)
351+ m.ToggleOverview()
352+ if got := previewOf(t, m.Layout(), 1).Rect; got != want {
353+ t.Fatalf("row with a stash at %+v, without at %+v", got, want)
354+ }
355+}
356+
333 // h from column 1 enters the pile on the stash's selection, h/l browse357 // h from column 1 enters the pile on the stash's selection, h/l browse
334 // it, l past its end returns to column 1; Return shows the stash on the358 // it, l past its end returns to column 1; Return shows the stash on the
335 // chosen window.359 // chosen window.
@@ -355,7 +379,7 @@ func TestOverviewPileNavigation(t *testing.T) {
355 }379 }
356 m.OverviewMove(1, 0)380 m.OverviewMove(1, 0)
357 m.ToggleOverview()381 m.ToggleOverview()
358- if f, _ := m.Focused(); f != 6 || w.stashHidden || m.overview {382+ if f, _ := m.Focused(); f != 6 || w.stashHidden || m.ov.open {
359 t.Fatalf("confirm on %d, hidden %v", f, w.stashHidden)383 t.Fatalf("confirm on %d, hidden %v", f, w.stashHidden)
360 }384 }
361 // Back in on the stash's selection (6), then l past the last entry385 // Back in on the stash's selection (6), then l past the last entry
@@ -420,24 +444,90 @@ func TestOverviewPileOnly(t *testing.T) {
420 }444 }
421 }445 }
422 446
423-// Fixed overflow's spiral is laid out as a row: no preview overlaps.447+// Fixed overflow previews use the actual buffer geometry, including the
424-func TestOverviewFixedOverflowRow(t *testing.T) {448+// spiral and window stacks, rather than laying full-height columns in a row.
449+func TestOverviewFixedOverflowGeometry(t *testing.T) {
450+ for _, expanded := range []bool{false, true} {
451+ t.Run(map[bool]string{false: "spiral", true: "expanded"}[expanded], func(t *testing.T) {
452+ m := newMonitor("", "")
453+ m.SetOutput(600, 400)
454+ m.SetOverflow(OverflowFixed)
455+ m.SetMaxColumns(3)
456+ for id := WindowID(1); id <= 5; id++ {
457+ m.AddWindow(id)
458+ }
459+ w := m.Current()
460+ if expanded {
461+ w.Columns[2].Expanded = true
462+ }
463+ w.Columns[3].Windows = append(w.Columns[3].Windows, 6)
464+ w.Columns[3].Focus = 1
465+ w.FocusID(6)
466+ screen := w.Layout()
467+ m.ToggleOverview()
468+ if _, span, _ := w.previewTiles(); span != w.Usable.W {
469+ t.Fatalf("span %d, usable %d", span, w.Usable.W)
470+ }
471+ ps := m.Layout()
472+ z := previewOf(t, ps, 6).Preview
473+ scale := func(v int) int { return int(math.Round(float64(v) * z)) }
474+ anchor, previewAnchor := previewOf(t, screen, 1).Rect, previewOf(t, ps, 1).Rect
475+ for _, id := range []WindowID{1, 2, 3, 4, 5, 6} {
476+ r, p := previewOf(t, screen, id).Rect, previewOf(t, ps, id)
477+ if p.Hidden || p.Preview != z || math.Abs(float64(p.Rect.W-scale(r.W))) > 1 || math.Abs(float64(p.Rect.H-scale(r.H))) > 1 || math.Abs(float64(p.Rect.X-previewAnchor.X-scale(r.X-anchor.X))) > 1 || math.Abs(float64(p.Rect.Y-previewAnchor.Y-scale(r.Y-anchor.Y))) > 1 {
478+ t.Fatalf("%d: screen %+v preview %+v anchor %+v %+v", id, r, p, anchor, previewAnchor)
479+ }
480+ if p.Focused != (id == 6) {
481+ t.Fatalf("%d focus: %+v", id, p)
482+ }
483+ for _, other := range []WindowID{1, 2, 3, 4, 5, 6} {
484+ if other > id && p.Rect.Overlaps(previewOf(t, ps, other).Rect) {
485+ t.Fatalf("%d overlaps %d", id, other)
486+ }
487+ }
488+ if p.Rect.W > 0 && p.Rect.H > 0 && m.overviewAt(float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2)) != id {
489+ t.Fatalf("%d not hit at %+v", id, p.Rect)
490+ }
491+ }
492+ if len(overviewOutline(ps, 2)) != 4 {
493+ t.Fatal("missing selection outline")
494+ }
495+ m.OverviewMove(-1, 0)
496+ if id, _ := m.Focused(); id != 3 {
497+ t.Fatalf("left selected %d", id)
498+ }
499+ m.OverviewMove(1, 0)
500+ if id, _ := m.Focused(); id != 6 {
501+ t.Fatalf("right selected %d", id)
502+ }
503+ })
504+ }
505+}
506+
507+func TestOverviewFixedFullWidthShowsHiddenColumnsCard(t *testing.T) {
425 m := newMonitor("", "")508 m := newMonitor("", "")
426- m.SetOutput(300, 200)509+ m.SetOutput(600, 400)
427 m.SetOverflow(OverflowFixed)510 m.SetOverflow(OverflowFixed)
428- m.SetMaxColumns(2)511+ m.SetMaxColumns(3)
429- for id := WindowID(1); id <= 4; id++ {512+ for id := WindowID(1); id <= 5; id++ {
430 m.AddWindow(id)513 m.AddWindow(id)
431 }514 }
515+ w := m.Current()
516+ w.ToggleFullWidth()
432 m.ToggleOverview()517 m.ToggleOverview()
433- ps := m.Layout()518+ if p := previewOf(t, m.Layout(), 5); p.Hidden || p.Peek || !p.Focused {
434- for a := WindowID(1); a <= 4; a++ {519+ t.Fatalf("front %+v", p)
435- for b := a + 1; b <= 4; b++ {520+ }
436- if ra, rb := previewOf(t, ps, a).Rect, previewOf(t, ps, b).Rect; ra.Overlaps(rb) {521+ for _, id := range []WindowID{1, 2} {
437- t.Fatalf("%d %+v overlaps %d %+v", a, ra, b, rb)522+ if p := previewOf(t, m.Layout(), id); p.Hidden || !p.Peek {
438- }523+ t.Fatalf("peek %d %+v", id, p)
439 }524 }
440 }525 }
526+ m.OverviewMove(0, -1)
527+ m.ToggleOverview()
528+ if id, _ := w.Focused(); id != 1 || !w.Columns[0].FullWidth || w.Columns[4].FullWidth {
529+ t.Fatalf("transfer %d %+v", id, w.Columns)
530+ }
441 }531 }
442 532
443 // The selected card follows its window: removing an earlier stash entry533 // The selected card follows its window: removing an earlier stash entry
M internal/core/stash.go +5 -1
@@ -84,6 +84,7 @@ func (w *Workspace) unstash(i int) {
8484 // index. It takes the focus.
8585 func (w *Workspace) restore(id WindowID, back *origPlace) {
8686 w.floatFocus, w.stashFocus = false, false
87+ w.raiseColumns()
8788 if back == nil {
8889 w.addColumn(Column{Windows: []WindowID{id}})
8990 return
@@ -106,7 +107,10 @@ func (w *Workspace) restore(id WindowID, back *origPlace) {
106107 // Its column is gone: it comes back expanded, unless another column
107108 // was expanded meanwhile (as leaveFullscreen).
108109 expanded := back.expanded && !slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Expanded })
109- w.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, FullWidth: back.fullWidth, Expanded: expanded})
110+ w.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded})
111+ if back.fullWidth {
112+ w.maximize(w.Focus)
113+ }
110114 }
111115
112116 // rehome is a stashed window moved to another workspace: its former column
@@ -84,6 +84,7 @@ func (w *Workspace) unstash(i int) {
84 // index. It takes the focus.84 // index. It takes the focus.
85 func (w *Workspace) restore(id WindowID, back *origPlace) {85 func (w *Workspace) restore(id WindowID, back *origPlace) {
86 w.floatFocus, w.stashFocus = false, false86 w.floatFocus, w.stashFocus = false, false
87+ w.raiseColumns()
87 if back == nil {88 if back == nil {
88 w.addColumn(Column{Windows: []WindowID{id}})89 w.addColumn(Column{Windows: []WindowID{id}})
89 return90 return
@@ -106,7 +107,10 @@ func (w *Workspace) restore(id WindowID, back *origPlace) {
106 // Its column is gone: it comes back expanded, unless another column107 // Its column is gone: it comes back expanded, unless another column
107 // was expanded meanwhile (as leaveFullscreen).108 // was expanded meanwhile (as leaveFullscreen).
108 expanded := back.expanded && !slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Expanded })109 expanded := back.expanded && !slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Expanded })
109- w.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, FullWidth: back.fullWidth, Expanded: expanded})110+ w.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded})
111+ if back.fullWidth {
112+ w.maximize(w.Focus)
113+ }
110 }114 }
111 115
112 // rehome is a stashed window moved to another workspace: its former column116 // rehome is a stashed window moved to another workspace: its former column
M internal/ports/messages.go +3 -2
@@ -1045,8 +1045,9 @@ type SceneWindow struct {
10451045 // Inset sides carry a separator line inside Rect: the client is drawn
10461046 // inside it.
10471047 Inset Sides
1048- // Floating windows are drawn over the tiles and their lines.
1049- Floating bool
1048+ // Floating windows draw at their placed size. Below marks a native
1049+ // covering float behind the column group.
1050+ Floating, Below bool
10501051 // Dim darkens the window, border included, with black at this
10511052 // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing.
10521053 Dim float64
@@ -1045,8 +1045,9 @@ type SceneWindow struct {
1045 // Inset sides carry a separator line inside Rect: the client is drawn1045 // Inset sides carry a separator line inside Rect: the client is drawn
1046 // inside it.1046 // inside it.
1047 Inset Sides1047 Inset Sides
1048- // Floating windows are drawn over the tiles and their lines.1048+ // Floating windows draw at their placed size. Below marks a native
1049- Floating bool1049+ // covering float behind the column group.
1050+ Floating, Below bool
1050 // Dim darkens the window, border included, with black at this1051 // Dim darkens the window, border included, with black at this
1051 // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing.1052 // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing.
1052 Dim float641053 Dim float64

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