refactor(core): keep fixed-overflow fullscreen windows in place (#77)
* test(core): wait for the output in the slot activation test The workspaces channel keeps the latest snapshot, which may still be the one from before the output was added; the test indexed its empty output list and panicked. * refactor(core): keep fixed-overflow fullscreen windows in place A fixed-overflow fullscreen window no longer moves to a workspace of its own. It stays in its column; focus-column-left/right and focus-window-up/down leave fullscreen for the window on that side, and at the workspace edge the move goes on to the next monitor or workspace with fullscreen kept. Windows opening meanwhile wait hidden. This removes the origin and sibling workspace machinery: fold-back, moved-window landing, slot tracking away from home and sibling sizing. Monitor.all yields workspaces instead of copying them. * fix(core): go to the neighbor monitor from a fullscreen stash edge focus-column from a fullscreen stashed window at the end of the stash now reaches the neighbor monitor and keeps fullscreen, as from a tile at the workspace edge. Fixes doc comments left from the sibling workspaces. * fix(core): refuse fixed-overflow fullscreen requests from unfocused windows In fixed overflow a fullscreen tile covers the output and takes the keyboard. A client request now applies only to the focused window (ADR 011); a taskbar request focuses the window first. Docs no longer claim the overview leaves fullscreen. * fix(core): answer every fullscreen request and let taskbars take over A refused or ignored fullscreen request now gets an unchanged configure back, as xdg-shell requires. A taskbar fullscreen request activates the window, leaving another window's fullscreen. * refactor(core): share the fullscreen exit and bring taskbar fullscreen on screen - endFullscreen is the one exit path for the bind, focus moves, activations and client requests; FocusColumn reports the edge. - A taskbar fullscreen request activates the window like xdg-activation, so a window off screen comes on screen with the focus. - Fullscreen requests blocked while locked are answered after unlock. - Monitor.Layout iterates with Monitor.all; arrive and the fullscreen normalize calls are gone. - Tests: one table for client requests, taskbar off screen and leave, focus-window-down at the column bottom, answer pruning; drop tests of the removed fullscreen workspace return. * fix(core): end fullscreen through endFullscreen when activating a hidden stash Activating a hidden stashed window over a scroll fullscreen tile now realigns the view and reconciles floats like every other exit. Locked fullscreen requests are answered through Core.blockProtected, with a test; the taskbar tests cover the overview and fail if window 1 is missing.
- commit
- d79bf7541b59c0e3bf015c1f298ce7a3a0c4afdc
- author
- bnema · · 2026-10-01 10:11 UTC
- committer
- GitHub · · 2026-10-01 10:11 UTC
- parent
- 1e27399
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
30 files · +635 -1087
M docs/config.md +3 -3
| @@ -117,14 +117,14 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||
| 117 | 117 | | `spawn <command>` | Run a program | | |
| 118 | 118 | | `spawn-terminal` | Run `terminal` | | |
| 119 | 119 | | `close-window` | Close the focused window | | |
| 120 | - | | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; activating one of them leaves fullscreen. In fixed overflow, the window gets its own workspace below its home one; a window opening while it is on screen tiles on the home workspace and the view stays on the fullscreen window; activating it shows the home workspace | | |
| 120 | + | | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; a window opening meanwhile waits hidden too. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down` or an activation) leaves fullscreen. In fixed overflow, an app's own request applies only while it has the focus. A taskbar request brings the window on screen with the focus first | | |
| 121 | 121 | | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | | |
| 122 | 122 | | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window | | |
| 123 | 123 | | `toggle-overview` | Open the [overview](#overview), or close it on the selected window | | |
| 124 | 124 | | `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 | | |
| 125 | 125 | | `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 | | |
| 126 | - | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen | | |
| 127 | - | | `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 | | |
| 126 | + | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays | | |
| 127 | + | | `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. From a fullscreen window, a window above or below comes first and fullscreen ends | | |
| 128 | 128 | | `move-column-left/right` | Move the focused column | | |
| 129 | 129 | | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column | | |
| 130 | 130 | | `focus-workspace <N>` / `focus-workspace-up/down` | Show a numbered or neighbor workspace | | |
| @@ -117,14 +117,14 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||||
| 117 | | `spawn <command>` | Run a program | | 117 | | `spawn <command>` | Run a program | | ||
| 118 | | `spawn-terminal` | Run `terminal` | | 118 | | `spawn-terminal` | Run `terminal` | | ||
| 119 | | `close-window` | Close the focused window | | 119 | | `close-window` | Close the focused window | | ||
| 120 | - | | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; activating one of them leaves fullscreen. In fixed overflow, the window gets its own workspace below its home one; a window opening while it is on screen tiles on the home workspace and the view stays on the fullscreen window; activating it shows the home workspace | | 120 | + | | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; a window opening meanwhile waits hidden too. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down` or an activation) leaves fullscreen. In fixed overflow, an app's own request applies only while it has the focus. A taskbar request brings the window on screen with the focus first | |
| 121 | | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | | 121 | | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | | ||
| 122 | | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window | | 122 | | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window | | ||
| 123 | | `toggle-overview` | Open the [overview](#overview), or close it on the selected window | | 123 | | `toggle-overview` | Open the [overview](#overview), or close it on the selected window | | ||
| 124 | | `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 | | 124 | | `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 | | ||
| 125 | | `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 | | 125 | | `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 | | ||
| 126 | - | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen | | 126 | + | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays | |
| 127 | - | | `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 | | 127 | + | | `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. From a fullscreen window, a window above or below comes first and fullscreen ends | |
| 128 | | `move-column-left/right` | Move the focused column | | 128 | | `move-column-left/right` | Move the focused column | | ||
| 129 | | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column | | 129 | | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column | | ||
| 130 | | `focus-workspace <N>` / `focus-workspace-up/down` | Show a numbered or neighbor workspace | | 130 | | `focus-workspace <N>` / `focus-workspace-up/down` | Show a numbered or neighbor workspace | | ||
M internal/core/action.go +15 -7
| @@ -146,11 +146,21 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 146 | 146 | } else { | |
| 147 | 147 | dir = 1 | |
| 148 | 148 | } | |
| 149 | + | // A covering fullscreen window is left for the window on that side; | |
| 150 | + | // with none (the workspace or stash edge), the move goes to the | |
| 151 | + | // neighbor monitor and fullscreen stays. | |
| 152 | + | if w.pinned() { | |
| 153 | + | if !w.FocusColumn(dir) { | |
| 154 | + | if i := c.neighbor(dir); i >= 0 { | |
| 155 | + | c.focusScreen = i | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | return Effect{} | |
| 159 | + | } | |
| 149 | 160 | // The first move from a float stays here unless nothing is under it: | |
| 150 | 161 | // a native float leaves for the stash or the columns, and the stash | |
| 151 | - | // keeps the focus at its ends. A covering fullscreen window pins | |
| 152 | - | // the focus: the move goes to the neighbor monitor. | |
| 153 | - | edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.pinned() || w.floatFocus && !w.canLeaveFloat()) | |
| 162 | + | // keeps the focus at its ends. | |
| 163 | + | edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat()) | |
| 154 | 164 | if i := c.neighbor(dir); edge && i >= 0 { | |
| 155 | 165 | c.focusScreen = i | |
| 156 | 166 | return Effect{} | |
| @@ -176,7 +186,7 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 176 | 186 | return Effect{} | |
| 177 | 187 | } | |
| 178 | 188 | c.focusScreen = i | |
| 179 | - | c.cur().mon.landing().expelTo(id, dir) | |
| 189 | + | c.cur().mon.Current().expelTo(id, dir) | |
| 180 | 190 | from.mon.normalize() | |
| 181 | 191 | c.cur().mon.normalize() | |
| 182 | 192 | return Effect{} | |
| @@ -196,7 +206,7 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 196 | 206 | } | |
| 197 | 207 | from := c.cur() | |
| 198 | 208 | c.focusScreen = i | |
| 199 | - | to := c.cur().mon.landing() | |
| 209 | + | to := c.cur().mon.Current() | |
| 200 | 210 | at := 0 | |
| 201 | 211 | if dir < 0 { | |
| 202 | 212 | at = len(to.Columns) | |
| @@ -320,8 +330,6 @@ func (w *Workspace) Apply(a Action) Effect { | |||
| 320 | 330 | case ActionMaximizeColumn: | |
| 321 | 331 | w.ToggleFullWidth() | |
| 322 | 332 | case ActionToggleFullscreen: | |
| 323 | - | // In place only: binds go through Monitor.Apply, which gives a | |
| 324 | - | // fixed-overflow fullscreen its own workspace. | |
| 325 | 333 | w.ToggleFullscreen() | |
| 326 | 334 | case ActionToggleWindowStash: | |
| 327 | 335 | w.ToggleWindowStash() | |
| @@ -146,11 +146,21 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 146 | } else { | 146 | } else { | ||
| 147 | dir = 1 | 147 | dir = 1 | ||
| 148 | } | 148 | } | ||
| 149 | + | // A covering fullscreen window is left for the window on that side; | |||
| 150 | + | // with none (the workspace or stash edge), the move goes to the | |||
| 151 | + | // neighbor monitor and fullscreen stays. | |||
| 152 | + | if w.pinned() { | |||
| 153 | + | if !w.FocusColumn(dir) { | |||
| 154 | + | if i := c.neighbor(dir); i >= 0 { | |||
| 155 | + | c.focusScreen = i | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | return Effect{} | |||
| 159 | + | } | |||
| 149 | // The first move from a float stays here unless nothing is under it: | 160 | // The first move from a float stays here unless nothing is under it: | ||
| 150 | // a native float leaves for the stash or the columns, and the stash | 161 | // a native float leaves for the stash or the columns, and the stash | ||
| 151 | - | // keeps the focus at its ends. A covering fullscreen window pins | 162 | + | // keeps the focus at its ends. |
| 152 | - | // the focus: the move goes to the neighbor monitor. | 163 | + | edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.floatFocus && !w.canLeaveFloat()) |
| 153 | - | edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.pinned() || w.floatFocus && !w.canLeaveFloat()) | |||
| 154 | if i := c.neighbor(dir); edge && i >= 0 { | 164 | if i := c.neighbor(dir); edge && i >= 0 { | ||
| 155 | c.focusScreen = i | 165 | c.focusScreen = i | ||
| 156 | return Effect{} | 166 | return Effect{} | ||
| @@ -176,7 +186,7 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 176 | return Effect{} | 186 | return Effect{} | ||
| 177 | } | 187 | } | ||
| 178 | c.focusScreen = i | 188 | c.focusScreen = i | ||
| 179 | - | c.cur().mon.landing().expelTo(id, dir) | 189 | + | c.cur().mon.Current().expelTo(id, dir) |
| 180 | from.mon.normalize() | 190 | from.mon.normalize() | ||
| 181 | c.cur().mon.normalize() | 191 | c.cur().mon.normalize() | ||
| 182 | return Effect{} | 192 | return Effect{} | ||
| @@ -196,7 +206,7 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 196 | } | 206 | } | ||
| 197 | from := c.cur() | 207 | from := c.cur() | ||
| 198 | c.focusScreen = i | 208 | c.focusScreen = i | ||
| 199 | - | to := c.cur().mon.landing() | 209 | + | to := c.cur().mon.Current() |
| 200 | at := 0 | 210 | at := 0 | ||
| 201 | if dir < 0 { | 211 | if dir < 0 { | ||
| 202 | at = len(to.Columns) | 212 | at = len(to.Columns) | ||
| @@ -320,8 +330,6 @@ func (w *Workspace) Apply(a Action) Effect { | |||||
| 320 | case ActionMaximizeColumn: | 330 | case ActionMaximizeColumn: | ||
| 321 | w.ToggleFullWidth() | 331 | w.ToggleFullWidth() | ||
| 322 | case ActionToggleFullscreen: | 332 | case ActionToggleFullscreen: | ||
| 323 | - | // In place only: binds go through Monitor.Apply, which gives a | |||
| 324 | - | // fixed-overflow fullscreen its own workspace. | |||
| 325 | w.ToggleFullscreen() | 333 | w.ToggleFullscreen() | ||
| 326 | case ActionToggleWindowStash: | 334 | case ActionToggleWindowStash: | ||
| 327 | w.ToggleWindowStash() | 335 | w.ToggleWindowStash() | ||
M internal/core/capture.go +1 -1
| @@ -434,7 +434,7 @@ func (c *Core) captureSceneFor(sc *screen, v *capView) *ports.SceneCapture { | |||
| 434 | 434 | // workspaceByID finds a workspace, numbered or hidden, on any screen. | |
| 435 | 435 | func (c *Core) workspaceByID(id uint64) (*screen, *Workspace) { | |
| 436 | 436 | for _, sc := range c.screens { | |
| 437 | - | for _, w := range sc.mon.all() { | |
| 437 | + | for w := range sc.mon.all() { | |
| 438 | 438 | if w.ID == id { | |
| 439 | 439 | return sc, w | |
| 440 | 440 | } | |
| @@ -434,7 +434,7 @@ func (c *Core) captureSceneFor(sc *screen, v *capView) *ports.SceneCapture { | |||||
| 434 | // workspaceByID finds a workspace, numbered or hidden, on any screen. | 434 | // workspaceByID finds a workspace, numbered or hidden, on any screen. | ||
| 435 | func (c *Core) workspaceByID(id uint64) (*screen, *Workspace) { | 435 | func (c *Core) workspaceByID(id uint64) (*screen, *Workspace) { | ||
| 436 | for _, sc := range c.screens { | 436 | for _, sc := range c.screens { | ||
| 437 | - | for _, w := range sc.mon.all() { | 437 | + | for w := range sc.mon.all() { |
| 438 | if w.ID == id { | 438 | if w.ID == id { | ||
| 439 | return sc, w | 439 | return sc, w | ||
| 440 | } | 440 | } | ||
M internal/core/configures.go +15 -4
| @@ -23,12 +23,15 @@ type configureTarget struct { | |||
| 23 | 23 | type configures struct { | |
| 24 | 24 | sent map[WindowID]ports.ConfigureWindow | |
| 25 | 25 | seen map[WindowID]bool | |
| 26 | + | // answer holds windows whose state request awaits a configure, sent | |
| 27 | + | // even unchanged: xdg-shell answers every request, a refused one too. | |
| 28 | + | answer map[WindowID]bool | |
| 26 | 29 | // cw is the workspace a capture session renders off screen, if any. | |
| 27 | 30 | cw captureWorkspace | |
| 28 | 31 | } | |
| 29 | 32 | ||
| 30 | 33 | func newConfigures() configures { | |
| 31 | - | return configures{sent: map[WindowID]ports.ConfigureWindow{}, seen: map[WindowID]bool{}} | |
| 34 | + | return configures{sent: map[WindowID]ports.ConfigureWindow{}, seen: map[WindowID]bool{}, answer: map[WindowID]bool{}} | |
| 32 | 35 | } | |
| 33 | 36 | ||
| 34 | 37 | // next returns the configure for p at t and whether it must be sent. The | |
| @@ -37,7 +40,7 @@ func (s *configures) next(p Placement, t configureTarget) (ports.ConfigureWindow | |||
| 37 | 40 | s.seen[p.ID] = true | |
| 38 | 41 | old, ok := s.sent[p.ID] | |
| 39 | 42 | v := build(p, t, old, ok) | |
| 40 | - | return v, !ok || v != old | |
| 43 | + | return v, !ok || v != old || s.answer[p.ID] | |
| 41 | 44 | } | |
| 42 | 45 | ||
| 43 | 46 | // nextWithCapture is next for a window of a workspace a capture session | |
| @@ -66,7 +69,7 @@ func (s *configures) nextWithCapture(p Placement, t configureTarget, cp *Placeme | |||
| 66 | 69 | v = build(p, t, old, ok) | |
| 67 | 70 | } | |
| 68 | 71 | v.Captured = !v.Visible && !cp.Hidden && cp.Rect.Overlaps(t.captureArea) | |
| 69 | - | return v, !ok || v != old | |
| 72 | + | return v, !ok || v != old || s.answer[p.ID] | |
| 70 | 73 | } | |
| 71 | 74 | ||
| 72 | 75 | // build is the configure for p at t given the last one sent, if any. It | |
| @@ -113,7 +116,10 @@ func build(p Placement, t configureTarget, old ports.ConfigureWindow, ok bool) p | |||
| 113 | 116 | } | |
| 114 | 117 | ||
| 115 | 118 | // mark records v as sent. | |
| 116 | - | func (s *configures) mark(v ports.ConfigureWindow) { s.sent[v.ID] = v } | |
| 119 | + | func (s *configures) mark(v ports.ConfigureWindow) { | |
| 120 | + | s.sent[v.ID] = v | |
| 121 | + | delete(s.answer, v.ID) | |
| 122 | + | } | |
| 117 | 123 | ||
| 118 | 124 | // prune forgets the windows no next call saw since the last prune: they | |
| 119 | 125 | // left the layout. | |
| @@ -123,5 +129,10 @@ func (s *configures) prune() { | |||
| 123 | 129 | delete(s.sent, id) | |
| 124 | 130 | } | |
| 125 | 131 | } | |
| 132 | + | for id := range s.answer { | |
| 133 | + | if !s.seen[id] { | |
| 134 | + | delete(s.answer, id) | |
| 135 | + | } | |
| 136 | + | } | |
| 126 | 137 | clear(s.seen) | |
| 127 | 138 | } | |
| @@ -23,12 +23,15 @@ type configureTarget struct { | |||||
| 23 | type configures struct { | 23 | type configures struct { | ||
| 24 | sent map[WindowID]ports.ConfigureWindow | 24 | sent map[WindowID]ports.ConfigureWindow | ||
| 25 | seen map[WindowID]bool | 25 | seen map[WindowID]bool | ||
| 26 | + | // answer holds windows whose state request awaits a configure, sent | |||
| 27 | + | // even unchanged: xdg-shell answers every request, a refused one too. | |||
| 28 | + | answer map[WindowID]bool | |||
| 26 | // cw is the workspace a capture session renders off screen, if any. | 29 | // cw is the workspace a capture session renders off screen, if any. | ||
| 27 | cw captureWorkspace | 30 | cw captureWorkspace | ||
| 28 | } | 31 | } | ||
| 29 | 32 | ||||
| 30 | func newConfigures() configures { | 33 | func newConfigures() configures { | ||
| 31 | - | return configures{sent: map[WindowID]ports.ConfigureWindow{}, seen: map[WindowID]bool{}} | 34 | + | return configures{sent: map[WindowID]ports.ConfigureWindow{}, seen: map[WindowID]bool{}, answer: map[WindowID]bool{}} |
| 32 | } | 35 | } | ||
| 33 | 36 | ||||
| 34 | // next returns the configure for p at t and whether it must be sent. The | 37 | // next returns the configure for p at t and whether it must be sent. The | ||
| @@ -37,7 +40,7 @@ func (s *configures) next(p Placement, t configureTarget) (ports.ConfigureWindow | |||||
| 37 | s.seen[p.ID] = true | 40 | s.seen[p.ID] = true | ||
| 38 | old, ok := s.sent[p.ID] | 41 | old, ok := s.sent[p.ID] | ||
| 39 | v := build(p, t, old, ok) | 42 | v := build(p, t, old, ok) | ||
| 40 | - | return v, !ok || v != old | 43 | + | return v, !ok || v != old || s.answer[p.ID] |
| 41 | } | 44 | } | ||
| 42 | 45 | ||||
| 43 | // nextWithCapture is next for a window of a workspace a capture session | 46 | // nextWithCapture is next for a window of a workspace a capture session | ||
| @@ -66,7 +69,7 @@ func (s *configures) nextWithCapture(p Placement, t configureTarget, cp *Placeme | |||||
| 66 | v = build(p, t, old, ok) | 69 | v = build(p, t, old, ok) | ||
| 67 | } | 70 | } | ||
| 68 | v.Captured = !v.Visible && !cp.Hidden && cp.Rect.Overlaps(t.captureArea) | 71 | v.Captured = !v.Visible && !cp.Hidden && cp.Rect.Overlaps(t.captureArea) | ||
| 69 | - | return v, !ok || v != old | 72 | + | return v, !ok || v != old || s.answer[p.ID] |
| 70 | } | 73 | } | ||
| 71 | 74 | ||||
| 72 | // build is the configure for p at t given the last one sent, if any. It | 75 | // build is the configure for p at t given the last one sent, if any. It | ||
| @@ -113,7 +116,10 @@ func build(p Placement, t configureTarget, old ports.ConfigureWindow, ok bool) p | |||||
| 113 | } | 116 | } | ||
| 114 | 117 | ||||
| 115 | // mark records v as sent. | 118 | // mark records v as sent. | ||
| 116 | - | func (s *configures) mark(v ports.ConfigureWindow) { s.sent[v.ID] = v } | 119 | + | func (s *configures) mark(v ports.ConfigureWindow) { |
| 120 | + | s.sent[v.ID] = v | |||
| 121 | + | delete(s.answer, v.ID) | |||
| 122 | + | } | |||
| 117 | 123 | ||||
| 118 | // prune forgets the windows no next call saw since the last prune: they | 124 | // prune forgets the windows no next call saw since the last prune: they | ||
| 119 | // left the layout. | 125 | // left the layout. | ||
| @@ -123,5 +129,10 @@ func (s *configures) prune() { | |||||
| 123 | delete(s.sent, id) | 129 | delete(s.sent, id) | ||
| 124 | } | 130 | } | ||
| 125 | } | 131 | } | ||
| 132 | + | for id := range s.answer { | |||
| 133 | + | if !s.seen[id] { | |||
| 134 | + | delete(s.answer, id) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 126 | clear(s.seen) | 137 | clear(s.seen) | ||
| 127 | } | 138 | } | ||
M internal/core/configures_test.go +22 -0
| @@ -49,6 +49,22 @@ func TestConfiguresNext(t *testing.T) { | |||
| 49 | 49 | } | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | + | // A window awaiting an answer gets its configure once, even unchanged. | |
| 53 | + | func TestConfiguresAnswer(t *testing.T) { | |
| 54 | + | s := newConfigures() | |
| 55 | + | p, target := Placement{ID: 1, Rect: Rect{W: 10, H: 10}}, configureTarget{output: "A"} | |
| 56 | + | v, _ := s.next(p, target) | |
| 57 | + | s.mark(v) | |
| 58 | + | s.answer[1] = true | |
| 59 | + | if _, send := s.next(p, target); !send { | |
| 60 | + | t.Fatal("answer not sent") | |
| 61 | + | } | |
| 62 | + | s.mark(v) | |
| 63 | + | if _, send := s.next(p, target); send { | |
| 64 | + | t.Fatal("answered twice") | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 52 | 68 | func TestConfiguresPruneForgetsUnseen(t *testing.T) { | |
| 53 | 69 | s := newConfigures() | |
| 54 | 70 | for _, id := range []WindowID{1, 2} { | |
| @@ -67,4 +83,10 @@ func TestConfiguresPruneForgetsUnseen(t *testing.T) { | |||
| 67 | 83 | if _, send := s.next(Placement{ID: 2}, configureTarget{}); !send { | |
| 68 | 84 | t.Fatal("returning window not configured") | |
| 69 | 85 | } | |
| 86 | + | // A request from a window out of the layout is forgotten too. | |
| 87 | + | s.answer[9] = true | |
| 88 | + | s.prune() | |
| 89 | + | if s.answer[9] { | |
| 90 | + | t.Fatal("answer for a window out of the layout kept") | |
| 91 | + | } | |
| 70 | 92 | } | |
| @@ -49,6 +49,22 @@ func TestConfiguresNext(t *testing.T) { | |||||
| 49 | } | 49 | } | ||
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | + | // A window awaiting an answer gets its configure once, even unchanged. | |||
| 53 | + | func TestConfiguresAnswer(t *testing.T) { | |||
| 54 | + | s := newConfigures() | |||
| 55 | + | p, target := Placement{ID: 1, Rect: Rect{W: 10, H: 10}}, configureTarget{output: "A"} | |||
| 56 | + | v, _ := s.next(p, target) | |||
| 57 | + | s.mark(v) | |||
| 58 | + | s.answer[1] = true | |||
| 59 | + | if _, send := s.next(p, target); !send { | |||
| 60 | + | t.Fatal("answer not sent") | |||
| 61 | + | } | |||
| 62 | + | s.mark(v) | |||
| 63 | + | if _, send := s.next(p, target); send { | |||
| 64 | + | t.Fatal("answered twice") | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 52 | func TestConfiguresPruneForgetsUnseen(t *testing.T) { | 68 | func TestConfiguresPruneForgetsUnseen(t *testing.T) { | ||
| 53 | s := newConfigures() | 69 | s := newConfigures() | ||
| 54 | for _, id := range []WindowID{1, 2} { | 70 | for _, id := range []WindowID{1, 2} { | ||
| @@ -67,4 +83,10 @@ func TestConfiguresPruneForgetsUnseen(t *testing.T) { | |||||
| 67 | if _, send := s.next(Placement{ID: 2}, configureTarget{}); !send { | 83 | if _, send := s.next(Placement{ID: 2}, configureTarget{}); !send { | ||
| 68 | t.Fatal("returning window not configured") | 84 | t.Fatal("returning window not configured") | ||
| 69 | } | 85 | } | ||
| 86 | + | // A request from a window out of the layout is forgotten too. | |||
| 87 | + | s.answer[9] = true | |||
| 88 | + | s.prune() | |||
| 89 | + | if s.answer[9] { | |||
| 90 | + | t.Fatal("answer for a window out of the layout kept") | |||
| 91 | + | } | |||
| 70 | } | 92 | } | ||
M internal/core/core.go +11 -9
| @@ -524,7 +524,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 524 | 524 | var real map[WindowID]Placement | |
| 525 | 525 | if sc.mon.ov.open { | |
| 526 | 526 | real = make(map[WindowID]Placement) | |
| 527 | - | for _, w := range sc.mon.all() { | |
| 527 | + | for w := range sc.mon.all() { | |
| 528 | 528 | for _, p := range w.Layout() { | |
| 529 | 529 | real[p.ID] = p | |
| 530 | 530 | } | |
| @@ -915,7 +915,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 915 | 915 | if c.securityCheckpoint(ctx) != nil { | |
| 916 | 916 | return nil | |
| 917 | 917 | } | |
| 918 | - | if c.security.Protected && blockedProtectedEvent(ev) { | |
| 918 | + | if c.blockProtected(ev) { | |
| 919 | 919 | continue | |
| 920 | 920 | } | |
| 921 | 921 | switch v := ev.(type) { | |
| @@ -1001,8 +1001,15 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1001 | 1001 | return nil | |
| 1002 | 1002 | } | |
| 1003 | 1003 | case ports.WindowFullscreenRequest: | |
| 1004 | + | c.configures.answer[v.ID] = true | |
| 1004 | 1005 | if v.Fullscreen && !v.External && c.now().Sub(c.windows.lookup(v.ID).mappedAt) < fullscreenGrace { | |
| 1005 | - | continue | |
| 1006 | + | break | |
| 1007 | + | } | |
| 1008 | + | // A taskbar request is a user action on that window, as an | |
| 1009 | + | // activation: it comes on screen with the focus, leaving | |
| 1010 | + | // another window's fullscreen. | |
| 1011 | + | if v.External && v.Fullscreen && c.activate(ctx, v.ID) != nil { | |
| 1012 | + | return nil | |
| 1006 | 1013 | } | |
| 1007 | 1014 | if s, _ := c.screenOf(v.ID); s != nil { | |
| 1008 | 1015 | s.mon.SetFullscreen(v.ID, v.Fullscreen) | |
| @@ -1014,7 +1021,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1014 | 1021 | if sc.name() == "" { | |
| 1015 | 1022 | continue | |
| 1016 | 1023 | } | |
| 1017 | - | for _, w := range sc.mon.all() { | |
| 1024 | + | for w := range sc.mon.all() { | |
| 1018 | 1025 | if w.ID == id { | |
| 1019 | 1026 | c.focusScreen = i | |
| 1020 | 1027 | sc.mon.show(w) | |
| @@ -1182,11 +1189,6 @@ func (c *Core) activate(ctx context.Context, id WindowID) error { | |||
| 1182 | 1189 | return nil | |
| 1183 | 1190 | } | |
| 1184 | 1191 | before := c.cur().mon.Current() | |
| 1185 | - | if full := w.cover(); full != 0 && full != id && w.origin != nil { | |
| 1186 | - | // The window hides under a fullscreen workspace: it goes home first. | |
| 1187 | - | s.mon.leaveFullscreen(w, false) | |
| 1188 | - | _, w = c.screenOf(id) | |
| 1189 | - | } | |
| 1190 | 1192 | if w != s.mon.Current() { | |
| 1191 | 1193 | s.mon.show(w) | |
| 1192 | 1194 | } | |
| @@ -524,7 +524,7 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 524 | var real map[WindowID]Placement | 524 | var real map[WindowID]Placement | ||
| 525 | if sc.mon.ov.open { | 525 | if sc.mon.ov.open { | ||
| 526 | real = make(map[WindowID]Placement) | 526 | real = make(map[WindowID]Placement) | ||
| 527 | - | for _, w := range sc.mon.all() { | 527 | + | for w := range sc.mon.all() { |
| 528 | for _, p := range w.Layout() { | 528 | for _, p := range w.Layout() { | ||
| 529 | real[p.ID] = p | 529 | real[p.ID] = p | ||
| 530 | } | 530 | } | ||
| @@ -915,7 +915,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 915 | if c.securityCheckpoint(ctx) != nil { | 915 | if c.securityCheckpoint(ctx) != nil { | ||
| 916 | return nil | 916 | return nil | ||
| 917 | } | 917 | } | ||
| 918 | - | if c.security.Protected && blockedProtectedEvent(ev) { | 918 | + | if c.blockProtected(ev) { |
| 919 | continue | 919 | continue | ||
| 920 | } | 920 | } | ||
| 921 | switch v := ev.(type) { | 921 | switch v := ev.(type) { | ||
| @@ -1001,8 +1001,15 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1001 | return nil | 1001 | return nil | ||
| 1002 | } | 1002 | } | ||
| 1003 | case ports.WindowFullscreenRequest: | 1003 | case ports.WindowFullscreenRequest: | ||
| 1004 | + | c.configures.answer[v.ID] = true | |||
| 1004 | if v.Fullscreen && !v.External && c.now().Sub(c.windows.lookup(v.ID).mappedAt) < fullscreenGrace { | 1005 | if v.Fullscreen && !v.External && c.now().Sub(c.windows.lookup(v.ID).mappedAt) < fullscreenGrace { | ||
| 1005 | - | continue | 1006 | + | break |
| 1007 | + | } | |||
| 1008 | + | // A taskbar request is a user action on that window, as an | |||
| 1009 | + | // activation: it comes on screen with the focus, leaving | |||
| 1010 | + | // another window's fullscreen. | |||
| 1011 | + | if v.External && v.Fullscreen && c.activate(ctx, v.ID) != nil { | |||
| 1012 | + | return nil | |||
| 1006 | } | 1013 | } | ||
| 1007 | if s, _ := c.screenOf(v.ID); s != nil { | 1014 | if s, _ := c.screenOf(v.ID); s != nil { | ||
| 1008 | s.mon.SetFullscreen(v.ID, v.Fullscreen) | 1015 | s.mon.SetFullscreen(v.ID, v.Fullscreen) | ||
| @@ -1014,7 +1021,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1014 | if sc.name() == "" { | 1021 | if sc.name() == "" { | ||
| 1015 | continue | 1022 | continue | ||
| 1016 | } | 1023 | } | ||
| 1017 | - | for _, w := range sc.mon.all() { | 1024 | + | for w := range sc.mon.all() { |
| 1018 | if w.ID == id { | 1025 | if w.ID == id { | ||
| 1019 | c.focusScreen = i | 1026 | c.focusScreen = i | ||
| 1020 | sc.mon.show(w) | 1027 | sc.mon.show(w) | ||
| @@ -1182,11 +1189,6 @@ func (c *Core) activate(ctx context.Context, id WindowID) error { | |||||
| 1182 | return nil | 1189 | return nil | ||
| 1183 | } | 1190 | } | ||
| 1184 | before := c.cur().mon.Current() | 1191 | before := c.cur().mon.Current() | ||
| 1185 | - | if full := w.cover(); full != 0 && full != id && w.origin != nil { | |||
| 1186 | - | // The window hides under a fullscreen workspace: it goes home first. | |||
| 1187 | - | s.mon.leaveFullscreen(w, false) | |||
| 1188 | - | _, w = c.screenOf(id) | |||
| 1189 | - | } | |||
| 1190 | if w != s.mon.Current() { | 1192 | if w != s.mon.Current() { | ||
| 1191 | s.mon.show(w) | 1193 | s.mon.show(w) | ||
| 1192 | } | 1194 | } | ||
M internal/core/core_test.go +121 -11
| @@ -837,19 +837,22 @@ func TestFullscreenAtMapIgnored(t *testing.T) { | |||
| 837 | 837 | } | |
| 838 | 838 | } | |
| 839 | 839 | now.Add(1) | |
| 840 | + | // Only the focused window may cover the screen (ADR 011). | |
| 841 | + | client <- ports.WindowUnmapped{ID: 3} | |
| 842 | + | scene(t, scenes) | |
| 840 | 843 | client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true} | |
| 841 | 844 | s = scene(t, scenes) | |
| 842 | - | // Honoured: under fixed overflow it gets its own workspace, and the view | |
| 843 | - | // stays here since it has no focus (ADR 011). | |
| 844 | - | if !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 && w.Hidden }) { | |
| 845 | + | // Honoured, in place. | |
| 846 | + | if !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 && w.Fullscreen && !w.Hidden }) { | |
| 845 | 847 | t.Fatalf("later fullscreen ignored: %+v", s.Windows) | |
| 846 | 848 | } | |
| 847 | 849 | } | |
| 848 | 850 | ||
| 849 | - | // Under fixed overflow, a window opening over a fullscreen game tiles on | |
| 850 | - | // its home workspace: the game keeps the screen and the keyboard, and an | |
| 851 | - | // activation (a user action in the new client) shows the window there. | |
| 852 | - | func TestFixedFullscreenArrivalTilesAtHome(t *testing.T) { | |
| 851 | + | // Under fixed overflow, a window opening over a fullscreen game waits | |
| 852 | + | // hidden: the game keeps the screen and the keyboard. An activation (a | |
| 853 | + | // user action in the new client) leaves fullscreen and shows both tiles; | |
| 854 | + | // a focus move back to the game and the bind make it fullscreen again. | |
| 855 | + | func TestFixedFullscreenArrivalWaits(t *testing.T) { | |
| 853 | 856 | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left) | |
| 854 | 857 | r.mapWindow(t, 1) | |
| 855 | 858 | r.key(t, "f", ports.ModAlt|ports.ModShift) | |
| @@ -862,7 +865,7 @@ func TestFixedFullscreenArrivalTilesAtHome(t *testing.T) { | |||
| 862 | 865 | } | |
| 863 | 866 | r.client <- ports.WindowActivate{ID: 2} | |
| 864 | 867 | set = receive(t, r.scenes) | |
| 865 | - | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 { | |
| 868 | + | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) || focused != 2 { | |
| 866 | 869 | t.Fatal("after activate:", got, focused) | |
| 867 | 870 | } | |
| 868 | 871 | for { | |
| @@ -870,10 +873,10 @@ func TestFixedFullscreenArrivalTilesAtHome(t *testing.T) { | |||
| 870 | 873 | break | |
| 871 | 874 | } | |
| 872 | 875 | } | |
| 873 | - | // The game still waits fullscreen one workspace down. | |
| 874 | - | set = r.key(t, "Next", ports.ModAlt) | |
| 876 | + | r.key(t, "Left", ports.ModAlt) | |
| 877 | + | set = r.key(t, "f", ports.ModAlt|ports.ModShift) | |
| 875 | 878 | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 { | |
| 876 | - | t.Fatal("game workspace:", got, focused) | |
| 879 | + | t.Fatal("game again:", got, focused) | |
| 877 | 880 | } | |
| 878 | 881 | } | |
| 879 | 882 | ||
| @@ -904,6 +907,113 @@ func TestExternalFullscreenAtMapApplies(t *testing.T) { | |||
| 904 | 907 | } | |
| 905 | 908 | } | |
| 906 | 909 | ||
| 910 | + | // Under fixed overflow, a taskbar's fullscreen on an unfocused window is | |
| 911 | + | // the user's choice: the window takes the focus and covers the screen. | |
| 912 | + | func TestExternalFullscreenFocusesFixed(t *testing.T) { | |
| 913 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left) | |
| 914 | + | r.mapWindow(t, 1) | |
| 915 | + | r.mapWindow(t, 2) | |
| 916 | + | r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true} | |
| 917 | + | set := receive(t, r.scenes) | |
| 918 | + | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 { | |
| 919 | + | t.Fatal("taskbar fullscreen:", got, focused) | |
| 920 | + | } | |
| 921 | + | // Over another covering window too: it leaves fullscreen. | |
| 922 | + | r.client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true, External: true} | |
| 923 | + | set = receive(t, r.scenes) | |
| 924 | + | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 { | |
| 925 | + | t.Fatal("taskbar fullscreen over another:", got, focused) | |
| 926 | + | } | |
| 927 | + | // Leaving from the taskbar keeps the focus. | |
| 928 | + | r.client <- ports.WindowFullscreenRequest{ID: 2, External: true} | |
| 929 | + | set = receive(t, r.scenes) | |
| 930 | + | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) || focused != 2 { | |
| 931 | + | t.Fatal("taskbar leave:", got, focused) | |
| 932 | + | } | |
| 933 | + | } | |
| 934 | + | ||
| 935 | + | // A taskbar fullscreen on a window of a workspace off screen, on another | |
| 936 | + | // output, brings it on screen with the focus, as an activation. | |
| 937 | + | func TestExternalFullscreenOffScreen(t *testing.T) { | |
| 938 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right) | |
| 939 | + | r.mapWindow(t, 1) | |
| 940 | + | r.key(t, "Next", ports.ModAlt|ports.ModShift) // 1 to workspace 2, not followed | |
| 941 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) // focus DP-2 | |
| 942 | + | r.mapWindow(t, 2) | |
| 943 | + | r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true} | |
| 944 | + | set := receive(t, r.scenes) | |
| 945 | + | found := false | |
| 946 | + | for _, s := range set { | |
| 947 | + | for _, w := range s.Windows { | |
| 948 | + | if w.ID != 1 { | |
| 949 | + | continue | |
| 950 | + | } | |
| 951 | + | found = true | |
| 952 | + | if s.Output != "DP-1" || w.Hidden || !w.Fullscreen || !w.Focused { | |
| 953 | + | t.Fatalf("window 1 on %s: %+v", s.Output, w) | |
| 954 | + | } | |
| 955 | + | } | |
| 956 | + | } | |
| 957 | + | if !found { | |
| 958 | + | t.Fatal("window 1 in no scene") | |
| 959 | + | } | |
| 960 | + | } | |
| 961 | + | ||
| 962 | + | // In the overview, a taskbar fullscreen on a window of another workspace | |
| 963 | + | // selects its row: closing the overview lands on it, fullscreen. | |
| 964 | + | func TestExternalFullscreenInOverview(t *testing.T) { | |
| 965 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left) | |
| 966 | + | r.mapWindow(t, 1) | |
| 967 | + | r.key(t, "Next", ports.ModAlt|ports.ModShift) // 1 to workspace 2 | |
| 968 | + | r.mapWindow(t, 2) | |
| 969 | + | r.key(t, "o", ports.ModAlt) | |
| 970 | + | r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true} | |
| 971 | + | receive(t, r.scenes) | |
| 972 | + | set := r.key(t, "o", ports.ModAlt) | |
| 973 | + | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 { | |
| 974 | + | t.Fatal("after the overview:", got, focused) | |
| 975 | + | } | |
| 976 | + | } | |
| 977 | + | ||
| 978 | + | // focus-window-down from a fullscreen tile at the bottom of its column | |
| 979 | + | // goes to the next workspace and fullscreen stays. | |
| 980 | + | func TestFocusWindowDownKeepsFullscreen(t *testing.T) { | |
| 981 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left) | |
| 982 | + | r.mapWindow(t, 1) | |
| 983 | + | r.mapWindow(t, 2) | |
| 984 | + | r.key(t, "f", ports.ModAlt|ports.ModShift) | |
| 985 | + | set := r.key(t, "Down", ports.ModAlt) | |
| 986 | + | if got, _ := windowsOf(set, "DP-1"); len(got) != 0 { | |
| 987 | + | t.Fatal("still on the first workspace:", got) | |
| 988 | + | } | |
| 989 | + | set = r.key(t, "Up", ports.ModAlt) | |
| 990 | + | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 { | |
| 991 | + | t.Fatal("fullscreen lost:", got, focused) | |
| 992 | + | } | |
| 993 | + | } | |
| 994 | + | ||
| 995 | + | // A refused fullscreen request still gets a configure, unchanged: clients | |
| 996 | + | // wait for it (xdg-shell). | |
| 997 | + | func TestRefusedFullscreenConfigured(t *testing.T) { | |
| 998 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left) | |
| 999 | + | r.mapWindow(t, 1) | |
| 1000 | + | r.mapWindow(t, 2) | |
| 1001 | + | for len(r.commands) > 0 { | |
| 1002 | + | <-r.commands | |
| 1003 | + | } | |
| 1004 | + | r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true} | |
| 1005 | + | receive(t, r.scenes) | |
| 1006 | + | for len(r.commands) > 0 { | |
| 1007 | + | if v, ok := (<-r.commands).(ports.ConfigureWindow); ok && v.ID == 1 { | |
| 1008 | + | if v.Fullscreen { | |
| 1009 | + | t.Fatalf("refused request applied: %+v", v) | |
| 1010 | + | } | |
| 1011 | + | return | |
| 1012 | + | } | |
| 1013 | + | } | |
| 1014 | + | t.Fatal("no configure answered the request") | |
| 1015 | + | } | |
| 1016 | + | ||
| 907 | 1017 | // An activated window on another workspace comes on screen with focus. | |
| 908 | 1018 | func TestWindowActivateShowsAndFocuses(t *testing.T) { | |
| 909 | 1019 | cfg := config.Defaults() | |
| @@ -837,19 +837,22 @@ func TestFullscreenAtMapIgnored(t *testing.T) { | |||||
| 837 | } | 837 | } | ||
| 838 | } | 838 | } | ||
| 839 | now.Add(1) | 839 | now.Add(1) | ||
| 840 | + | // Only the focused window may cover the screen (ADR 011). | |||
| 841 | + | client <- ports.WindowUnmapped{ID: 3} | |||
| 842 | + | scene(t, scenes) | |||
| 840 | client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true} | 843 | client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true} | ||
| 841 | s = scene(t, scenes) | 844 | s = scene(t, scenes) | ||
| 842 | - | // Honoured: under fixed overflow it gets its own workspace, and the view | 845 | + | // Honoured, in place. |
| 843 | - | // stays here since it has no focus (ADR 011). | 846 | + | if !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 && w.Fullscreen && !w.Hidden }) { |
| 844 | - | if !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 && w.Hidden }) { | |||
| 845 | t.Fatalf("later fullscreen ignored: %+v", s.Windows) | 847 | t.Fatalf("later fullscreen ignored: %+v", s.Windows) | ||
| 846 | } | 848 | } | ||
| 847 | } | 849 | } | ||
| 848 | 850 | ||||
| 849 | - | // Under fixed overflow, a window opening over a fullscreen game tiles on | 851 | + | // Under fixed overflow, a window opening over a fullscreen game waits |
| 850 | - | // its home workspace: the game keeps the screen and the keyboard, and an | 852 | + | // hidden: the game keeps the screen and the keyboard. An activation (a |
| 851 | - | // activation (a user action in the new client) shows the window there. | 853 | + | // user action in the new client) leaves fullscreen and shows both tiles; |
| 852 | - | func TestFixedFullscreenArrivalTilesAtHome(t *testing.T) { | 854 | + | // a focus move back to the game and the bind make it fullscreen again. |
| 855 | + | func TestFixedFullscreenArrivalWaits(t *testing.T) { | |||
| 853 | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left) | 856 | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left) | ||
| 854 | r.mapWindow(t, 1) | 857 | r.mapWindow(t, 1) | ||
| 855 | r.key(t, "f", ports.ModAlt|ports.ModShift) | 858 | r.key(t, "f", ports.ModAlt|ports.ModShift) | ||
| @@ -862,7 +865,7 @@ func TestFixedFullscreenArrivalTilesAtHome(t *testing.T) { | |||||
| 862 | } | 865 | } | ||
| 863 | r.client <- ports.WindowActivate{ID: 2} | 866 | r.client <- ports.WindowActivate{ID: 2} | ||
| 864 | set = receive(t, r.scenes) | 867 | set = receive(t, r.scenes) | ||
| 865 | - | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 { | 868 | + | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) || focused != 2 { |
| 866 | t.Fatal("after activate:", got, focused) | 869 | t.Fatal("after activate:", got, focused) | ||
| 867 | } | 870 | } | ||
| 868 | for { | 871 | for { | ||
| @@ -870,10 +873,10 @@ func TestFixedFullscreenArrivalTilesAtHome(t *testing.T) { | |||||
| 870 | break | 873 | break | ||
| 871 | } | 874 | } | ||
| 872 | } | 875 | } | ||
| 873 | - | // The game still waits fullscreen one workspace down. | 876 | + | r.key(t, "Left", ports.ModAlt) |
| 874 | - | set = r.key(t, "Next", ports.ModAlt) | 877 | + | set = r.key(t, "f", ports.ModAlt|ports.ModShift) |
| 875 | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 { | 878 | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 { | ||
| 876 | - | t.Fatal("game workspace:", got, focused) | 879 | + | t.Fatal("game again:", got, focused) |
| 877 | } | 880 | } | ||
| 878 | } | 881 | } | ||
| 879 | 882 | ||||
| @@ -904,6 +907,113 @@ func TestExternalFullscreenAtMapApplies(t *testing.T) { | |||||
| 904 | } | 907 | } | ||
| 905 | } | 908 | } | ||
| 906 | 909 | ||||
| 910 | + | // Under fixed overflow, a taskbar's fullscreen on an unfocused window is | |||
| 911 | + | // the user's choice: the window takes the focus and covers the screen. | |||
| 912 | + | func TestExternalFullscreenFocusesFixed(t *testing.T) { | |||
| 913 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left) | |||
| 914 | + | r.mapWindow(t, 1) | |||
| 915 | + | r.mapWindow(t, 2) | |||
| 916 | + | r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true} | |||
| 917 | + | set := receive(t, r.scenes) | |||
| 918 | + | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 { | |||
| 919 | + | t.Fatal("taskbar fullscreen:", got, focused) | |||
| 920 | + | } | |||
| 921 | + | // Over another covering window too: it leaves fullscreen. | |||
| 922 | + | r.client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true, External: true} | |||
| 923 | + | set = receive(t, r.scenes) | |||
| 924 | + | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 { | |||
| 925 | + | t.Fatal("taskbar fullscreen over another:", got, focused) | |||
| 926 | + | } | |||
| 927 | + | // Leaving from the taskbar keeps the focus. | |||
| 928 | + | r.client <- ports.WindowFullscreenRequest{ID: 2, External: true} | |||
| 929 | + | set = receive(t, r.scenes) | |||
| 930 | + | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) || focused != 2 { | |||
| 931 | + | t.Fatal("taskbar leave:", got, focused) | |||
| 932 | + | } | |||
| 933 | + | } | |||
| 934 | + | ||||
| 935 | + | // A taskbar fullscreen on a window of a workspace off screen, on another | |||
| 936 | + | // output, brings it on screen with the focus, as an activation. | |||
| 937 | + | func TestExternalFullscreenOffScreen(t *testing.T) { | |||
| 938 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right) | |||
| 939 | + | r.mapWindow(t, 1) | |||
| 940 | + | r.key(t, "Next", ports.ModAlt|ports.ModShift) // 1 to workspace 2, not followed | |||
| 941 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) // focus DP-2 | |||
| 942 | + | r.mapWindow(t, 2) | |||
| 943 | + | r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true} | |||
| 944 | + | set := receive(t, r.scenes) | |||
| 945 | + | found := false | |||
| 946 | + | for _, s := range set { | |||
| 947 | + | for _, w := range s.Windows { | |||
| 948 | + | if w.ID != 1 { | |||
| 949 | + | continue | |||
| 950 | + | } | |||
| 951 | + | found = true | |||
| 952 | + | if s.Output != "DP-1" || w.Hidden || !w.Fullscreen || !w.Focused { | |||
| 953 | + | t.Fatalf("window 1 on %s: %+v", s.Output, w) | |||
| 954 | + | } | |||
| 955 | + | } | |||
| 956 | + | } | |||
| 957 | + | if !found { | |||
| 958 | + | t.Fatal("window 1 in no scene") | |||
| 959 | + | } | |||
| 960 | + | } | |||
| 961 | + | ||||
| 962 | + | // In the overview, a taskbar fullscreen on a window of another workspace | |||
| 963 | + | // selects its row: closing the overview lands on it, fullscreen. | |||
| 964 | + | func TestExternalFullscreenInOverview(t *testing.T) { | |||
| 965 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left) | |||
| 966 | + | r.mapWindow(t, 1) | |||
| 967 | + | r.key(t, "Next", ports.ModAlt|ports.ModShift) // 1 to workspace 2 | |||
| 968 | + | r.mapWindow(t, 2) | |||
| 969 | + | r.key(t, "o", ports.ModAlt) | |||
| 970 | + | r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true} | |||
| 971 | + | receive(t, r.scenes) | |||
| 972 | + | set := r.key(t, "o", ports.ModAlt) | |||
| 973 | + | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 { | |||
| 974 | + | t.Fatal("after the overview:", got, focused) | |||
| 975 | + | } | |||
| 976 | + | } | |||
| 977 | + | ||||
| 978 | + | // focus-window-down from a fullscreen tile at the bottom of its column | |||
| 979 | + | // goes to the next workspace and fullscreen stays. | |||
| 980 | + | func TestFocusWindowDownKeepsFullscreen(t *testing.T) { | |||
| 981 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left) | |||
| 982 | + | r.mapWindow(t, 1) | |||
| 983 | + | r.mapWindow(t, 2) | |||
| 984 | + | r.key(t, "f", ports.ModAlt|ports.ModShift) | |||
| 985 | + | set := r.key(t, "Down", ports.ModAlt) | |||
| 986 | + | if got, _ := windowsOf(set, "DP-1"); len(got) != 0 { | |||
| 987 | + | t.Fatal("still on the first workspace:", got) | |||
| 988 | + | } | |||
| 989 | + | set = r.key(t, "Up", ports.ModAlt) | |||
| 990 | + | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 { | |||
| 991 | + | t.Fatal("fullscreen lost:", got, focused) | |||
| 992 | + | } | |||
| 993 | + | } | |||
| 994 | + | ||||
| 995 | + | // A refused fullscreen request still gets a configure, unchanged: clients | |||
| 996 | + | // wait for it (xdg-shell). | |||
| 997 | + | func TestRefusedFullscreenConfigured(t *testing.T) { | |||
| 998 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left) | |||
| 999 | + | r.mapWindow(t, 1) | |||
| 1000 | + | r.mapWindow(t, 2) | |||
| 1001 | + | for len(r.commands) > 0 { | |||
| 1002 | + | <-r.commands | |||
| 1003 | + | } | |||
| 1004 | + | r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true} | |||
| 1005 | + | receive(t, r.scenes) | |||
| 1006 | + | for len(r.commands) > 0 { | |||
| 1007 | + | if v, ok := (<-r.commands).(ports.ConfigureWindow); ok && v.ID == 1 { | |||
| 1008 | + | if v.Fullscreen { | |||
| 1009 | + | t.Fatalf("refused request applied: %+v", v) | |||
| 1010 | + | } | |||
| 1011 | + | return | |||
| 1012 | + | } | |||
| 1013 | + | } | |||
| 1014 | + | t.Fatal("no configure answered the request") | |||
| 1015 | + | } | |||
| 1016 | + | ||||
| 907 | // An activated window on another workspace comes on screen with focus. | 1017 | // An activated window on another workspace comes on screen with focus. | ||
| 908 | func TestWindowActivateShowsAndFocuses(t *testing.T) { | 1018 | func TestWindowActivateShowsAndFocuses(t *testing.T) { | ||
| 909 | cfg := config.Defaults() | 1019 | cfg := config.Defaults() | ||
M internal/core/expel.go +1 -1
| @@ -98,7 +98,7 @@ func (w *Workspace) expelTo(id WindowID, dir int) { | |||
| 98 | 98 | if dir < 0 { | |
| 99 | 99 | at = len(w.Columns) | |
| 100 | 100 | } | |
| 101 | - | if !w.full() || w.origin != nil { | |
| 101 | + | if !w.full() { | |
| 102 | 102 | w.receive(Column{Windows: []WindowID{id}}, at) | |
| 103 | 103 | return | |
| 104 | 104 | } | |
| @@ -98,7 +98,7 @@ func (w *Workspace) expelTo(id WindowID, dir int) { | |||||
| 98 | if dir < 0 { | 98 | if dir < 0 { | ||
| 99 | at = len(w.Columns) | 99 | at = len(w.Columns) | ||
| 100 | } | 100 | } | ||
| 101 | - | if !w.full() || w.origin != nil { | 101 | + | if !w.full() { |
| 102 | w.receive(Column{Windows: []WindowID{id}}, at) | 102 | w.receive(Column{Windows: []WindowID{id}}, at) | ||
| 103 | return | 103 | return | ||
| 104 | } | 104 | } | ||
M internal/core/float_order_test.go +11 -19
| @@ -91,18 +91,11 @@ func TestDemotedFloatFullscreenRoundTrip(t *testing.T) { | |||
| 91 | 91 | w.FocusID(1) | |
| 92 | 92 | wantOrder(t, w, 2, 1) | |
| 93 | 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) | |
| 94 | + | // Fixed overflow refuses an unfocused window's fullscreen (ADR 011). | |
| 95 | + | if p := placement(w, 2); p.Fullscreen != (overflow == OverflowScroll) { | |
| 96 | + | t.Fatalf("%v: fullscreen %+v", overflow, p) | |
| 105 | 97 | } | |
| 98 | + | m.SetFullscreen(2, false) | |
| 106 | 99 | wantOrder(t, w, 2, 1) | |
| 107 | 100 | if id, _ := w.Focused(); id != 1 { | |
| 108 | 101 | t.Fatalf("focus %d", id) | |
| @@ -216,8 +209,7 @@ func TestFocusWindowWithoutDemotedFloatChangesWorkspace(t *testing.T) { | |||
| 216 | 209 | } | |
| 217 | 210 | ||
| 218 | 211 | // 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. | |
| 212 | + | // float; from a fullscreen float it leaves fullscreen for the tile below. | |
| 221 | 213 | func TestFocusWindowUpStashAndPinned(t *testing.T) { | |
| 222 | 214 | m := monitor() | |
| 223 | 215 | m.SetBorder(2) | |
| @@ -239,12 +231,12 @@ func TestFocusWindowUpStashAndPinned(t *testing.T) { | |||
| 239 | 231 | m.SetOverflow(OverflowFixed) | |
| 240 | 232 | m.AddWindow(1) | |
| 241 | 233 | m.AddFloating(2, 50, 40) | |
| 242 | - | m.SetFullscreen(2, true) // own workspace below | |
| 243 | - | if m.Active != 1 || !m.Current().pinned() { | |
| 234 | + | m.SetFullscreen(2, true) | |
| 235 | + | if m.Active != 0 || !m.Current().pinned() { | |
| 244 | 236 | t.Fatalf("setup: active %d", m.Active) | |
| 245 | 237 | } | |
| 246 | 238 | m.Apply(ActionFocusWindowUp) | |
| 247 | - | if id, _ := m.Current().Focused(); id != 1 || m.Active != 0 { | |
| 239 | + | if id, _ := m.Current().Focused(); id != 1 || m.Active != 0 || m.Current().fullscreen != 0 { | |
| 248 | 240 | t.Fatalf("pinned up focused %d on %d", id, m.Active) | |
| 249 | 241 | } | |
| 250 | 242 | } | |
| @@ -263,8 +255,8 @@ func TestCoveringFloatAloneKeepsFocus(t *testing.T) { | |||
| 263 | 255 | wantOrder(t, w, 3) | |
| 264 | 256 | } | |
| 265 | 257 | ||
| 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. | |
| 258 | + | // A tile leaving fullscreen with the focus comes back in front of a | |
| 259 | + | // covering float mapped meanwhile. | |
| 268 | 260 | func TestFullscreenReturnRaisesColumns(t *testing.T) { | |
| 269 | 261 | for _, stacked := range []bool{false, true} { | |
| 270 | 262 | m := monitor() | |
| @@ -277,7 +269,7 @@ func TestFullscreenReturnRaisesColumns(t *testing.T) { | |||
| 277 | 269 | } | |
| 278 | 270 | w := m.Current() | |
| 279 | 271 | w.FocusID(2) | |
| 280 | - | m.ToggleFullscreen() // 2 to its own workspace | |
| 272 | + | m.ToggleFullscreen() | |
| 281 | 273 | w.AddFloating(3, 100, 80) | |
| 282 | 274 | m.ToggleFullscreen() // back, focused | |
| 283 | 275 | if id, _ := w.Focused(); id != 2 { | |
| @@ -91,18 +91,11 @@ func TestDemotedFloatFullscreenRoundTrip(t *testing.T) { | |||||
| 91 | w.FocusID(1) | 91 | w.FocusID(1) | ||
| 92 | wantOrder(t, w, 2, 1) | 92 | wantOrder(t, w, 2, 1) | ||
| 93 | m.SetFullscreen(2, true) | 93 | m.SetFullscreen(2, true) | ||
| 94 | - | if overflow == OverflowFixed { | 94 | + | // Fixed overflow refuses an unfocused window's fullscreen (ADR 011). |
| 95 | - | fs := m.Workspaces[1] | 95 | + | if p := placement(w, 2); p.Fullscreen != (overflow == OverflowScroll) { |
| 96 | - | if fs.origin != w || fs.back.float == nil || !fs.back.float.below { | 96 | + | t.Fatalf("%v: fullscreen %+v", overflow, p) |
| 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 | } | 97 | } | ||
| 98 | + | m.SetFullscreen(2, false) | |||
| 106 | wantOrder(t, w, 2, 1) | 99 | wantOrder(t, w, 2, 1) | ||
| 107 | if id, _ := w.Focused(); id != 1 { | 100 | if id, _ := w.Focused(); id != 1 { | ||
| 108 | t.Fatalf("focus %d", id) | 101 | t.Fatalf("focus %d", id) | ||
| @@ -216,8 +209,7 @@ func TestFocusWindowWithoutDemotedFloatChangesWorkspace(t *testing.T) { | |||||
| 216 | } | 209 | } | ||
| 217 | 210 | ||||
| 218 | // Up from a shown stash stays in the stash, even with a demoted covering | 211 | // 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, | 212 | + | // float; from a fullscreen float it leaves fullscreen for the tile below. |
| 220 | - | // as without covering floats. | |||
| 221 | func TestFocusWindowUpStashAndPinned(t *testing.T) { | 213 | func TestFocusWindowUpStashAndPinned(t *testing.T) { | ||
| 222 | m := monitor() | 214 | m := monitor() | ||
| 223 | m.SetBorder(2) | 215 | m.SetBorder(2) | ||
| @@ -239,12 +231,12 @@ func TestFocusWindowUpStashAndPinned(t *testing.T) { | |||||
| 239 | m.SetOverflow(OverflowFixed) | 231 | m.SetOverflow(OverflowFixed) | ||
| 240 | m.AddWindow(1) | 232 | m.AddWindow(1) | ||
| 241 | m.AddFloating(2, 50, 40) | 233 | m.AddFloating(2, 50, 40) | ||
| 242 | - | m.SetFullscreen(2, true) // own workspace below | 234 | + | m.SetFullscreen(2, true) |
| 243 | - | if m.Active != 1 || !m.Current().pinned() { | 235 | + | if m.Active != 0 || !m.Current().pinned() { |
| 244 | t.Fatalf("setup: active %d", m.Active) | 236 | t.Fatalf("setup: active %d", m.Active) | ||
| 245 | } | 237 | } | ||
| 246 | m.Apply(ActionFocusWindowUp) | 238 | m.Apply(ActionFocusWindowUp) | ||
| 247 | - | if id, _ := m.Current().Focused(); id != 1 || m.Active != 0 { | 239 | + | if id, _ := m.Current().Focused(); id != 1 || m.Active != 0 || m.Current().fullscreen != 0 { |
| 248 | t.Fatalf("pinned up focused %d on %d", id, m.Active) | 240 | t.Fatalf("pinned up focused %d on %d", id, m.Active) | ||
| 249 | } | 241 | } | ||
| 250 | } | 242 | } | ||
| @@ -263,8 +255,8 @@ func TestCoveringFloatAloneKeepsFocus(t *testing.T) { | |||||
| 263 | wantOrder(t, w, 3) | 255 | wantOrder(t, w, 3) | ||
| 264 | } | 256 | } | ||
| 265 | 257 | ||||
| 266 | - | // A tile returning from its own fullscreen workspace with the focus comes | 258 | + | // A tile leaving fullscreen with the focus comes back in front of a |
| 267 | - | // back in front of a covering float mapped in its origin meanwhile. | 259 | + | // covering float mapped meanwhile. |
| 268 | func TestFullscreenReturnRaisesColumns(t *testing.T) { | 260 | func TestFullscreenReturnRaisesColumns(t *testing.T) { | ||
| 269 | for _, stacked := range []bool{false, true} { | 261 | for _, stacked := range []bool{false, true} { | ||
| 270 | m := monitor() | 262 | m := monitor() | ||
| @@ -277,7 +269,7 @@ func TestFullscreenReturnRaisesColumns(t *testing.T) { | |||||
| 277 | } | 269 | } | ||
| 278 | w := m.Current() | 270 | w := m.Current() | ||
| 279 | w.FocusID(2) | 271 | w.FocusID(2) | ||
| 280 | - | m.ToggleFullscreen() // 2 to its own workspace | 272 | + | m.ToggleFullscreen() |
| 281 | w.AddFloating(3, 100, 80) | 273 | w.AddFloating(3, 100, 80) | ||
| 282 | m.ToggleFullscreen() // back, focused | 274 | m.ToggleFullscreen() // back, focused | ||
| 283 | if id, _ := w.Focused(); id != 2 { | 275 | if id, _ := w.Focused(); id != 2 { | ||
M internal/core/float_review_regression_test.go +2 -3
| @@ -32,10 +32,9 @@ func TestDemotedFloatFullscreenUserReturn(t *testing.T) { | |||
| 32 | 32 | m.AddWindow(1) | |
| 33 | 33 | m.AddFloating(2, 100, 80) | |
| 34 | 34 | w := m.Current() | |
| 35 | - | w.FocusID(1) | |
| 35 | + | w.FocusID(2) | |
| 36 | 36 | m.SetFullscreen(2, true) | |
| 37 | - | m.Focus(1) | |
| 38 | - | m.Current().FocusID(2) | |
| 37 | + | w.focusCover() | |
| 39 | 38 | m.SetFullscreen(2, false) | |
| 40 | 39 | if id, _ := w.Focused(); id != 2 || !w.floatFocus || w.Floats[0].below { | |
| 41 | 40 | t.Fatalf("returned focus %d floats %+v", id, w.Floats) | |
| @@ -32,10 +32,9 @@ func TestDemotedFloatFullscreenUserReturn(t *testing.T) { | |||||
| 32 | m.AddWindow(1) | 32 | m.AddWindow(1) | ||
| 33 | m.AddFloating(2, 100, 80) | 33 | m.AddFloating(2, 100, 80) | ||
| 34 | w := m.Current() | 34 | w := m.Current() | ||
| 35 | - | w.FocusID(1) | 35 | + | w.FocusID(2) |
| 36 | m.SetFullscreen(2, true) | 36 | m.SetFullscreen(2, true) | ||
| 37 | - | m.Focus(1) | 37 | + | w.focusCover() |
| 38 | - | m.Current().FocusID(2) | |||
| 39 | m.SetFullscreen(2, false) | 38 | m.SetFullscreen(2, false) | ||
| 40 | if id, _ := w.Focused(); id != 2 || !w.floatFocus || w.Floats[0].below { | 39 | if id, _ := w.Focused(); id != 2 || !w.floatFocus || w.Floats[0].below { | ||
| 41 | t.Fatalf("returned focus %d floats %+v", id, w.Floats) | 40 | t.Fatalf("returned focus %d floats %+v", id, w.Floats) | ||
M internal/core/floating_test.go +38 -7
| @@ -228,16 +228,13 @@ func TestFullscreenFloatNewWindowNoColumns(t *testing.T) { | |||
| 228 | 228 | } | |
| 229 | 229 | ||
| 230 | 230 | // Under a covering fullscreen float, or any fixed-overflow fullscreen, | |
| 231 | - | // focus moves inside the workspace are off and binds act on the window on | |
| 232 | - | // screen, never on a column it hides. | |
| 231 | + | // binds act on the window on screen, never on a column it hides. | |
| 233 | 232 | func TestFullscreenPinsFocus(t *testing.T) { | |
| 234 | 233 | w := workspace() | |
| 235 | 234 | w.AddWindow(1) | |
| 236 | 235 | w.AddWindow(2) | |
| 237 | 236 | w.AddFloating(3, 20, 10) | |
| 238 | 237 | w.SetFullscreen(3, true) | |
| 239 | - | w.FocusColumn(-1) | |
| 240 | - | w.FocusWindow(1) | |
| 241 | 238 | w.focusCover() | |
| 242 | 239 | w.Apply(ActionMoveColumnLeft) | |
| 243 | 240 | w.Apply(ActionCycleColumnWidth) | |
| @@ -247,14 +244,48 @@ func TestFullscreenPinsFocus(t *testing.T) { | |||
| 247 | 244 | if e := w.Apply(ActionCloseWindow); e.Close != 3 { | |
| 248 | 245 | t.Fatalf("close %d, want the fullscreen window", e.Close) | |
| 249 | 246 | } | |
| 250 | - | // A fixed-overflow fullscreen tile pins the focus too. | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // A focus move from a covering fullscreen window leaves fullscreen for the | |
| 250 | + | // window on that side; with none there, fullscreen stays. | |
| 251 | + | func TestFocusMoveLeavesFullscreen(t *testing.T) { | |
| 252 | + | w := workspace() | |
| 253 | + | w.AddWindow(1) | |
| 254 | + | w.AddWindow(2) | |
| 255 | + | w.AddFloating(3, 20, 10) | |
| 256 | + | w.SetFullscreen(3, true) | |
| 257 | + | w.FocusColumn(-1) | |
| 258 | + | if id, _ := w.Focused(); id == 3 || w.fullscreen != 0 { | |
| 259 | + | t.Fatalf("float: focused %d fullscreen %d", id, w.fullscreen) | |
| 260 | + | } | |
| 251 | 261 | f := workspace() | |
| 252 | 262 | f.Overflow = OverflowFixed | |
| 253 | 263 | f.AddWindow(1) | |
| 254 | 264 | f.AddWindow(2) | |
| 255 | 265 | f.SetFullscreen(2, true) | |
| 256 | - | if f.FocusColumn(-1); f.Focus != 1 { | |
| 257 | - | t.Fatalf("fixed: focus moved to column %d", f.Focus) | |
| 266 | + | if f.FocusColumn(1); f.fullscreen != 2 || f.Focus != 1 { | |
| 267 | + | t.Fatalf("fixed edge: focus %d fullscreen %d", f.Focus, f.fullscreen) | |
| 268 | + | } | |
| 269 | + | if f.FocusColumn(-1); f.fullscreen != 0 || f.Focus != 0 { | |
| 270 | + | t.Fatalf("fixed: focus %d fullscreen %d", f.Focus, f.fullscreen) | |
| 271 | + | } | |
| 272 | + | // Side by side: nothing above, so up keeps fullscreen and reports | |
| 273 | + | // the edge (the monitor goes to the workspace above). | |
| 274 | + | f.Focus = 1 | |
| 275 | + | f.SetFullscreen(2, true) | |
| 276 | + | if f.FocusWindow(-1) || f.fullscreen != 2 || f.Focus != 1 { | |
| 277 | + | t.Fatalf("fixed up: focus %d fullscreen %d", f.Focus, f.fullscreen) | |
| 278 | + | } | |
| 279 | + | // Stacked: up reaches the window above in the column. | |
| 280 | + | f.SetFullscreen(2, false) | |
| 281 | + | f.stackWindow(2, 1) | |
| 282 | + | f.FocusID(2) | |
| 283 | + | f.SetFullscreen(2, true) | |
| 284 | + | if !f.FocusWindow(-1) || f.fullscreen != 0 { | |
| 285 | + | t.Fatalf("stacked up: fullscreen %d", f.fullscreen) | |
| 286 | + | } | |
| 287 | + | if id, _ := f.Focused(); id != 1 { | |
| 288 | + | t.Fatalf("stacked up: focused %d", id) | |
| 258 | 289 | } | |
| 259 | 290 | // Scroll overflow: moving off a fullscreen column still works; it no | |
| 260 | 291 | // longer covers. | |
| @@ -228,16 +228,13 @@ func TestFullscreenFloatNewWindowNoColumns(t *testing.T) { | |||||
| 228 | } | 228 | } | ||
| 229 | 229 | ||||
| 230 | // Under a covering fullscreen float, or any fixed-overflow fullscreen, | 230 | // Under a covering fullscreen float, or any fixed-overflow fullscreen, | ||
| 231 | - | // focus moves inside the workspace are off and binds act on the window on | 231 | + | // binds act on the window on screen, never on a column it hides. |
| 232 | - | // screen, never on a column it hides. | |||
| 233 | func TestFullscreenPinsFocus(t *testing.T) { | 232 | func TestFullscreenPinsFocus(t *testing.T) { | ||
| 234 | w := workspace() | 233 | w := workspace() | ||
| 235 | w.AddWindow(1) | 234 | w.AddWindow(1) | ||
| 236 | w.AddWindow(2) | 235 | w.AddWindow(2) | ||
| 237 | w.AddFloating(3, 20, 10) | 236 | w.AddFloating(3, 20, 10) | ||
| 238 | w.SetFullscreen(3, true) | 237 | w.SetFullscreen(3, true) | ||
| 239 | - | w.FocusColumn(-1) | |||
| 240 | - | w.FocusWindow(1) | |||
| 241 | w.focusCover() | 238 | w.focusCover() | ||
| 242 | w.Apply(ActionMoveColumnLeft) | 239 | w.Apply(ActionMoveColumnLeft) | ||
| 243 | w.Apply(ActionCycleColumnWidth) | 240 | w.Apply(ActionCycleColumnWidth) | ||
| @@ -247,14 +244,48 @@ func TestFullscreenPinsFocus(t *testing.T) { | |||||
| 247 | if e := w.Apply(ActionCloseWindow); e.Close != 3 { | 244 | if e := w.Apply(ActionCloseWindow); e.Close != 3 { | ||
| 248 | t.Fatalf("close %d, want the fullscreen window", e.Close) | 245 | t.Fatalf("close %d, want the fullscreen window", e.Close) | ||
| 249 | } | 246 | } | ||
| 250 | - | // A fixed-overflow fullscreen tile pins the focus too. | 247 | + | } |
| 248 | + | ||||
| 249 | + | // A focus move from a covering fullscreen window leaves fullscreen for the | |||
| 250 | + | // window on that side; with none there, fullscreen stays. | |||
| 251 | + | func TestFocusMoveLeavesFullscreen(t *testing.T) { | |||
| 252 | + | w := workspace() | |||
| 253 | + | w.AddWindow(1) | |||
| 254 | + | w.AddWindow(2) | |||
| 255 | + | w.AddFloating(3, 20, 10) | |||
| 256 | + | w.SetFullscreen(3, true) | |||
| 257 | + | w.FocusColumn(-1) | |||
| 258 | + | if id, _ := w.Focused(); id == 3 || w.fullscreen != 0 { | |||
| 259 | + | t.Fatalf("float: focused %d fullscreen %d", id, w.fullscreen) | |||
| 260 | + | } | |||
| 251 | f := workspace() | 261 | f := workspace() | ||
| 252 | f.Overflow = OverflowFixed | 262 | f.Overflow = OverflowFixed | ||
| 253 | f.AddWindow(1) | 263 | f.AddWindow(1) | ||
| 254 | f.AddWindow(2) | 264 | f.AddWindow(2) | ||
| 255 | f.SetFullscreen(2, true) | 265 | f.SetFullscreen(2, true) | ||
| 256 | - | if f.FocusColumn(-1); f.Focus != 1 { | 266 | + | if f.FocusColumn(1); f.fullscreen != 2 || f.Focus != 1 { |
| 257 | - | t.Fatalf("fixed: focus moved to column %d", f.Focus) | 267 | + | t.Fatalf("fixed edge: focus %d fullscreen %d", f.Focus, f.fullscreen) |
| 268 | + | } | |||
| 269 | + | if f.FocusColumn(-1); f.fullscreen != 0 || f.Focus != 0 { | |||
| 270 | + | t.Fatalf("fixed: focus %d fullscreen %d", f.Focus, f.fullscreen) | |||
| 271 | + | } | |||
| 272 | + | // Side by side: nothing above, so up keeps fullscreen and reports | |||
| 273 | + | // the edge (the monitor goes to the workspace above). | |||
| 274 | + | f.Focus = 1 | |||
| 275 | + | f.SetFullscreen(2, true) | |||
| 276 | + | if f.FocusWindow(-1) || f.fullscreen != 2 || f.Focus != 1 { | |||
| 277 | + | t.Fatalf("fixed up: focus %d fullscreen %d", f.Focus, f.fullscreen) | |||
| 278 | + | } | |||
| 279 | + | // Stacked: up reaches the window above in the column. | |||
| 280 | + | f.SetFullscreen(2, false) | |||
| 281 | + | f.stackWindow(2, 1) | |||
| 282 | + | f.FocusID(2) | |||
| 283 | + | f.SetFullscreen(2, true) | |||
| 284 | + | if !f.FocusWindow(-1) || f.fullscreen != 0 { | |||
| 285 | + | t.Fatalf("stacked up: fullscreen %d", f.fullscreen) | |||
| 286 | + | } | |||
| 287 | + | if id, _ := f.Focused(); id != 1 { | |||
| 288 | + | t.Fatalf("stacked up: focused %d", id) | |||
| 258 | } | 289 | } | ||
| 259 | // Scroll overflow: moving off a fullscreen column still works; it no | 290 | // Scroll overflow: moving off a fullscreen column still works; it no | ||
| 260 | // longer covers. | 291 | // longer covers. | ||
M internal/core/gesture.go +2 -2
| @@ -323,7 +323,7 @@ func (c *Core) animate(now time.Time) { | |||
| 323 | 323 | m.stopSwitch() | |
| 324 | 324 | } | |
| 325 | 325 | } | |
| 326 | - | for _, w := range m.all() { | |
| 326 | + | for w := range m.all() { | |
| 327 | 327 | if w.motion != nil { | |
| 328 | 328 | v, done := w.motion.at(now) | |
| 329 | 329 | w.shift = v | |
| @@ -355,7 +355,7 @@ func (m *Monitor) springing() bool { | |||
| 355 | 355 | if m.switchMotion != nil { | |
| 356 | 356 | return true | |
| 357 | 357 | } | |
| 358 | - | for _, w := range m.all() { | |
| 358 | + | for w := range m.all() { | |
| 359 | 359 | if w.motion != nil { | |
| 360 | 360 | return true | |
| 361 | 361 | } | |
| @@ -323,7 +323,7 @@ func (c *Core) animate(now time.Time) { | |||||
| 323 | m.stopSwitch() | 323 | m.stopSwitch() | ||
| 324 | } | 324 | } | ||
| 325 | } | 325 | } | ||
| 326 | - | for _, w := range m.all() { | 326 | + | for w := range m.all() { |
| 327 | if w.motion != nil { | 327 | if w.motion != nil { | ||
| 328 | v, done := w.motion.at(now) | 328 | v, done := w.motion.at(now) | ||
| 329 | w.shift = v | 329 | w.shift = v | ||
| @@ -355,7 +355,7 @@ func (m *Monitor) springing() bool { | |||||
| 355 | if m.switchMotion != nil { | 355 | if m.switchMotion != nil { | ||
| 356 | return true | 356 | return true | ||
| 357 | } | 357 | } | ||
| 358 | - | for _, w := range m.all() { | 358 | + | for w := range m.all() { |
| 359 | if w.motion != nil { | 359 | if w.motion != nil { | ||
| 360 | return true | 360 | return true | ||
| 361 | } | 361 | } | ||
M internal/core/layout.go +84 -44
| @@ -183,19 +183,11 @@ type Workspace struct { | |||
| 183 | 183 | homePos int | |
| 184 | 184 | // termAt is when core last spawned a terminal for this workspace. | |
| 185 | 185 | termAt time.Time | |
| 186 | - | // origin is set on the workspace a fixed-overflow fullscreen window | |
| 187 | - | // moved to; back is where it returns there (Monitor.fullscreenHome). | |
| 188 | - | origin *Workspace | |
| 189 | - | back origPlace | |
| 190 | 186 | } | |
| 191 | 187 | ||
| 192 | - | // origPlace remembers where a window was: its column, its row in a stacked | |
| 193 | - | // column, the column's width and slot, or its floating size. | |
| 188 | + | // origPlace remembers where a stashed window was: its column, its row in a | |
| 189 | + | // stacked column, the column's width and slot. | |
| 194 | 190 | type origPlace struct { | |
| 195 | - | // id is the window that went fullscreen; tiled lists the windows that | |
| 196 | - | // joined its workspace tiled, floating there until they go home. | |
| 197 | - | id WindowID | |
| 198 | - | tiled []WindowID | |
| 199 | 191 | col, row, slot int | |
| 200 | 192 | // stacked holds the windows left in the column: the window returns to | |
| 201 | 193 | // its row in the column that still holds one of them. | |
| @@ -204,11 +196,6 @@ type origPlace struct { | |||
| 204 | 196 | expanded bool | |
| 205 | 197 | // fullWidth is the column's maximize-column state. | |
| 206 | 198 | fullWidth bool | |
| 207 | - | float *Float | |
| 208 | - | // floatAt is the original native float position on fullscreen entry. | |
| 209 | - | floatAt int | |
| 210 | - | // stash is set when float came from the stash, at index col. | |
| 211 | - | stash bool | |
| 212 | 199 | } | |
| 213 | 200 | ||
| 214 | 201 | // Float is a floating window and its client size, logical. | |
| @@ -525,10 +512,19 @@ func (w *Workspace) FocusID(id WindowID) bool { | |||
| 525 | 512 | } | |
| 526 | 513 | return false | |
| 527 | 514 | } | |
| 528 | - | func (w *Workspace) FocusColumn(dir int) { | |
| 515 | + | ||
| 516 | + | // FocusColumn moves focus to the column on that side. From a covering | |
| 517 | + | // fullscreen window it leaves fullscreen for the window there (leaveCover); | |
| 518 | + | // false means it stays, with no window on that side. | |
| 519 | + | func (w *Workspace) FocusColumn(dir int) bool { | |
| 529 | 520 | if w.pinned() { | |
| 530 | - | return | |
| 521 | + | return w.leaveCover(func() { w.focusColumn(dir) }) | |
| 531 | 522 | } | |
| 523 | + | w.focusColumn(dir) | |
| 524 | + | return true | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | func (w *Workspace) focusColumn(dir int) { | |
| 532 | 528 | if w.floatFocus { | |
| 533 | 529 | // The first move leaves the native float for the stash or columns, | |
| 534 | 530 | // if there is one: otherwise the float keeps the focus. | |
| @@ -577,11 +573,16 @@ func (w *Workspace) onScreenFocus() bool { | |||
| 577 | 573 | ||
| 578 | 574 | // FocusWindow moves focus inside the column, then to a fixed-overflow column | |
| 579 | 575 | // above or below on screen. Up at the top raises the topmost demoted covering | |
| 580 | - | // float; false means no window is available in that direction. | |
| 576 | + | // float; false means no window is available in that direction. From a | |
| 577 | + | // covering fullscreen window it leaves fullscreen for that window. | |
| 581 | 578 | func (w *Workspace) FocusWindow(dir int) bool { | |
| 582 | 579 | if w.pinned() { | |
| 583 | - | return false | |
| 580 | + | return w.leaveCover(func() { w.focusWindow(dir) }) | |
| 584 | 581 | } | |
| 582 | + | return w.focusWindow(dir) | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | func (w *Workspace) focusWindow(dir int) bool { | |
| 585 | 586 | if w.floatFocus { | |
| 586 | 587 | return w.leaveFloat() | |
| 587 | 588 | } | |
| @@ -787,10 +788,23 @@ func (w *Workspace) ToggleFullscreen() { | |||
| 787 | 788 | return | |
| 788 | 789 | } | |
| 789 | 790 | if w.fullscreen == id { | |
| 791 | + | // The user was on it: it keeps the focus, in front of floats that | |
| 792 | + | // mapped meanwhile. Cleared first, so that floats it covers drop | |
| 793 | + | // below the columns. | |
| 790 | 794 | w.fullscreen = 0 | |
| 791 | - | } else { | |
| 792 | - | w.fullscreen = id | |
| 795 | + | w.FocusID(id) | |
| 796 | + | w.endFullscreen() | |
| 797 | + | return | |
| 793 | 798 | } | |
| 799 | + | w.fullscreen = id | |
| 800 | + | w.scroll() | |
| 801 | + | } | |
| 802 | + | ||
| 803 | + | // endFullscreen ends the fullscreen: floats it kept demoted are promoted | |
| 804 | + | // again and the view follows the focus. | |
| 805 | + | func (w *Workspace) endFullscreen() { | |
| 806 | + | w.fullscreen = 0 | |
| 807 | + | w.reconcileFloats() | |
| 794 | 808 | w.scroll() | |
| 795 | 809 | } | |
| 796 | 810 | ||
| @@ -801,11 +815,12 @@ func (w *Workspace) Activate(id WindowID) { | |||
| 801 | 815 | // A hidden stashed window must be seen: show the stash with it, | |
| 802 | 816 | // selected. Another window's fullscreen leaves first, so its own | |
| 803 | 817 | // pending fullscreen comes back. | |
| 804 | - | w.fullscreen, w.stashAt = 0, i | |
| 818 | + | w.endFullscreen() | |
| 819 | + | w.stashAt = i | |
| 805 | 820 | w.showStash() | |
| 806 | 821 | } | |
| 807 | 822 | if w.fullscreen != 0 && w.fullscreen != id { | |
| 808 | - | w.fullscreen = 0 | |
| 823 | + | w.endFullscreen() | |
| 809 | 824 | } | |
| 810 | 825 | w.FocusID(id) | |
| 811 | 826 | } | |
| @@ -813,6 +828,9 @@ func (w *Workspace) Activate(id WindowID) { | |||
| 813 | 828 | // SetFullscreen applies a client request. It never moves focus: client | |
| 814 | 829 | // requests are automatic events (ADR 011 golden rule). | |
| 815 | 830 | func (w *Workspace) SetFullscreen(id WindowID, on bool) { | |
| 831 | + | if on && !w.mayCover(id) { | |
| 832 | + | return | |
| 833 | + | } | |
| 816 | 834 | if w.isFloat(id) { | |
| 817 | 835 | if w.stashHidden && w.stashIndex(id) >= 0 { | |
| 818 | 836 | // Applied when the stash is shown again. | |
| @@ -823,26 +841,19 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) { | |||
| 823 | 841 | } | |
| 824 | 842 | return | |
| 825 | 843 | } | |
| 826 | - | if on { | |
| 827 | - | w.fullscreen = id | |
| 828 | - | } else if w.fullscreen == id { | |
| 829 | - | w.fullscreen = 0 | |
| 830 | - | w.reconcileFloats() | |
| 831 | - | } | |
| 844 | + | } | |
| 845 | + | if !w.has(id) { | |
| 832 | 846 | return | |
| 833 | 847 | } | |
| 834 | - | for i := range w.Columns { | |
| 835 | - | for _, v := range w.Columns[i].Windows { | |
| 836 | - | if v == id { | |
| 837 | - | if on { | |
| 838 | - | w.fullscreen = id | |
| 839 | - | } else if w.fullscreen == id { | |
| 840 | - | w.fullscreen = 0 | |
| 841 | - | } | |
| 842 | - | w.scroll() | |
| 843 | - | return | |
| 844 | - | } | |
| 848 | + | if !on { | |
| 849 | + | if w.fullscreen == id { | |
| 850 | + | w.endFullscreen() | |
| 845 | 851 | } | |
| 852 | + | return | |
| 853 | + | } | |
| 854 | + | w.fullscreen = id | |
| 855 | + | if !w.isFloat(id) { | |
| 856 | + | w.scroll() | |
| 846 | 857 | } | |
| 847 | 858 | } | |
| 848 | 859 | ||
| @@ -916,15 +927,44 @@ func (w *Workspace) overviewArea() Rect { return w.reserved } | |||
| 916 | 927 | ||
| 917 | 928 | func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) } | |
| 918 | 929 | ||
| 919 | - | // pinned reports whether focus moves inside the workspace are off: the | |
| 920 | - | // covering fullscreen window hides every target. Only in scroll overflow | |
| 921 | - | // does moving to a tiled neighbor scroll it off and show the target. | |
| 922 | - | // Moves to another workspace or monitor still work. | |
| 930 | + | // mayCover reports whether a client may make id fullscreen. In fixed | |
| 931 | + | // overflow it would cover the output at once and take the keyboard from | |
| 932 | + | // the focused window, so only the focused window may; the request of | |
| 933 | + | // another is refused and the user can still toggle-fullscreen it. A | |
| 934 | + | // hidden stash's request waits for the show (showStash). | |
| 935 | + | func (w *Workspace) mayCover(id WindowID) bool { | |
| 936 | + | if w.Overflow != OverflowFixed || w.stashHidden && w.stashIndex(id) >= 0 { | |
| 937 | + | return true | |
| 938 | + | } | |
| 939 | + | focused, ok := w.Focused() | |
| 940 | + | return ok && focused == id | |
| 941 | + | } | |
| 942 | + | ||
| 943 | + | // pinned reports whether the covering fullscreen window hides every other | |
| 944 | + | // window: focus moves leave it (leaveCover). Only in scroll overflow does | |
| 945 | + | // moving to a tiled neighbor scroll it off and show the target instead. | |
| 923 | 946 | func (w *Workspace) pinned() bool { | |
| 924 | 947 | c := w.cover() | |
| 925 | 948 | return c != 0 && (w.Overflow == OverflowFixed || w.isFloat(c)) | |
| 926 | 949 | } | |
| 927 | 950 | ||
| 951 | + | // leaveCover runs a focus move from the covering fullscreen window as if it | |
| 952 | + | // were not fullscreen. When the move reaches another window, fullscreen | |
| 953 | + | // ends and that window shows, focused; otherwise (an edge) nothing changes | |
| 954 | + | // and the caller goes on to the next workspace or monitor. | |
| 955 | + | func (w *Workspace) leaveCover(move func()) bool { | |
| 956 | + | w.focusCover() | |
| 957 | + | full := w.fullscreen | |
| 958 | + | w.fullscreen = 0 | |
| 959 | + | move() | |
| 960 | + | if id, ok := w.Focused(); ok && id != full { | |
| 961 | + | w.endFullscreen() | |
| 962 | + | return true | |
| 963 | + | } | |
| 964 | + | w.fullscreen = full | |
| 965 | + | return false | |
| 966 | + | } | |
| 967 | + | ||
| 928 | 968 | // focusCover points the focus state at the covering fullscreen window, | |
| 929 | 969 | // which Focused already reports, so a user action acts on the window on | |
| 930 | 970 | // screen, not on one it hides. | |
| @@ -183,19 +183,11 @@ type Workspace struct { | |||||
| 183 | homePos int | 183 | homePos int | ||
| 184 | // termAt is when core last spawned a terminal for this workspace. | 184 | // termAt is when core last spawned a terminal for this workspace. | ||
| 185 | termAt time.Time | 185 | termAt time.Time | ||
| 186 | - | // origin is set on the workspace a fixed-overflow fullscreen window | |||
| 187 | - | // moved to; back is where it returns there (Monitor.fullscreenHome). | |||
| 188 | - | origin *Workspace | |||
| 189 | - | back origPlace | |||
| 190 | } | 186 | } | ||
| 191 | 187 | ||||
| 192 | - | // origPlace remembers where a window was: its column, its row in a stacked | 188 | + | // origPlace remembers where a stashed window was: its column, its row in a |
| 193 | - | // column, the column's width and slot, or its floating size. | 189 | + | // stacked column, the column's width and slot. |
| 194 | type origPlace struct { | 190 | type origPlace struct { | ||
| 195 | - | // id is the window that went fullscreen; tiled lists the windows that | |||
| 196 | - | // joined its workspace tiled, floating there until they go home. | |||
| 197 | - | id WindowID | |||
| 198 | - | tiled []WindowID | |||
| 199 | col, row, slot int | 191 | col, row, slot int | ||
| 200 | // stacked holds the windows left in the column: the window returns to | 192 | // stacked holds the windows left in the column: the window returns to | ||
| 201 | // its row in the column that still holds one of them. | 193 | // its row in the column that still holds one of them. | ||
| @@ -204,11 +196,6 @@ type origPlace struct { | |||||
| 204 | expanded bool | 196 | expanded bool | ||
| 205 | // fullWidth is the column's maximize-column state. | 197 | // fullWidth is the column's maximize-column state. | ||
| 206 | fullWidth bool | 198 | fullWidth bool | ||
| 207 | - | float *Float | |||
| 208 | - | // floatAt is the original native float position on fullscreen entry. | |||
| 209 | - | floatAt int | |||
| 210 | - | // stash is set when float came from the stash, at index col. | |||
| 211 | - | stash bool | |||
| 212 | } | 199 | } | ||
| 213 | 200 | ||||
| 214 | // Float is a floating window and its client size, logical. | 201 | // Float is a floating window and its client size, logical. | ||
| @@ -525,10 +512,19 @@ func (w *Workspace) FocusID(id WindowID) bool { | |||||
| 525 | } | 512 | } | ||
| 526 | return false | 513 | return false | ||
| 527 | } | 514 | } | ||
| 528 | - | func (w *Workspace) FocusColumn(dir int) { | 515 | + | |
| 516 | + | // FocusColumn moves focus to the column on that side. From a covering | |||
| 517 | + | // fullscreen window it leaves fullscreen for the window there (leaveCover); | |||
| 518 | + | // false means it stays, with no window on that side. | |||
| 519 | + | func (w *Workspace) FocusColumn(dir int) bool { | |||
| 529 | if w.pinned() { | 520 | if w.pinned() { | ||
| 530 | - | return | 521 | + | return w.leaveCover(func() { w.focusColumn(dir) }) |
| 531 | } | 522 | } | ||
| 523 | + | w.focusColumn(dir) | |||
| 524 | + | return true | |||
| 525 | + | } | |||
| 526 | + | ||||
| 527 | + | func (w *Workspace) focusColumn(dir int) { | |||
| 532 | if w.floatFocus { | 528 | if w.floatFocus { | ||
| 533 | // The first move leaves the native float for the stash or columns, | 529 | // The first move leaves the native float for the stash or columns, | ||
| 534 | // if there is one: otherwise the float keeps the focus. | 530 | // if there is one: otherwise the float keeps the focus. | ||
| @@ -577,11 +573,16 @@ func (w *Workspace) onScreenFocus() bool { | |||||
| 577 | 573 | ||||
| 578 | // FocusWindow moves focus inside the column, then to a fixed-overflow column | 574 | // FocusWindow moves focus inside the column, then to a fixed-overflow column | ||
| 579 | // above or below on screen. Up at the top raises the topmost demoted covering | 575 | // above or below on screen. Up at the top raises the topmost demoted covering | ||
| 580 | - | // float; false means no window is available in that direction. | 576 | + | // float; false means no window is available in that direction. From a |
| 577 | + | // covering fullscreen window it leaves fullscreen for that window. | |||
| 581 | func (w *Workspace) FocusWindow(dir int) bool { | 578 | func (w *Workspace) FocusWindow(dir int) bool { | ||
| 582 | if w.pinned() { | 579 | if w.pinned() { | ||
| 583 | - | return false | 580 | + | return w.leaveCover(func() { w.focusWindow(dir) }) |
| 584 | } | 581 | } | ||
| 582 | + | return w.focusWindow(dir) | |||
| 583 | + | } | |||
| 584 | + | ||||
| 585 | + | func (w *Workspace) focusWindow(dir int) bool { | |||
| 585 | if w.floatFocus { | 586 | if w.floatFocus { | ||
| 586 | return w.leaveFloat() | 587 | return w.leaveFloat() | ||
| 587 | } | 588 | } | ||
| @@ -787,10 +788,23 @@ func (w *Workspace) ToggleFullscreen() { | |||||
| 787 | return | 788 | return | ||
| 788 | } | 789 | } | ||
| 789 | if w.fullscreen == id { | 790 | if w.fullscreen == id { | ||
| 791 | + | // The user was on it: it keeps the focus, in front of floats that | |||
| 792 | + | // mapped meanwhile. Cleared first, so that floats it covers drop | |||
| 793 | + | // below the columns. | |||
| 790 | w.fullscreen = 0 | 794 | w.fullscreen = 0 | ||
| 791 | - | } else { | 795 | + | w.FocusID(id) |
| 792 | - | w.fullscreen = id | 796 | + | w.endFullscreen() |
| 797 | + | return | |||
| 793 | } | 798 | } | ||
| 799 | + | w.fullscreen = id | |||
| 800 | + | w.scroll() | |||
| 801 | + | } | |||
| 802 | + | ||||
| 803 | + | // endFullscreen ends the fullscreen: floats it kept demoted are promoted | |||
| 804 | + | // again and the view follows the focus. | |||
| 805 | + | func (w *Workspace) endFullscreen() { | |||
| 806 | + | w.fullscreen = 0 | |||
| 807 | + | w.reconcileFloats() | |||
| 794 | w.scroll() | 808 | w.scroll() | ||
| 795 | } | 809 | } | ||
| 796 | 810 | ||||
| @@ -801,11 +815,12 @@ func (w *Workspace) Activate(id WindowID) { | |||||
| 801 | // A hidden stashed window must be seen: show the stash with it, | 815 | // A hidden stashed window must be seen: show the stash with it, | ||
| 802 | // selected. Another window's fullscreen leaves first, so its own | 816 | // selected. Another window's fullscreen leaves first, so its own | ||
| 803 | // pending fullscreen comes back. | 817 | // pending fullscreen comes back. | ||
| 804 | - | w.fullscreen, w.stashAt = 0, i | 818 | + | w.endFullscreen() |
| 819 | + | w.stashAt = i | |||
| 805 | w.showStash() | 820 | w.showStash() | ||
| 806 | } | 821 | } | ||
| 807 | if w.fullscreen != 0 && w.fullscreen != id { | 822 | if w.fullscreen != 0 && w.fullscreen != id { | ||
| 808 | - | w.fullscreen = 0 | 823 | + | w.endFullscreen() |
| 809 | } | 824 | } | ||
| 810 | w.FocusID(id) | 825 | w.FocusID(id) | ||
| 811 | } | 826 | } | ||
| @@ -813,6 +828,9 @@ func (w *Workspace) Activate(id WindowID) { | |||||
| 813 | // SetFullscreen applies a client request. It never moves focus: client | 828 | // SetFullscreen applies a client request. It never moves focus: client | ||
| 814 | // requests are automatic events (ADR 011 golden rule). | 829 | // requests are automatic events (ADR 011 golden rule). | ||
| 815 | func (w *Workspace) SetFullscreen(id WindowID, on bool) { | 830 | func (w *Workspace) SetFullscreen(id WindowID, on bool) { | ||
| 831 | + | if on && !w.mayCover(id) { | |||
| 832 | + | return | |||
| 833 | + | } | |||
| 816 | if w.isFloat(id) { | 834 | if w.isFloat(id) { | ||
| 817 | if w.stashHidden && w.stashIndex(id) >= 0 { | 835 | if w.stashHidden && w.stashIndex(id) >= 0 { | ||
| 818 | // Applied when the stash is shown again. | 836 | // Applied when the stash is shown again. | ||
| @@ -823,26 +841,19 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) { | |||||
| 823 | } | 841 | } | ||
| 824 | return | 842 | return | ||
| 825 | } | 843 | } | ||
| 826 | - | if on { | 844 | + | } |
| 827 | - | w.fullscreen = id | 845 | + | if !w.has(id) { |
| 828 | - | } else if w.fullscreen == id { | |||
| 829 | - | w.fullscreen = 0 | |||
| 830 | - | w.reconcileFloats() | |||
| 831 | - | } | |||
| 832 | return | 846 | return | ||
| 833 | } | 847 | } | ||
| 834 | - | for i := range w.Columns { | 848 | + | if !on { |
| 835 | - | for _, v := range w.Columns[i].Windows { | 849 | + | if w.fullscreen == id { |
| 836 | - | if v == id { | 850 | + | w.endFullscreen() |
| 837 | - | if on { | |||
| 838 | - | w.fullscreen = id | |||
| 839 | - | } else if w.fullscreen == id { | |||
| 840 | - | w.fullscreen = 0 | |||
| 841 | - | } | |||
| 842 | - | w.scroll() | |||
| 843 | - | return | |||
| 844 | - | } | |||
| 845 | } | 851 | } | ||
| 852 | + | return | |||
| 853 | + | } | |||
| 854 | + | w.fullscreen = id | |||
| 855 | + | if !w.isFloat(id) { | |||
| 856 | + | w.scroll() | |||
| 846 | } | 857 | } | ||
| 847 | } | 858 | } | ||
| 848 | 859 | ||||
| @@ -916,15 +927,44 @@ func (w *Workspace) overviewArea() Rect { return w.reserved } | |||||
| 916 | 927 | ||||
| 917 | func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) } | 928 | func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) } | ||
| 918 | 929 | ||||
| 919 | - | // pinned reports whether focus moves inside the workspace are off: the | 930 | + | // mayCover reports whether a client may make id fullscreen. In fixed |
| 920 | - | // covering fullscreen window hides every target. Only in scroll overflow | 931 | + | // overflow it would cover the output at once and take the keyboard from |
| 921 | - | // does moving to a tiled neighbor scroll it off and show the target. | 932 | + | // the focused window, so only the focused window may; the request of |
| 922 | - | // Moves to another workspace or monitor still work. | 933 | + | // another is refused and the user can still toggle-fullscreen it. A |
| 934 | + | // hidden stash's request waits for the show (showStash). | |||
| 935 | + | func (w *Workspace) mayCover(id WindowID) bool { | |||
| 936 | + | if w.Overflow != OverflowFixed || w.stashHidden && w.stashIndex(id) >= 0 { | |||
| 937 | + | return true | |||
| 938 | + | } | |||
| 939 | + | focused, ok := w.Focused() | |||
| 940 | + | return ok && focused == id | |||
| 941 | + | } | |||
| 942 | + | ||||
| 943 | + | // pinned reports whether the covering fullscreen window hides every other | |||
| 944 | + | // window: focus moves leave it (leaveCover). Only in scroll overflow does | |||
| 945 | + | // moving to a tiled neighbor scroll it off and show the target instead. | |||
| 923 | func (w *Workspace) pinned() bool { | 946 | func (w *Workspace) pinned() bool { | ||
| 924 | c := w.cover() | 947 | c := w.cover() | ||
| 925 | return c != 0 && (w.Overflow == OverflowFixed || w.isFloat(c)) | 948 | return c != 0 && (w.Overflow == OverflowFixed || w.isFloat(c)) | ||
| 926 | } | 949 | } | ||
| 927 | 950 | ||||
| 951 | + | // leaveCover runs a focus move from the covering fullscreen window as if it | |||
| 952 | + | // were not fullscreen. When the move reaches another window, fullscreen | |||
| 953 | + | // ends and that window shows, focused; otherwise (an edge) nothing changes | |||
| 954 | + | // and the caller goes on to the next workspace or monitor. | |||
| 955 | + | func (w *Workspace) leaveCover(move func()) bool { | |||
| 956 | + | w.focusCover() | |||
| 957 | + | full := w.fullscreen | |||
| 958 | + | w.fullscreen = 0 | |||
| 959 | + | move() | |||
| 960 | + | if id, ok := w.Focused(); ok && id != full { | |||
| 961 | + | w.endFullscreen() | |||
| 962 | + | return true | |||
| 963 | + | } | |||
| 964 | + | w.fullscreen = full | |||
| 965 | + | return false | |||
| 966 | + | } | |||
| 967 | + | ||||
| 928 | // focusCover points the focus state at the covering fullscreen window, | 968 | // focusCover points the focus state at the covering fullscreen window, | ||
| 929 | // which Focused already reports, so a user action acts on the window on | 969 | // which Focused already reports, so a user action acts on the window on | ||
| 930 | // screen, not on one it hides. | 970 | // screen, not on one it hides. | ||
M internal/core/monitor.go +40 -346
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | - | import "slices" | |
| 3 | + | import ( | |
| 4 | + | "iter" | |
| 5 | + | "slices" | |
| 6 | + | ) | |
| 4 | 7 | ||
| 5 | 8 | // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers | |
| 6 | 9 | // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits | |
| @@ -71,9 +74,21 @@ func (m *Monitor) matches(home string) bool { | |||
| 71 | 74 | return home != "" && (home == m.Key || home == m.Name) | |
| 72 | 75 | } | |
| 73 | 76 | ||
| 74 | - | // all lists every workspace, numbered then hidden. | |
| 75 | - | func (m *Monitor) all() []*Workspace { | |
| 76 | - | return append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) | |
| 77 | + | // all yields every workspace, numbered then hidden, without copying. | |
| 78 | + | // Callers must not add or remove workspaces while ranging. | |
| 79 | + | func (m *Monitor) all() iter.Seq[*Workspace] { | |
| 80 | + | return func(yield func(*Workspace) bool) { | |
| 81 | + | for _, w := range m.Workspaces { | |
| 82 | + | if !yield(w) { | |
| 83 | + | return | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | for _, w := range m.hidden { | |
| 87 | + | if !yield(w) { | |
| 88 | + | return | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | } | |
| 77 | 92 | } | |
| 78 | 93 | ||
| 79 | 94 | // isHidden reports whether w is in the hidden list. | |
| @@ -141,7 +156,7 @@ func (m *Monitor) Current() *Workspace { | |||
| 141 | 156 | func (m *Monitor) newWorkspace() *Workspace { | |
| 142 | 157 | w := m.template | |
| 143 | 158 | w.presets = append([]Width(nil), m.template.presets...) | |
| 144 | - | w.Columns, w.Floats, w.maximized, w.floatFocus, w.home, w.origin, w.back = nil, nil, nil, false, "", nil, origPlace{} | |
| 159 | + | w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, "" | |
| 145 | 160 | w.overviewAfter = nil | |
| 146 | 161 | w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0 | |
| 147 | 162 | (*m.nextID)++ | |
| @@ -153,7 +168,6 @@ func (m *Monitor) newWorkspace() *Workspace { | |||
| 153 | 168 | // unnamed ones that are neither the first nor active. The active workspace | |
| 154 | 169 | // never changes. | |
| 155 | 170 | func (m *Monitor) normalize() { | |
| 156 | - | m.fullscreenHome() | |
| 157 | 171 | spare := func(i int) bool { return m.Workspaces[i].empty() && m.Workspaces[i].Name == "" } | |
| 158 | 172 | for i := len(m.Workspaces) - 2; i >= 1; i-- { | |
| 159 | 173 | if spare(i) && i != m.Active { | |
| @@ -229,11 +243,9 @@ func (m *Monitor) ToggleNamed(name string) { | |||
| 229 | 243 | ||
| 230 | 244 | // byName returns the configured workspace with that name, or nil. | |
| 231 | 245 | func (m *Monitor) byName(name string) *Workspace { | |
| 232 | - | for _, list := range [2][]*Workspace{m.Workspaces, m.hidden} { | |
| 233 | - | for _, w := range list { | |
| 234 | - | if w.Name == name { | |
| 235 | - | return w | |
| 236 | - | } | |
| 246 | + | for w := range m.all() { | |
| 247 | + | if w.Name == name { | |
| 248 | + | return w | |
| 237 | 249 | } | |
| 238 | 250 | } | |
| 239 | 251 | return nil | |
| @@ -273,26 +285,7 @@ func (m *Monitor) AddWindow(id WindowID) { | |||
| 273 | 285 | if w, _ := m.find(id); w != nil { | |
| 274 | 286 | return | |
| 275 | 287 | } | |
| 276 | - | m.arrive(m.Current(), func(w *Workspace) { w.AddWindow(id) }) | |
| 277 | - | } | |
| 278 | - | ||
| 279 | - | // arrive adds a new tiled window to w with add. Under fixed overflow a | |
| 280 | - | // fullscreen window hides every other one of its workspace and keeps the | |
| 281 | - | // view (ADR 011), so the new window goes to its home workspace instead: | |
| 282 | - | // the origin of a fullscreen workspace, or w itself once a fullscreen | |
| 283 | - | // window still in place there has moved to its own workspace. | |
| 284 | - | func (m *Monitor) arrive(w *Workspace, add func(*Workspace)) { | |
| 285 | - | switch { | |
| 286 | - | case w.origin != nil: | |
| 287 | - | add(w.origin) | |
| 288 | - | case w.Overflow == OverflowFixed && w.fullscreen != 0 && m.has(w): | |
| 289 | - | // Added first, hidden and unfocused: w is never left empty, | |
| 290 | - | // which normalize would drop. | |
| 291 | - | add(w) | |
| 292 | - | m.enterFullscreen(w, w.fullscreen, m.Current() == w) | |
| 293 | - | default: | |
| 294 | - | add(w) | |
| 295 | - | } | |
| 288 | + | m.Current().AddWindow(id) | |
| 296 | 289 | m.normalize() | |
| 297 | 290 | } | |
| 298 | 291 | ||
| @@ -306,270 +299,25 @@ func (m *Monitor) AddFloating(id WindowID, width, height int) { | |||
| 306 | 299 | } | |
| 307 | 300 | ||
| 308 | 301 | // RemoveWindow drops the window wherever it is; focus stays on the active | |
| 309 | - | // workspace, except that closing a fullscreen workspace goes back to its origin. | |
| 302 | + | // workspace. | |
| 310 | 303 | func (m *Monitor) RemoveWindow(id WindowID) { | |
| 311 | 304 | if w, _ := m.find(id); w != nil { | |
| 312 | - | if w.origin != nil && id == w.back.id && m.has(w.origin) { | |
| 313 | - | // Its dialogs go home with the view. | |
| 314 | - | origin, back := w.origin, w.back | |
| 315 | - | w.RemoveWindow(id) | |
| 316 | - | w.origin, w.back = nil, origPlace{} | |
| 317 | - | m.foldInto(w, origin, back.col, false, back.tiled) | |
| 318 | - | } else { | |
| 319 | - | w.RemoveWindow(id) | |
| 320 | - | } | |
| 305 | + | w.RemoveWindow(id) | |
| 321 | 306 | m.normalize() | |
| 322 | 307 | } | |
| 323 | 308 | } | |
| 324 | 309 | ||
| 325 | - | // SetFullscreen applies a client request. Under fixed overflow the window | |
| 326 | - | // gets its own workspace, as with the bind. A client request never moves | |
| 327 | - | // the user (ADR 011): the view follows only a window that is focused and | |
| 328 | - | // on screen, and a window coming back does not take the focus. | |
| 310 | + | // SetFullscreen applies a client request, in place. A client request never | |
| 311 | + | // moves the user (ADR 011): the view and the focus stay where they are. | |
| 329 | 312 | func (m *Monitor) SetFullscreen(id WindowID, on bool) { | |
| 330 | - | w, _ := m.find(id) | |
| 331 | - | switch { | |
| 332 | - | case w == nil: | |
| 333 | - | case w.origin != nil && id != w.back.id: | |
| 334 | - | // A dialog of a fullscreen workspace already floats above it; its | |
| 335 | - | // own fullscreen would take the workspace from the window it holds. | |
| 336 | - | case w.stashHidden && w.stashIndex(id) >= 0: | |
| 337 | - | // A hidden stash stays hidden: its fullscreen waits for the show. | |
| 338 | - | w.SetFullscreen(id, on) | |
| 339 | - | case on && m.ownWorkspace(w, id): | |
| 340 | - | focused, _ := w.Focused() | |
| 341 | - | m.enterFullscreen(w, id, w == m.Current() && focused == id) | |
| 342 | - | case !on && w.origin != nil && w.fullscreen == id: | |
| 343 | - | // The user was on that window: they stay on it, back home. | |
| 344 | - | m.leaveFullscreen(w, m.Current() == w) | |
| 345 | - | default: | |
| 313 | + | if w, _ := m.find(id); w != nil { | |
| 346 | 314 | w.SetFullscreen(id, on) | |
| 347 | 315 | } | |
| 348 | - | m.normalize() | |
| 349 | 316 | } | |
| 350 | 317 | ||
| 351 | - | // ToggleFullscreen is the bind. Under fixed overflow the focused window | |
| 352 | - | // moves to a new workspace below the current one, so workspace up/down | |
| 353 | - | // still reach the others; toggling again sends it back where it was. | |
| 354 | - | func (m *Monitor) ToggleFullscreen() { | |
| 355 | - | w := m.Current() | |
| 356 | - | id, ok := w.Focused() | |
| 357 | - | switch { | |
| 358 | - | case !ok: | |
| 359 | - | case w.origin != nil && w.fullscreen == w.back.id: | |
| 360 | - | // From its window or one of its dialogs. | |
| 361 | - | m.leaveFullscreen(w, true) | |
| 362 | - | case m.ownWorkspace(w, id): | |
| 363 | - | m.enterFullscreen(w, id, true) | |
| 364 | - | default: | |
| 365 | - | w.ToggleFullscreen() | |
| 366 | - | } | |
| 367 | - | m.normalize() | |
| 368 | - | } | |
| 369 | - | ||
| 370 | - | // ownWorkspace reports whether going fullscreen moves the window to its own | |
| 371 | - | // workspace: fixed overflow hides every other window behind a fullscreen | |
| 372 | - | // one and never scrolls, so it would leave the user stuck. Not needed when | |
| 373 | - | // the window is alone, or already has its own workspace. | |
| 374 | - | func (m *Monitor) ownWorkspace(w *Workspace, id WindowID) bool { | |
| 375 | - | return w.Overflow == OverflowFixed && w.origin == nil && w.fullscreen != id && len(w.windows()) > 1 && m.has(w) | |
| 376 | - | } | |
| 377 | - | ||
| 378 | - | // enterFullscreen moves window id from w to a new workspace just below the | |
| 379 | - | // numbered one on screen, fullscreen, and shows it when show is set. It | |
| 380 | - | // remembers the window's place in w to return there. | |
| 381 | - | func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) { | |
| 382 | - | fs := m.newWorkspace() | |
| 383 | - | fs.Overflow, fs.MaxColumns = w.Overflow, w.MaxColumns | |
| 384 | - | fs.SetSize(w.size[0], w.size[1]) | |
| 385 | - | fs.origin = w | |
| 386 | - | fs.back.id = id | |
| 387 | - | if f := w.floatIndex(id); f >= 0 { | |
| 388 | - | fl := w.Floats[f] | |
| 389 | - | fs.back.float, fs.back.floatAt = &fl, f | |
| 390 | - | w.RemoveWindow(id) | |
| 391 | - | fs.AddFloating(id, fl.W, fl.H) | |
| 392 | - | fs.Floats[len(fs.Floats)-1] = fl | |
| 393 | - | } else if f := w.stashIndex(id); f >= 0 { | |
| 394 | - | // It returns to its place in the stash. | |
| 395 | - | fl := w.Stash[f] | |
| 396 | - | fs.back.float, fs.back.stash, fs.back.col = &fl, true, f | |
| 397 | - | w.RemoveWindow(id) | |
| 398 | - | fs.addStash(fl) | |
| 399 | - | } else { | |
| 400 | - | col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) }) | |
| 401 | - | c := w.Columns[col] | |
| 402 | - | fs.back = origPlace{id: id, col: col, row: slices.Index(c.Windows, id), slot: c.Slot, width: c.Width, expanded: c.Expanded} | |
| 403 | - | for _, v := range c.Windows { | |
| 404 | - | if v != id { | |
| 405 | - | fs.back.stacked = append(fs.back.stacked, v) | |
| 406 | - | } | |
| 407 | - | } | |
| 408 | - | w.RemoveWindow(id) | |
| 409 | - | fs.AddWindow(id) | |
| 410 | - | fs.Columns[0].Width = c.Width | |
| 411 | - | } | |
| 412 | - | fs.SetFullscreen(id, true) | |
| 413 | - | at := m.Active + 1 | |
| 414 | - | if i := indexOf(m.Workspaces, w); i >= 0 { | |
| 415 | - | at = i + 1 | |
| 416 | - | } | |
| 417 | - | m.Workspaces = slices.Insert(m.Workspaces, at, fs) | |
| 418 | - | if at <= m.Active { | |
| 419 | - | m.Active++ | |
| 420 | - | } | |
| 421 | - | if show { | |
| 422 | - | m.show(fs) | |
| 423 | - | } | |
| 424 | - | } | |
| 425 | - | ||
| 426 | - | // leaveFullscreen sends the window of fs back to its place in the origin, | |
| 427 | - | // with the windows opened there meanwhile (its dialogs) just after it, and | |
| 428 | - | // drops fs; the view goes along when fs was on screen. focus is set when | |
| 429 | - | // the user was on the window: it gets the focus back there. | |
| 430 | - | func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |
| 431 | - | if m.fullscreenHome(); fs.origin == nil { | |
| 432 | - | fs.SetFullscreen(fs.fullscreen, false) | |
| 433 | - | return | |
| 434 | - | } | |
| 435 | - | origin, back, id := fs.origin, fs.back, fs.back.id | |
| 436 | - | fs.origin, fs.back = nil, origPlace{} | |
| 437 | - | fs.RemoveWindow(id) | |
| 438 | - | // A closure: back.col is where the window lands, known after the switch. | |
| 439 | - | defer func() { m.foldInto(fs, origin, back.col+1, focus, back.tiled) }() | |
| 440 | - | switch prev := origin.Focus; { | |
| 441 | - | case back.stash: | |
| 442 | - | at := min(back.col, len(origin.Stash)) | |
| 443 | - | origin.Stash = slices.Insert(origin.Stash, at, *back.float) | |
| 444 | - | if focus { | |
| 445 | - | origin.stashAt = at | |
| 446 | - | origin.showStash() | |
| 447 | - | } else if at <= origin.stashAt && len(origin.Stash) > 1 { | |
| 448 | - | origin.stashAt++ | |
| 449 | - | } | |
| 450 | - | back.col = len(origin.Columns) | |
| 451 | - | case back.float != nil: | |
| 452 | - | // Restore the original position before an optional focus-driven raise. | |
| 453 | - | at := min(back.floatAt, len(origin.Floats)) | |
| 454 | - | if !focus && origin.floatFocus && len(origin.Floats) > 0 { | |
| 455 | - | at = min(at, len(origin.Floats)-1) | |
| 456 | - | } | |
| 457 | - | origin.Floats = slices.Insert(origin.Floats, at, *back.float) | |
| 458 | - | origin.reconcileFloats() | |
| 459 | - | if focus { | |
| 460 | - | origin.FocusID(id) | |
| 461 | - | } | |
| 462 | - | case slices.ContainsFunc(origin.Columns, back.holdsStack): | |
| 463 | - | back.col = slices.IndexFunc(origin.Columns, back.holdsStack) | |
| 464 | - | c := &origin.Columns[back.col] | |
| 465 | - | // Below the windows that were above it and are still there. | |
| 466 | - | row := 0 | |
| 467 | - | for _, v := range back.stacked[:back.row] { | |
| 468 | - | if slices.Contains(c.Windows, v) { | |
| 469 | - | row++ | |
| 470 | - | } | |
| 471 | - | } | |
| 472 | - | row = min(row, len(c.Windows)) | |
| 473 | - | c.Windows = slices.Insert(c.Windows, row, id) | |
| 474 | - | if focus { | |
| 475 | - | origin.Focus, c.Focus, origin.floatFocus, origin.stashFocus = back.col, row, false, false | |
| 476 | - | origin.raiseColumns() | |
| 477 | - | } else if row <= c.Focus && len(c.Windows) > 1 { | |
| 478 | - | c.Focus++ | |
| 479 | - | } | |
| 480 | - | default: | |
| 481 | - | at := min(back.col, len(origin.Columns)) | |
| 482 | - | // Its column is gone: it comes back expanded, unless another | |
| 483 | - | // column was expanded meanwhile. | |
| 484 | - | expanded := back.expanded && !slices.ContainsFunc(origin.Columns, func(c Column) bool { return c.Expanded }) | |
| 485 | - | origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded}) | |
| 486 | - | if focus { | |
| 487 | - | origin.floatFocus, origin.stashFocus = false, false | |
| 488 | - | origin.raiseColumns() | |
| 489 | - | } else if len(origin.Columns) > 1 { | |
| 490 | - | origin.Focus = prev | |
| 491 | - | if at <= prev { | |
| 492 | - | origin.Focus++ | |
| 493 | - | } | |
| 494 | - | } | |
| 495 | - | } | |
| 496 | - | } | |
| 497 | - | ||
| 498 | - | // foldInto moves what is left in fs (windows opened while fullscreen: its | |
| 499 | - | // dialogs) to origin, tiled ones from column at, floating ones on top, and | |
| 500 | - | // drops fs. The view goes along when fs was on screen. | |
| 501 | - | func (m *Monitor) foldInto(fs, origin *Workspace, at int, focus bool, tiled []WindowID) { | |
| 502 | - | shown := m.Current() == fs | |
| 503 | - | at = min(max(at, 0), len(origin.Columns)) | |
| 504 | - | for _, c := range slices.Backward(fs.Columns) { | |
| 505 | - | origin.Columns = slices.Insert(origin.Columns, at, Column{Windows: c.Windows, Width: c.Width, Focus: c.Focus}) | |
| 506 | - | if at <= origin.Focus && len(origin.Columns) > 1 { | |
| 507 | - | origin.Focus++ | |
| 508 | - | } | |
| 509 | - | } | |
| 510 | - | var floats []Float | |
| 511 | - | for _, f := range fs.Floats { | |
| 512 | - | if !slices.Contains(tiled, f.ID) { | |
| 513 | - | floats = append(floats, f) | |
| 514 | - | continue | |
| 515 | - | } | |
| 516 | - | origin.Columns = slices.Insert(origin.Columns, at, Column{Windows: []WindowID{f.ID}}) | |
| 517 | - | if at <= origin.Focus && len(origin.Columns) > 1 { | |
| 518 | - | origin.Focus++ | |
| 519 | - | } | |
| 520 | - | at++ | |
| 521 | - | } | |
| 522 | - | if focus || shown { | |
| 523 | - | origin.Floats = append(origin.Floats, floats...) | |
| 524 | - | origin.floatFocus = origin.floatFocus || len(floats) > 0 && fs.floatFocus | |
| 525 | - | } else { | |
| 526 | - | // The user is elsewhere: below, the focused float keeps the focus. | |
| 527 | - | origin.Floats = append(floats, origin.Floats...) | |
| 528 | - | } | |
| 529 | - | origin.reconcileFloats() | |
| 530 | - | // Windows stashed there join the end of the stash. | |
| 531 | - | for _, f := range fs.Stash { | |
| 532 | - | origin.Stash = append(origin.Stash, f.rehome()) | |
| 533 | - | } | |
| 534 | - | fs.Columns, fs.Floats, fs.Stash = nil, nil, nil | |
| 535 | - | origin.scroll() | |
| 536 | - | m.take(fs) | |
| 537 | - | if shown { | |
| 538 | - | m.show(origin) | |
| 539 | - | } | |
| 540 | - | } | |
| 541 | - | ||
| 542 | - | // awayInSlot reports whether id is away from slot n of w, fullscreen in a | |
| 543 | - | // workspace that returns it there. | |
| 544 | - | func (m *Monitor) awayInSlot(w *Workspace, id WindowID, n int) bool { | |
| 545 | - | for _, fs := range m.all() { | |
| 546 | - | if fs.origin == w && fs.back.id == id && fs.back.slot == n { | |
| 547 | - | return true | |
| 548 | - | } | |
| 549 | - | } | |
| 550 | - | return false | |
| 551 | - | } | |
| 552 | - | ||
| 553 | - | // holdsStack reports whether c holds a window of the stack left behind. | |
| 554 | - | func (p origPlace) holdsStack(c Column) bool { | |
| 555 | - | return slices.ContainsFunc(c.Windows, func(v WindowID) bool { return slices.Contains(p.stacked, v) }) | |
| 556 | - | } | |
| 557 | - | ||
| 558 | - | // fullscreenHome keeps each fullscreen workspace linked to its origin only | |
| 559 | - | // while its window is still there and fullscreen, and the origin is still | |
| 560 | - | // on this monitor. Otherwise (the window moved out or activated away from | |
| 561 | - | // fullscreen, the origin gone to another monitor) it is a normal workspace. | |
| 562 | - | // Other windows opened there (a dialog) stay when the window returns. | |
| 563 | - | func (m *Monitor) fullscreenHome() { | |
| 564 | - | for _, w := range m.all() { | |
| 565 | - | if w.origin == nil { | |
| 566 | - | continue | |
| 567 | - | } | |
| 568 | - | if !m.has(w.origin) || w.fullscreen != w.back.id || !w.has(w.back.id) { | |
| 569 | - | w.origin, w.back = nil, origPlace{} | |
| 570 | - | } | |
| 571 | - | } | |
| 572 | - | } | |
| 318 | + | // ToggleFullscreen is the bind: the focused window goes fullscreen in place, | |
| 319 | + | // or leaves it. Focus moves leave it too (Workspace.leaveCover). | |
| 320 | + | func (m *Monitor) ToggleFullscreen() { m.Current().ToggleFullscreen() } | |
| 573 | 321 | ||
| 574 | 322 | // MoveToWorkspace moves the focused column (or only the focused window) to | |
| 575 | 323 | // numbered workspace index i. A floating window always moves alone. Focus | |
| @@ -581,35 +329,19 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) { | |||
| 581 | 329 | if !ok || to == cur { | |
| 582 | 330 | return | |
| 583 | 331 | } | |
| 584 | - | if m.followMove && to.origin != nil { | |
| 585 | - | // The view follows the window: a fullscreen workspace would hide | |
| 586 | - | // it, so its window goes home first and the move lands there. | |
| 587 | - | origin := to.origin | |
| 588 | - | m.leaveFullscreen(to, false) | |
| 589 | - | to = origin | |
| 590 | - | if i = indexOf(m.Workspaces, to); i < 0 || to == cur { | |
| 591 | - | return | |
| 592 | - | } | |
| 593 | - | } | |
| 594 | 332 | if f := cur.floatIndex(id); f >= 0 { | |
| 595 | 333 | fl := cur.Floats[f] | |
| 596 | 334 | cur.RemoveWindow(id) | |
| 597 | 335 | to.AddFloating(id, fl.W, fl.H) | |
| 598 | 336 | to.Floats[len(to.Floats)-1] = fl | |
| 599 | - | if to.origin == nil { | |
| 600 | - | // As receive: an in-place fullscreen would hide it. | |
| 601 | - | to.Activate(id) | |
| 602 | - | } else { | |
| 603 | - | to.FocusID(id) | |
| 604 | - | } | |
| 337 | + | // As receive: a fullscreen window would hide it. | |
| 338 | + | to.Activate(id) | |
| 605 | 339 | } else if f := cur.stashIndex(id); f >= 0 { | |
| 606 | 340 | // It joins the end of the stash there, shown and selected. | |
| 607 | 341 | fl := cur.Stash[f].rehome() | |
| 608 | 342 | cur.RemoveWindow(id) | |
| 609 | 343 | to.addStash(fl) | |
| 610 | - | if to.origin == nil { | |
| 611 | - | to.Activate(id) | |
| 612 | - | } | |
| 344 | + | to.Activate(id) | |
| 613 | 345 | } else { | |
| 614 | 346 | col := Column{Windows: []WindowID{id}} | |
| 615 | 347 | ok := true | |
| @@ -631,17 +363,11 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) { | |||
| 631 | 363 | } | |
| 632 | 364 | ||
| 633 | 365 | // receive adds a column moved in from another workspace and focuses it: at | |
| 634 | - | // index at, or where a new window goes when at < 0. An in-place fullscreen | |
| 635 | - | // is left. On a fixed-overflow fullscreen workspace its windows float, | |
| 636 | - | // hidden until the fullscreen window leaves: it stays exclusive. | |
| 366 | + | // index at, or where a new window goes when at < 0. A fullscreen window | |
| 367 | + | // there is left, so the moved window is seen. | |
| 637 | 368 | func (w *Workspace) receive(col Column, at int) { | |
| 638 | 369 | id := col.Windows[col.Focus] | |
| 639 | 370 | switch { | |
| 640 | - | case w.origin != nil: | |
| 641 | - | for _, v := range col.Windows { | |
| 642 | - | w.joinFullscreen(v) | |
| 643 | - | } | |
| 644 | - | return | |
| 645 | 371 | case at < 0: | |
| 646 | 372 | w.addColumn(col) | |
| 647 | 373 | default: | |
| @@ -650,23 +376,6 @@ func (w *Workspace) receive(col Column, at int) { | |||
| 650 | 376 | w.Activate(id) | |
| 651 | 377 | } | |
| 652 | 378 | ||
| 653 | - | // joinFullscreen adds a tiled window to a fullscreen workspace fs: it | |
| 654 | - | // floats, hidden while the window is fullscreen, and tiles again once home. | |
| 655 | - | func (fs *Workspace) joinFullscreen(id WindowID) { | |
| 656 | - | fs.AddFloating(id, 0, 0) | |
| 657 | - | fs.back.tiled = append(fs.back.tiled, id) | |
| 658 | - | } | |
| 659 | - | ||
| 660 | - | // landing is the workspace on screen for a window the user moves here. A | |
| 661 | - | // fullscreen workspace hides everything but its window, so it goes home | |
| 662 | - | // first: the moved window must be seen. | |
| 663 | - | func (m *Monitor) landing() *Workspace { | |
| 664 | - | if fs := m.Current(); fs.origin != nil { | |
| 665 | - | m.leaveFullscreen(fs, false) | |
| 666 | - | } | |
| 667 | - | return m.Current() | |
| 668 | - | } | |
| 669 | - | ||
| 670 | 379 | // Focused is the focused window of the workspace on screen; in the | |
| 671 | 380 | // overview, the selected stash card when it is one. | |
| 672 | 381 | func (m *Monitor) Focused() (WindowID, bool) { | |
| @@ -683,7 +392,7 @@ func (m *Monitor) Layout() []Placement { | |||
| 683 | 392 | } | |
| 684 | 393 | var result []Placement | |
| 685 | 394 | cur := m.Current() | |
| 686 | - | for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) { | |
| 395 | + | for w := range m.all() { | |
| 687 | 396 | if w == cur { | |
| 688 | 397 | result = append(result, w.Layout()...) | |
| 689 | 398 | continue | |
| @@ -774,15 +483,9 @@ func (m *Monitor) applyNamed() { | |||
| 774 | 483 | if s.MaxColumns > 0 { | |
| 775 | 484 | w.SetMaxColumns(s.MaxColumns) | |
| 776 | 485 | } | |
| 777 | - | // Named workspaces take their configured size, a fullscreen sibling | |
| 778 | - | // that of its named origin. Any other workspace keeps its own: a | |
| 779 | - | // sibling detached from its origin must not resize on reload. | |
| 780 | - | switch { | |
| 781 | - | case w.Name != "": | |
| 486 | + | // Named workspaces take their configured size; any other keeps its own. | |
| 487 | + | if w.Name != "" { | |
| 782 | 488 | w.SetSize(s.Size[0], s.Size[1]) | |
| 783 | - | case w.origin != nil && w.origin.Name != "": | |
| 784 | - | size := specs[w.origin.Name].Size | |
| 785 | - | w.SetSize(size[0], size[1]) | |
| 786 | 489 | } | |
| 787 | 490 | }) | |
| 788 | 491 | } | |
| @@ -795,15 +498,6 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) { | |||
| 795 | 498 | for _, s := range specs { | |
| 796 | 499 | want[s.Name] = s | |
| 797 | 500 | } | |
| 798 | - | // A sibling inherits its named origin's override only while that | |
| 799 | - | // declaration exists. Removing it restores monitor-sized geometry. | |
| 800 | - | for _, w := range m.Workspaces { | |
| 801 | - | if w.origin != nil && w.origin.Name != "" { | |
| 802 | - | if _, ok := want[w.origin.Name]; !ok { | |
| 803 | - | w.SetSize(0, 0) | |
| 804 | - | } | |
| 805 | - | } | |
| 806 | - | } | |
| 807 | 501 | byName := map[string]*Workspace{} | |
| 808 | 502 | for _, w := range m.Workspaces { | |
| 809 | 503 | if _, ok := want[w.Name]; w.Name != "" && !ok { | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | - | import "slices" | 3 | + | import ( |
| 4 | + | "iter" | |||
| 5 | + | "slices" | |||
| 6 | + | ) | |||
| 4 | 7 | ||||
| 5 | // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers | 8 | // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers | ||
| 6 | // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits | 9 | // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits | ||
| @@ -71,9 +74,21 @@ func (m *Monitor) matches(home string) bool { | |||||
| 71 | return home != "" && (home == m.Key || home == m.Name) | 74 | return home != "" && (home == m.Key || home == m.Name) | ||
| 72 | } | 75 | } | ||
| 73 | 76 | ||||
| 74 | - | // all lists every workspace, numbered then hidden. | 77 | + | // all yields every workspace, numbered then hidden, without copying. |
| 75 | - | func (m *Monitor) all() []*Workspace { | 78 | + | // Callers must not add or remove workspaces while ranging. |
| 76 | - | return append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) | 79 | + | func (m *Monitor) all() iter.Seq[*Workspace] { |
| 80 | + | return func(yield func(*Workspace) bool) { | |||
| 81 | + | for _, w := range m.Workspaces { | |||
| 82 | + | if !yield(w) { | |||
| 83 | + | return | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | for _, w := range m.hidden { | |||
| 87 | + | if !yield(w) { | |||
| 88 | + | return | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 77 | } | 92 | } | ||
| 78 | 93 | ||||
| 79 | // isHidden reports whether w is in the hidden list. | 94 | // isHidden reports whether w is in the hidden list. | ||
| @@ -141,7 +156,7 @@ func (m *Monitor) Current() *Workspace { | |||||
| 141 | func (m *Monitor) newWorkspace() *Workspace { | 156 | func (m *Monitor) newWorkspace() *Workspace { | ||
| 142 | w := m.template | 157 | w := m.template | ||
| 143 | w.presets = append([]Width(nil), m.template.presets...) | 158 | w.presets = append([]Width(nil), m.template.presets...) | ||
| 144 | - | w.Columns, w.Floats, w.maximized, w.floatFocus, w.home, w.origin, w.back = nil, nil, nil, false, "", nil, origPlace{} | 159 | + | w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, "" |
| 145 | w.overviewAfter = nil | 160 | w.overviewAfter = nil | ||
| 146 | w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0 | 161 | w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0 | ||
| 147 | (*m.nextID)++ | 162 | (*m.nextID)++ | ||
| @@ -153,7 +168,6 @@ func (m *Monitor) newWorkspace() *Workspace { | |||||
| 153 | // unnamed ones that are neither the first nor active. The active workspace | 168 | // unnamed ones that are neither the first nor active. The active workspace | ||
| 154 | // never changes. | 169 | // never changes. | ||
| 155 | func (m *Monitor) normalize() { | 170 | func (m *Monitor) normalize() { | ||
| 156 | - | m.fullscreenHome() | |||
| 157 | spare := func(i int) bool { return m.Workspaces[i].empty() && m.Workspaces[i].Name == "" } | 171 | spare := func(i int) bool { return m.Workspaces[i].empty() && m.Workspaces[i].Name == "" } | ||
| 158 | for i := len(m.Workspaces) - 2; i >= 1; i-- { | 172 | for i := len(m.Workspaces) - 2; i >= 1; i-- { | ||
| 159 | if spare(i) && i != m.Active { | 173 | if spare(i) && i != m.Active { | ||
| @@ -229,11 +243,9 @@ func (m *Monitor) ToggleNamed(name string) { | |||||
| 229 | 243 | ||||
| 230 | // byName returns the configured workspace with that name, or nil. | 244 | // byName returns the configured workspace with that name, or nil. | ||
| 231 | func (m *Monitor) byName(name string) *Workspace { | 245 | func (m *Monitor) byName(name string) *Workspace { | ||
| 232 | - | for _, list := range [2][]*Workspace{m.Workspaces, m.hidden} { | 246 | + | for w := range m.all() { |
| 233 | - | for _, w := range list { | 247 | + | if w.Name == name { |
| 234 | - | if w.Name == name { | 248 | + | return w |
| 235 | - | return w | |||
| 236 | - | } | |||
| 237 | } | 249 | } | ||
| 238 | } | 250 | } | ||
| 239 | return nil | 251 | return nil | ||
| @@ -273,26 +285,7 @@ func (m *Monitor) AddWindow(id WindowID) { | |||||
| 273 | if w, _ := m.find(id); w != nil { | 285 | if w, _ := m.find(id); w != nil { | ||
| 274 | return | 286 | return | ||
| 275 | } | 287 | } | ||
| 276 | - | m.arrive(m.Current(), func(w *Workspace) { w.AddWindow(id) }) | 288 | + | m.Current().AddWindow(id) |
| 277 | - | } | |||
| 278 | - | ||||
| 279 | - | // arrive adds a new tiled window to w with add. Under fixed overflow a | |||
| 280 | - | // fullscreen window hides every other one of its workspace and keeps the | |||
| 281 | - | // view (ADR 011), so the new window goes to its home workspace instead: | |||
| 282 | - | // the origin of a fullscreen workspace, or w itself once a fullscreen | |||
| 283 | - | // window still in place there has moved to its own workspace. | |||
| 284 | - | func (m *Monitor) arrive(w *Workspace, add func(*Workspace)) { | |||
| 285 | - | switch { | |||
| 286 | - | case w.origin != nil: | |||
| 287 | - | add(w.origin) | |||
| 288 | - | case w.Overflow == OverflowFixed && w.fullscreen != 0 && m.has(w): | |||
| 289 | - | // Added first, hidden and unfocused: w is never left empty, | |||
| 290 | - | // which normalize would drop. | |||
| 291 | - | add(w) | |||
| 292 | - | m.enterFullscreen(w, w.fullscreen, m.Current() == w) | |||
| 293 | - | default: | |||
| 294 | - | add(w) | |||
| 295 | - | } | |||
| 296 | m.normalize() | 289 | m.normalize() | ||
| 297 | } | 290 | } | ||
| 298 | 291 | ||||
| @@ -306,270 +299,25 @@ func (m *Monitor) AddFloating(id WindowID, width, height int) { | |||||
| 306 | } | 299 | } | ||
| 307 | 300 | ||||
| 308 | // RemoveWindow drops the window wherever it is; focus stays on the active | 301 | // RemoveWindow drops the window wherever it is; focus stays on the active | ||
| 309 | - | // workspace, except that closing a fullscreen workspace goes back to its origin. | 302 | + | // workspace. |
| 310 | func (m *Monitor) RemoveWindow(id WindowID) { | 303 | func (m *Monitor) RemoveWindow(id WindowID) { | ||
| 311 | if w, _ := m.find(id); w != nil { | 304 | if w, _ := m.find(id); w != nil { | ||
| 312 | - | if w.origin != nil && id == w.back.id && m.has(w.origin) { | 305 | + | w.RemoveWindow(id) |
| 313 | - | // Its dialogs go home with the view. | |||
| 314 | - | origin, back := w.origin, w.back | |||
| 315 | - | w.RemoveWindow(id) | |||
| 316 | - | w.origin, w.back = nil, origPlace{} | |||
| 317 | - | m.foldInto(w, origin, back.col, false, back.tiled) | |||
| 318 | - | } else { | |||
| 319 | - | w.RemoveWindow(id) | |||
| 320 | - | } | |||
| 321 | m.normalize() | 306 | m.normalize() | ||
| 322 | } | 307 | } | ||
| 323 | } | 308 | } | ||
| 324 | 309 | ||||
| 325 | - | // SetFullscreen applies a client request. Under fixed overflow the window | 310 | + | // SetFullscreen applies a client request, in place. A client request never |
| 326 | - | // gets its own workspace, as with the bind. A client request never moves | 311 | + | // moves the user (ADR 011): the view and the focus stay where they are. |
| 327 | - | // the user (ADR 011): the view follows only a window that is focused and | |||
| 328 | - | // on screen, and a window coming back does not take the focus. | |||
| 329 | func (m *Monitor) SetFullscreen(id WindowID, on bool) { | 312 | func (m *Monitor) SetFullscreen(id WindowID, on bool) { | ||
| 330 | - | w, _ := m.find(id) | 313 | + | if w, _ := m.find(id); w != nil { |
| 331 | - | switch { | |||
| 332 | - | case w == nil: | |||
| 333 | - | case w.origin != nil && id != w.back.id: | |||
| 334 | - | // A dialog of a fullscreen workspace already floats above it; its | |||
| 335 | - | // own fullscreen would take the workspace from the window it holds. | |||
| 336 | - | case w.stashHidden && w.stashIndex(id) >= 0: | |||
| 337 | - | // A hidden stash stays hidden: its fullscreen waits for the show. | |||
| 338 | - | w.SetFullscreen(id, on) | |||
| 339 | - | case on && m.ownWorkspace(w, id): | |||
| 340 | - | focused, _ := w.Focused() | |||
| 341 | - | m.enterFullscreen(w, id, w == m.Current() && focused == id) | |||
| 342 | - | case !on && w.origin != nil && w.fullscreen == id: | |||
| 343 | - | // The user was on that window: they stay on it, back home. | |||
| 344 | - | m.leaveFullscreen(w, m.Current() == w) | |||
| 345 | - | default: | |||
| 346 | w.SetFullscreen(id, on) | 314 | w.SetFullscreen(id, on) | ||
| 347 | } | 315 | } | ||
| 348 | - | m.normalize() | |||
| 349 | } | 316 | } | ||
| 350 | 317 | ||||
| 351 | - | // ToggleFullscreen is the bind. Under fixed overflow the focused window | 318 | + | // ToggleFullscreen is the bind: the focused window goes fullscreen in place, |
| 352 | - | // moves to a new workspace below the current one, so workspace up/down | 319 | + | // or leaves it. Focus moves leave it too (Workspace.leaveCover). |
| 353 | - | // still reach the others; toggling again sends it back where it was. | 320 | + | func (m *Monitor) ToggleFullscreen() { m.Current().ToggleFullscreen() } |
| 354 | - | func (m *Monitor) ToggleFullscreen() { | |||
| 355 | - | w := m.Current() | |||
| 356 | - | id, ok := w.Focused() | |||
| 357 | - | switch { | |||
| 358 | - | case !ok: | |||
| 359 | - | case w.origin != nil && w.fullscreen == w.back.id: | |||
| 360 | - | // From its window or one of its dialogs. | |||
| 361 | - | m.leaveFullscreen(w, true) | |||
| 362 | - | case m.ownWorkspace(w, id): | |||
| 363 | - | m.enterFullscreen(w, id, true) | |||
| 364 | - | default: | |||
| 365 | - | w.ToggleFullscreen() | |||
| 366 | - | } | |||
| 367 | - | m.normalize() | |||
| 368 | - | } | |||
| 369 | - | ||||
| 370 | - | // ownWorkspace reports whether going fullscreen moves the window to its own | |||
| 371 | - | // workspace: fixed overflow hides every other window behind a fullscreen | |||
| 372 | - | // one and never scrolls, so it would leave the user stuck. Not needed when | |||
| 373 | - | // the window is alone, or already has its own workspace. | |||
| 374 | - | func (m *Monitor) ownWorkspace(w *Workspace, id WindowID) bool { | |||
| 375 | - | return w.Overflow == OverflowFixed && w.origin == nil && w.fullscreen != id && len(w.windows()) > 1 && m.has(w) | |||
| 376 | - | } | |||
| 377 | - | ||||
| 378 | - | // enterFullscreen moves window id from w to a new workspace just below the | |||
| 379 | - | // numbered one on screen, fullscreen, and shows it when show is set. It | |||
| 380 | - | // remembers the window's place in w to return there. | |||
| 381 | - | func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) { | |||
| 382 | - | fs := m.newWorkspace() | |||
| 383 | - | fs.Overflow, fs.MaxColumns = w.Overflow, w.MaxColumns | |||
| 384 | - | fs.SetSize(w.size[0], w.size[1]) | |||
| 385 | - | fs.origin = w | |||
| 386 | - | fs.back.id = id | |||
| 387 | - | if f := w.floatIndex(id); f >= 0 { | |||
| 388 | - | fl := w.Floats[f] | |||
| 389 | - | fs.back.float, fs.back.floatAt = &fl, f | |||
| 390 | - | w.RemoveWindow(id) | |||
| 391 | - | fs.AddFloating(id, fl.W, fl.H) | |||
| 392 | - | fs.Floats[len(fs.Floats)-1] = fl | |||
| 393 | - | } else if f := w.stashIndex(id); f >= 0 { | |||
| 394 | - | // It returns to its place in the stash. | |||
| 395 | - | fl := w.Stash[f] | |||
| 396 | - | fs.back.float, fs.back.stash, fs.back.col = &fl, true, f | |||
| 397 | - | w.RemoveWindow(id) | |||
| 398 | - | fs.addStash(fl) | |||
| 399 | - | } else { | |||
| 400 | - | col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) }) | |||
| 401 | - | c := w.Columns[col] | |||
| 402 | - | fs.back = origPlace{id: id, col: col, row: slices.Index(c.Windows, id), slot: c.Slot, width: c.Width, expanded: c.Expanded} | |||
| 403 | - | for _, v := range c.Windows { | |||
| 404 | - | if v != id { | |||
| 405 | - | fs.back.stacked = append(fs.back.stacked, v) | |||
| 406 | - | } | |||
| 407 | - | } | |||
| 408 | - | w.RemoveWindow(id) | |||
| 409 | - | fs.AddWindow(id) | |||
| 410 | - | fs.Columns[0].Width = c.Width | |||
| 411 | - | } | |||
| 412 | - | fs.SetFullscreen(id, true) | |||
| 413 | - | at := m.Active + 1 | |||
| 414 | - | if i := indexOf(m.Workspaces, w); i >= 0 { | |||
| 415 | - | at = i + 1 | |||
| 416 | - | } | |||
| 417 | - | m.Workspaces = slices.Insert(m.Workspaces, at, fs) | |||
| 418 | - | if at <= m.Active { | |||
| 419 | - | m.Active++ | |||
| 420 | - | } | |||
| 421 | - | if show { | |||
| 422 | - | m.show(fs) | |||
| 423 | - | } | |||
| 424 | - | } | |||
| 425 | - | ||||
| 426 | - | // leaveFullscreen sends the window of fs back to its place in the origin, | |||
| 427 | - | // with the windows opened there meanwhile (its dialogs) just after it, and | |||
| 428 | - | // drops fs; the view goes along when fs was on screen. focus is set when | |||
| 429 | - | // the user was on the window: it gets the focus back there. | |||
| 430 | - | func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||
| 431 | - | if m.fullscreenHome(); fs.origin == nil { | |||
| 432 | - | fs.SetFullscreen(fs.fullscreen, false) | |||
| 433 | - | return | |||
| 434 | - | } | |||
| 435 | - | origin, back, id := fs.origin, fs.back, fs.back.id | |||
| 436 | - | fs.origin, fs.back = nil, origPlace{} | |||
| 437 | - | fs.RemoveWindow(id) | |||
| 438 | - | // A closure: back.col is where the window lands, known after the switch. | |||
| 439 | - | defer func() { m.foldInto(fs, origin, back.col+1, focus, back.tiled) }() | |||
| 440 | - | switch prev := origin.Focus; { | |||
| 441 | - | case back.stash: | |||
| 442 | - | at := min(back.col, len(origin.Stash)) | |||
| 443 | - | origin.Stash = slices.Insert(origin.Stash, at, *back.float) | |||
| 444 | - | if focus { | |||
| 445 | - | origin.stashAt = at | |||
| 446 | - | origin.showStash() | |||
| 447 | - | } else if at <= origin.stashAt && len(origin.Stash) > 1 { | |||
| 448 | - | origin.stashAt++ | |||
| 449 | - | } | |||
| 450 | - | back.col = len(origin.Columns) | |||
| 451 | - | case back.float != nil: | |||
| 452 | - | // Restore the original position before an optional focus-driven raise. | |||
| 453 | - | at := min(back.floatAt, len(origin.Floats)) | |||
| 454 | - | if !focus && origin.floatFocus && len(origin.Floats) > 0 { | |||
| 455 | - | at = min(at, len(origin.Floats)-1) | |||
| 456 | - | } | |||
| 457 | - | origin.Floats = slices.Insert(origin.Floats, at, *back.float) | |||
| 458 | - | origin.reconcileFloats() | |||
| 459 | - | if focus { | |||
| 460 | - | origin.FocusID(id) | |||
| 461 | - | } | |||
| 462 | - | case slices.ContainsFunc(origin.Columns, back.holdsStack): | |||
| 463 | - | back.col = slices.IndexFunc(origin.Columns, back.holdsStack) | |||
| 464 | - | c := &origin.Columns[back.col] | |||
| 465 | - | // Below the windows that were above it and are still there. | |||
| 466 | - | row := 0 | |||
| 467 | - | for _, v := range back.stacked[:back.row] { | |||
| 468 | - | if slices.Contains(c.Windows, v) { | |||
| 469 | - | row++ | |||
| 470 | - | } | |||
| 471 | - | } | |||
| 472 | - | row = min(row, len(c.Windows)) | |||
| 473 | - | c.Windows = slices.Insert(c.Windows, row, id) | |||
| 474 | - | if focus { | |||
| 475 | - | origin.Focus, c.Focus, origin.floatFocus, origin.stashFocus = back.col, row, false, false | |||
| 476 | - | origin.raiseColumns() | |||
| 477 | - | } else if row <= c.Focus && len(c.Windows) > 1 { | |||
| 478 | - | c.Focus++ | |||
| 479 | - | } | |||
| 480 | - | default: | |||
| 481 | - | at := min(back.col, len(origin.Columns)) | |||
| 482 | - | // Its column is gone: it comes back expanded, unless another | |||
| 483 | - | // column was expanded meanwhile. | |||
| 484 | - | expanded := back.expanded && !slices.ContainsFunc(origin.Columns, func(c Column) bool { return c.Expanded }) | |||
| 485 | - | origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded}) | |||
| 486 | - | if focus { | |||
| 487 | - | origin.floatFocus, origin.stashFocus = false, false | |||
| 488 | - | origin.raiseColumns() | |||
| 489 | - | } else if len(origin.Columns) > 1 { | |||
| 490 | - | origin.Focus = prev | |||
| 491 | - | if at <= prev { | |||
| 492 | - | origin.Focus++ | |||
| 493 | - | } | |||
| 494 | - | } | |||
| 495 | - | } | |||
| 496 | - | } | |||
| 497 | - | ||||
| 498 | - | // foldInto moves what is left in fs (windows opened while fullscreen: its | |||
| 499 | - | // dialogs) to origin, tiled ones from column at, floating ones on top, and | |||
| 500 | - | // drops fs. The view goes along when fs was on screen. | |||
| 501 | - | func (m *Monitor) foldInto(fs, origin *Workspace, at int, focus bool, tiled []WindowID) { | |||
| 502 | - | shown := m.Current() == fs | |||
| 503 | - | at = min(max(at, 0), len(origin.Columns)) | |||
| 504 | - | for _, c := range slices.Backward(fs.Columns) { | |||
| 505 | - | origin.Columns = slices.Insert(origin.Columns, at, Column{Windows: c.Windows, Width: c.Width, Focus: c.Focus}) | |||
| 506 | - | if at <= origin.Focus && len(origin.Columns) > 1 { | |||
| 507 | - | origin.Focus++ | |||
| 508 | - | } | |||
| 509 | - | } | |||
| 510 | - | var floats []Float | |||
| 511 | - | for _, f := range fs.Floats { | |||
| 512 | - | if !slices.Contains(tiled, f.ID) { | |||
| 513 | - | floats = append(floats, f) | |||
| 514 | - | continue | |||
| 515 | - | } | |||
| 516 | - | origin.Columns = slices.Insert(origin.Columns, at, Column{Windows: []WindowID{f.ID}}) | |||
| 517 | - | if at <= origin.Focus && len(origin.Columns) > 1 { | |||
| 518 | - | origin.Focus++ | |||
| 519 | - | } | |||
| 520 | - | at++ | |||
| 521 | - | } | |||
| 522 | - | if focus || shown { | |||
| 523 | - | origin.Floats = append(origin.Floats, floats...) | |||
| 524 | - | origin.floatFocus = origin.floatFocus || len(floats) > 0 && fs.floatFocus | |||
| 525 | - | } else { | |||
| 526 | - | // The user is elsewhere: below, the focused float keeps the focus. | |||
| 527 | - | origin.Floats = append(floats, origin.Floats...) | |||
| 528 | - | } | |||
| 529 | - | origin.reconcileFloats() | |||
| 530 | - | // Windows stashed there join the end of the stash. | |||
| 531 | - | for _, f := range fs.Stash { | |||
| 532 | - | origin.Stash = append(origin.Stash, f.rehome()) | |||
| 533 | - | } | |||
| 534 | - | fs.Columns, fs.Floats, fs.Stash = nil, nil, nil | |||
| 535 | - | origin.scroll() | |||
| 536 | - | m.take(fs) | |||
| 537 | - | if shown { | |||
| 538 | - | m.show(origin) | |||
| 539 | - | } | |||
| 540 | - | } | |||
| 541 | - | ||||
| 542 | - | // awayInSlot reports whether id is away from slot n of w, fullscreen in a | |||
| 543 | - | // workspace that returns it there. | |||
| 544 | - | func (m *Monitor) awayInSlot(w *Workspace, id WindowID, n int) bool { | |||
| 545 | - | for _, fs := range m.all() { | |||
| 546 | - | if fs.origin == w && fs.back.id == id && fs.back.slot == n { | |||
| 547 | - | return true | |||
| 548 | - | } | |||
| 549 | - | } | |||
| 550 | - | return false | |||
| 551 | - | } | |||
| 552 | - | ||||
| 553 | - | // holdsStack reports whether c holds a window of the stack left behind. | |||
| 554 | - | func (p origPlace) holdsStack(c Column) bool { | |||
| 555 | - | return slices.ContainsFunc(c.Windows, func(v WindowID) bool { return slices.Contains(p.stacked, v) }) | |||
| 556 | - | } | |||
| 557 | - | ||||
| 558 | - | // fullscreenHome keeps each fullscreen workspace linked to its origin only | |||
| 559 | - | // while its window is still there and fullscreen, and the origin is still | |||
| 560 | - | // on this monitor. Otherwise (the window moved out or activated away from | |||
| 561 | - | // fullscreen, the origin gone to another monitor) it is a normal workspace. | |||
| 562 | - | // Other windows opened there (a dialog) stay when the window returns. | |||
| 563 | - | func (m *Monitor) fullscreenHome() { | |||
| 564 | - | for _, w := range m.all() { | |||
| 565 | - | if w.origin == nil { | |||
| 566 | - | continue | |||
| 567 | - | } | |||
| 568 | - | if !m.has(w.origin) || w.fullscreen != w.back.id || !w.has(w.back.id) { | |||
| 569 | - | w.origin, w.back = nil, origPlace{} | |||
| 570 | - | } | |||
| 571 | - | } | |||
| 572 | - | } | |||
| 573 | 321 | ||||
| 574 | // MoveToWorkspace moves the focused column (or only the focused window) to | 322 | // MoveToWorkspace moves the focused column (or only the focused window) to | ||
| 575 | // numbered workspace index i. A floating window always moves alone. Focus | 323 | // numbered workspace index i. A floating window always moves alone. Focus | ||
| @@ -581,35 +329,19 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) { | |||||
| 581 | if !ok || to == cur { | 329 | if !ok || to == cur { | ||
| 582 | return | 330 | return | ||
| 583 | } | 331 | } | ||
| 584 | - | if m.followMove && to.origin != nil { | |||
| 585 | - | // The view follows the window: a fullscreen workspace would hide | |||
| 586 | - | // it, so its window goes home first and the move lands there. | |||
| 587 | - | origin := to.origin | |||
| 588 | - | m.leaveFullscreen(to, false) | |||
| 589 | - | to = origin | |||
| 590 | - | if i = indexOf(m.Workspaces, to); i < 0 || to == cur { | |||
| 591 | - | return | |||
| 592 | - | } | |||
| 593 | - | } | |||
| 594 | if f := cur.floatIndex(id); f >= 0 { | 332 | if f := cur.floatIndex(id); f >= 0 { | ||
| 595 | fl := cur.Floats[f] | 333 | fl := cur.Floats[f] | ||
| 596 | cur.RemoveWindow(id) | 334 | cur.RemoveWindow(id) | ||
| 597 | to.AddFloating(id, fl.W, fl.H) | 335 | to.AddFloating(id, fl.W, fl.H) | ||
| 598 | to.Floats[len(to.Floats)-1] = fl | 336 | to.Floats[len(to.Floats)-1] = fl | ||
| 599 | - | if to.origin == nil { | 337 | + | // As receive: a fullscreen window would hide it. |
| 600 | - | // As receive: an in-place fullscreen would hide it. | 338 | + | to.Activate(id) |
| 601 | - | to.Activate(id) | |||
| 602 | - | } else { | |||
| 603 | - | to.FocusID(id) | |||
| 604 | - | } | |||
| 605 | } else if f := cur.stashIndex(id); f >= 0 { | 339 | } else if f := cur.stashIndex(id); f >= 0 { | ||
| 606 | // It joins the end of the stash there, shown and selected. | 340 | // It joins the end of the stash there, shown and selected. | ||
| 607 | fl := cur.Stash[f].rehome() | 341 | fl := cur.Stash[f].rehome() | ||
| 608 | cur.RemoveWindow(id) | 342 | cur.RemoveWindow(id) | ||
| 609 | to.addStash(fl) | 343 | to.addStash(fl) | ||
| 610 | - | if to.origin == nil { | 344 | + | to.Activate(id) |
| 611 | - | to.Activate(id) | |||
| 612 | - | } | |||
| 613 | } else { | 345 | } else { | ||
| 614 | col := Column{Windows: []WindowID{id}} | 346 | col := Column{Windows: []WindowID{id}} | ||
| 615 | ok := true | 347 | ok := true | ||
| @@ -631,17 +363,11 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) { | |||||
| 631 | } | 363 | } | ||
| 632 | 364 | ||||
| 633 | // receive adds a column moved in from another workspace and focuses it: at | 365 | // receive adds a column moved in from another workspace and focuses it: at | ||
| 634 | - | // index at, or where a new window goes when at < 0. An in-place fullscreen | 366 | + | // index at, or where a new window goes when at < 0. A fullscreen window |
| 635 | - | // is left. On a fixed-overflow fullscreen workspace its windows float, | 367 | + | // there is left, so the moved window is seen. |
| 636 | - | // hidden until the fullscreen window leaves: it stays exclusive. | |||
| 637 | func (w *Workspace) receive(col Column, at int) { | 368 | func (w *Workspace) receive(col Column, at int) { | ||
| 638 | id := col.Windows[col.Focus] | 369 | id := col.Windows[col.Focus] | ||
| 639 | switch { | 370 | switch { | ||
| 640 | - | case w.origin != nil: | |||
| 641 | - | for _, v := range col.Windows { | |||
| 642 | - | w.joinFullscreen(v) | |||
| 643 | - | } | |||
| 644 | - | return | |||
| 645 | case at < 0: | 371 | case at < 0: | ||
| 646 | w.addColumn(col) | 372 | w.addColumn(col) | ||
| 647 | default: | 373 | default: | ||
| @@ -650,23 +376,6 @@ func (w *Workspace) receive(col Column, at int) { | |||||
| 650 | w.Activate(id) | 376 | w.Activate(id) | ||
| 651 | } | 377 | } | ||
| 652 | 378 | ||||
| 653 | - | // joinFullscreen adds a tiled window to a fullscreen workspace fs: it | |||
| 654 | - | // floats, hidden while the window is fullscreen, and tiles again once home. | |||
| 655 | - | func (fs *Workspace) joinFullscreen(id WindowID) { | |||
| 656 | - | fs.AddFloating(id, 0, 0) | |||
| 657 | - | fs.back.tiled = append(fs.back.tiled, id) | |||
| 658 | - | } | |||
| 659 | - | ||||
| 660 | - | // landing is the workspace on screen for a window the user moves here. A | |||
| 661 | - | // fullscreen workspace hides everything but its window, so it goes home | |||
| 662 | - | // first: the moved window must be seen. | |||
| 663 | - | func (m *Monitor) landing() *Workspace { | |||
| 664 | - | if fs := m.Current(); fs.origin != nil { | |||
| 665 | - | m.leaveFullscreen(fs, false) | |||
| 666 | - | } | |||
| 667 | - | return m.Current() | |||
| 668 | - | } | |||
| 669 | - | ||||
| 670 | // Focused is the focused window of the workspace on screen; in the | 379 | // Focused is the focused window of the workspace on screen; in the | ||
| 671 | // overview, the selected stash card when it is one. | 380 | // overview, the selected stash card when it is one. | ||
| 672 | func (m *Monitor) Focused() (WindowID, bool) { | 381 | func (m *Monitor) Focused() (WindowID, bool) { | ||
| @@ -683,7 +392,7 @@ func (m *Monitor) Layout() []Placement { | |||||
| 683 | } | 392 | } | ||
| 684 | var result []Placement | 393 | var result []Placement | ||
| 685 | cur := m.Current() | 394 | cur := m.Current() | ||
| 686 | - | for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) { | 395 | + | for w := range m.all() { |
| 687 | if w == cur { | 396 | if w == cur { | ||
| 688 | result = append(result, w.Layout()...) | 397 | result = append(result, w.Layout()...) | ||
| 689 | continue | 398 | continue | ||
| @@ -774,15 +483,9 @@ func (m *Monitor) applyNamed() { | |||||
| 774 | if s.MaxColumns > 0 { | 483 | if s.MaxColumns > 0 { | ||
| 775 | w.SetMaxColumns(s.MaxColumns) | 484 | w.SetMaxColumns(s.MaxColumns) | ||
| 776 | } | 485 | } | ||
| 777 | - | // Named workspaces take their configured size, a fullscreen sibling | 486 | + | // Named workspaces take their configured size; any other keeps its own. |
| 778 | - | // that of its named origin. Any other workspace keeps its own: a | 487 | + | if w.Name != "" { |
| 779 | - | // sibling detached from its origin must not resize on reload. | |||
| 780 | - | switch { | |||
| 781 | - | case w.Name != "": | |||
| 782 | w.SetSize(s.Size[0], s.Size[1]) | 488 | w.SetSize(s.Size[0], s.Size[1]) | ||
| 783 | - | case w.origin != nil && w.origin.Name != "": | |||
| 784 | - | size := specs[w.origin.Name].Size | |||
| 785 | - | w.SetSize(size[0], size[1]) | |||
| 786 | } | 489 | } | ||
| 787 | }) | 490 | }) | ||
| 788 | } | 491 | } | ||
| @@ -795,15 +498,6 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) { | |||||
| 795 | for _, s := range specs { | 498 | for _, s := range specs { | ||
| 796 | want[s.Name] = s | 499 | want[s.Name] = s | ||
| 797 | } | 500 | } | ||
| 798 | - | // A sibling inherits its named origin's override only while that | |||
| 799 | - | // declaration exists. Removing it restores monitor-sized geometry. | |||
| 800 | - | for _, w := range m.Workspaces { | |||
| 801 | - | if w.origin != nil && w.origin.Name != "" { | |||
| 802 | - | if _, ok := want[w.origin.Name]; !ok { | |||
| 803 | - | w.SetSize(0, 0) | |||
| 804 | - | } | |||
| 805 | - | } | |||
| 806 | - | } | |||
| 807 | byName := map[string]*Workspace{} | 501 | byName := map[string]*Workspace{} | ||
| 808 | for _, w := range m.Workspaces { | 502 | for _, w := range m.Workspaces { | ||
| 809 | if _, ok := want[w.Name]; w.Name != "" && !ok { | 503 | if _, ok := want[w.Name]; w.Name != "" && !ok { | ||
M internal/core/monitor_test.go +73 -448
| @@ -2,7 +2,6 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "reflect" | |
| 5 | - | "slices" | |
| 6 | 5 | "testing" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -123,20 +122,6 @@ func TestMonitorLayoutHidesOtherWorkspaces(t *testing.T) { | |||
| 123 | 122 | } | |
| 124 | 123 | } | |
| 125 | 124 | ||
| 126 | - | func TestMonitorFullscreenRequestStaysPut(t *testing.T) { | |
| 127 | - | m := monitor() | |
| 128 | - | m.AddWindow(1) | |
| 129 | - | m.FocusNumber(2) | |
| 130 | - | m.AddWindow(2) | |
| 131 | - | m.SetFullscreen(1, true) | |
| 132 | - | if m.Active != 1 { | |
| 133 | - | t.Fatal("client request switched workspace") | |
| 134 | - | } | |
| 135 | - | if m.Workspaces[0].fullscreen != 1 { | |
| 136 | - | t.Fatal("fullscreen not applied") | |
| 137 | - | } | |
| 138 | - | } | |
| 139 | - | ||
| 140 | 125 | func TestMonitorActions(t *testing.T) { | |
| 141 | 126 | m := monitor() | |
| 142 | 127 | m.AddWindow(1) | |
| @@ -291,19 +276,6 @@ func TestWorkspaceArg(t *testing.T) { | |||
| 291 | 276 | } | |
| 292 | 277 | } | |
| 293 | 278 | ||
| 294 | - | func TestFullscreenRequestKeepsFocus(t *testing.T) { | |
| 295 | - | m := monitor() | |
| 296 | - | m.AddWindow(1) | |
| 297 | - | m.AddWindow(2) | |
| 298 | - | m.SetFullscreen(1, true) | |
| 299 | - | if id, _ := m.Focused(); id != 2 { | |
| 300 | - | t.Fatal(id) | |
| 301 | - | } | |
| 302 | - | if m.Current().fullscreen != 1 { | |
| 303 | - | t.Fatal("not fullscreen") | |
| 304 | - | } | |
| 305 | - | } | |
| 306 | - | ||
| 307 | 279 | func TestLonePresetColumnKeepsWidth(t *testing.T) { | |
| 308 | 280 | m := monitor() | |
| 309 | 281 | m.SetPresets([]Width{{Num: 1, Den: 2}}) | |
| @@ -411,386 +383,6 @@ func TestNamedWorkspaceSettings(t *testing.T) { | |||
| 411 | 383 | } | |
| 412 | 384 | } | |
| 413 | 385 | ||
| 414 | - | // The expanded column comes back expanded from its fullscreen workspace. | |
| 415 | - | func TestMonitorFixedFullscreenKeepsExpanded(t *testing.T) { | |
| 416 | - | m := monitor() | |
| 417 | - | m.SetOverflow(OverflowFixed) | |
| 418 | - | m.SetMaxColumns(3) | |
| 419 | - | for id := WindowID(1); id <= 3; id++ { | |
| 420 | - | m.AddWindow(id) | |
| 421 | - | } | |
| 422 | - | m.Current().FocusID(2) | |
| 423 | - | m.Apply(ActionCycleColumnWidth) | |
| 424 | - | m.ToggleFullscreen() | |
| 425 | - | m.ToggleFullscreen() | |
| 426 | - | if c := m.Current().Columns; len(c) != 3 || !c[1].Expanded || c[0].Expanded || c[2].Expanded { | |
| 427 | - | t.Fatalf("%+v", c) | |
| 428 | - | } | |
| 429 | - | } | |
| 430 | - | ||
| 431 | - | // A window of an expanded stacked column comes back expanded when the rest | |
| 432 | - | // of its column closed meanwhile, unless another column took the expansion. | |
| 433 | - | func TestMonitorFixedFullscreenStackedExpanded(t *testing.T) { | |
| 434 | - | for _, other := range []bool{false, true} { | |
| 435 | - | m := monitor() | |
| 436 | - | m.SetOverflow(OverflowFixed) | |
| 437 | - | m.SetMaxColumns(3) | |
| 438 | - | for id := WindowID(1); id <= 4; id++ { | |
| 439 | - | m.AddWindow(id) | |
| 440 | - | } | |
| 441 | - | w := m.Current() | |
| 442 | - | w.FocusID(2) | |
| 443 | - | w.stackWindow(2, 1) // column {1, 2}, then {3}, {4} | |
| 444 | - | w.FocusID(2) | |
| 445 | - | m.Apply(ActionCycleColumnWidth) | |
| 446 | - | m.ToggleFullscreen() | |
| 447 | - | w.RemoveWindow(1) | |
| 448 | - | if other { | |
| 449 | - | w.FocusID(3) | |
| 450 | - | w.CycleWidth() | |
| 451 | - | } | |
| 452 | - | m.ToggleFullscreen() | |
| 453 | - | c := m.Current().Columns | |
| 454 | - | if len(c) != 3 || c[0].Windows[0] != 2 || c[0].Expanded == other || c[1].Expanded != other || c[2].Expanded { | |
| 455 | - | t.Fatalf("other=%v %+v", other, c) | |
| 456 | - | } | |
| 457 | - | } | |
| 458 | - | } | |
| 459 | - | ||
| 460 | - | func TestMonitorFixedFullscreenOwnWorkspace(t *testing.T) { | |
| 461 | - | m := monitor() | |
| 462 | - | m.SetOverflow(OverflowFixed) | |
| 463 | - | for id := WindowID(1); id <= 3; id++ { | |
| 464 | - | m.AddWindow(id) | |
| 465 | - | } | |
| 466 | - | m.Current().FocusID(2) | |
| 467 | - | m.ToggleFullscreen() | |
| 468 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {2}, {}}) || m.Active != 1 { | |
| 469 | - | t.Fatal(got, m.Active) | |
| 470 | - | } | |
| 471 | - | if p := m.Layout(); !slices.ContainsFunc(p, func(p Placement) bool { return p.ID == 2 && p.Fullscreen && !p.Hidden }) { | |
| 472 | - | t.Fatal(p) | |
| 473 | - | } | |
| 474 | - | m.Apply(ActionFocusWorkspaceUp) | |
| 475 | - | if m.Active != 0 { | |
| 476 | - | t.Fatal("stuck on the fullscreen workspace") | |
| 477 | - | } | |
| 478 | - | m.Apply(ActionFocusWorkspaceDown) | |
| 479 | - | m.ToggleFullscreen() | |
| 480 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) || m.Active != 0 { | |
| 481 | - | t.Fatal(got, m.Active) | |
| 482 | - | } | |
| 483 | - | if id, _ := m.Focused(); id != 2 { | |
| 484 | - | t.Fatal("focus", id) | |
| 485 | - | } | |
| 486 | - | ||
| 487 | - | // A client request moves it too, without taking the user along | |
| 488 | - | // unless the window is the focused one on screen. | |
| 489 | - | m.Current().FocusID(1) | |
| 490 | - | m.SetFullscreen(3, true) | |
| 491 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {3}, {}}) || m.Active != 0 { | |
| 492 | - | t.Fatal(got, m.Active) | |
| 493 | - | } | |
| 494 | - | m.SetFullscreen(3, false) | |
| 495 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) { | |
| 496 | - | t.Fatal(got) | |
| 497 | - | } | |
| 498 | - | ||
| 499 | - | // Closing the fullscreen window drops its workspace and returns. | |
| 500 | - | m.Current().FocusID(3) | |
| 501 | - | m.ToggleFullscreen() | |
| 502 | - | m.RemoveWindow(3) | |
| 503 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {}}) || m.Active != 0 { | |
| 504 | - | t.Fatal(got, m.Active) | |
| 505 | - | } | |
| 506 | - | ||
| 507 | - | // Scroll overflow keeps the niri behaviour: same workspace. | |
| 508 | - | m.SetOverflow(OverflowScroll) | |
| 509 | - | m.ToggleFullscreen() | |
| 510 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {}}) { | |
| 511 | - | t.Fatal(got) | |
| 512 | - | } | |
| 513 | - | } | |
| 514 | - | ||
| 515 | - | // The fullscreen workspace link survives only while it holds just its | |
| 516 | - | // window; exits restore the exact place and never move the user's focus. | |
| 517 | - | func TestMonitorFixedFullscreenEdges(t *testing.T) { | |
| 518 | - | fixed := func() *Monitor { | |
| 519 | - | m := monitor() | |
| 520 | - | m.SetOverflow(OverflowFixed) | |
| 521 | - | for id := WindowID(1); id <= 3; id++ { | |
| 522 | - | m.AddWindow(id) | |
| 523 | - | } | |
| 524 | - | return m | |
| 525 | - | } | |
| 526 | - | t.Run("a window opened there tiles at home, the view stays", func(t *testing.T) { | |
| 527 | - | m := fixed() | |
| 528 | - | m.Current().FocusID(2) | |
| 529 | - | m.ToggleFullscreen() | |
| 530 | - | m.AddWindow(4) | |
| 531 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3, 4}, {2}, {}}) || m.Active != 1 { | |
| 532 | - | t.Fatal(got, m.Active) | |
| 533 | - | } | |
| 534 | - | if id, _ := m.Focused(); id != 2 { | |
| 535 | - | t.Fatal("focus", id) | |
| 536 | - | } | |
| 537 | - | m.SetFullscreen(2, false) | |
| 538 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3, 4}, {}}) || m.Active != 0 { | |
| 539 | - | t.Fatal(got, m.Active) | |
| 540 | - | } | |
| 541 | - | }) | |
| 542 | - | t.Run("a window opened over a lone fullscreen moves it to its own workspace", func(t *testing.T) { | |
| 543 | - | for _, client := range []bool{false, true} { | |
| 544 | - | m := monitor() | |
| 545 | - | m.SetOverflow(OverflowFixed) | |
| 546 | - | m.AddWindow(1) | |
| 547 | - | if client { | |
| 548 | - | m.SetFullscreen(1, true) | |
| 549 | - | } else { | |
| 550 | - | m.ToggleFullscreen() | |
| 551 | - | } | |
| 552 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1}, {}}) || m.Current().fullscreen != 1 { | |
| 553 | - | t.Fatal("in place", got) | |
| 554 | - | } | |
| 555 | - | m.AddWindow(2) | |
| 556 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{2}, {1}, {}}) || m.Active != 1 { | |
| 557 | - | t.Fatal(client, got, m.Active) | |
| 558 | - | } | |
| 559 | - | if id, _ := m.Focused(); id != 1 || m.Current().cover() != 1 { | |
| 560 | - | t.Fatal("focus", id) | |
| 561 | - | } | |
| 562 | - | m.SetFullscreen(1, false) | |
| 563 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {}}) || m.Active != 0 { | |
| 564 | - | t.Fatal(client, got, m.Active) | |
| 565 | - | } | |
| 566 | - | if id, _ := m.Focused(); id != 1 { | |
| 567 | - | t.Fatal("focus after", id) | |
| 568 | - | } | |
| 569 | - | } | |
| 570 | - | }) | |
| 571 | - | t.Run("a window opened over a lone fullscreen off screen stays there", func(t *testing.T) { | |
| 572 | - | m := monitor() | |
| 573 | - | m.SetOverflow(OverflowFixed) | |
| 574 | - | m.AddWindow(1) | |
| 575 | - | m.ToggleFullscreen() | |
| 576 | - | m.Focus(1) | |
| 577 | - | m.AddWindow(2) | |
| 578 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1}, {2}, {}}) || m.Active != 1 { | |
| 579 | - | t.Fatal(got, m.Active) | |
| 580 | - | } | |
| 581 | - | }) | |
| 582 | - | t.Run("arrivals over a lone fullscreen: slot, named workspace, float", func(t *testing.T) { | |
| 583 | - | for _, tc := range []struct { | |
| 584 | - | name string | |
| 585 | - | setup func(m *Monitor) *Workspace | |
| 586 | - | add func(w *Workspace) | |
| 587 | - | }{ | |
| 588 | - | {"slot", func(m *Monitor) *Workspace { m.AddWindow(1); return m.Current() }, | |
| 589 | - | func(w *Workspace) { w.AddSlotWindow(2, 1, Width{}) }}, | |
| 590 | - | {"hidden named", func(m *Monitor) *Workspace { | |
| 591 | - | m.SetNamed([]NamedWorkspace{{Name: "dev", Overflow: OverflowFixed}}) | |
| 592 | - | m.Apply("workspace dev") | |
| 593 | - | m.AddWindow(1) | |
| 594 | - | return m.Current() | |
| 595 | - | }, func(w *Workspace) { w.AddWindow(2) }}, | |
| 596 | - | {"float", func(m *Monitor) *Workspace { m.AddFloating(1, 10, 10); return m.Current() }, | |
| 597 | - | func(w *Workspace) { w.AddWindow(2) }}, | |
| 598 | - | } { | |
| 599 | - | m := monitor() | |
| 600 | - | m.SetOverflow(OverflowFixed) | |
| 601 | - | home := tc.setup(m) | |
| 602 | - | m.SetFullscreen(1, true) | |
| 603 | - | m.arrive(home, tc.add) | |
| 604 | - | if fs := m.Current(); fs == home || fs.cover() != 1 || fs.origin != home { | |
| 605 | - | t.Fatal(tc.name, "fullscreen not on its own workspace", windows(m)) | |
| 606 | - | } | |
| 607 | - | if id, _ := m.Focused(); id != 1 || !home.has(2) || home.has(1) { | |
| 608 | - | t.Fatal(tc.name, "focus", id, home.windows()) | |
| 609 | - | } | |
| 610 | - | m.SetFullscreen(1, false) | |
| 611 | - | if m.Current() != home || !home.has(1) || !home.has(2) { | |
| 612 | - | t.Fatal(tc.name, "not home", windows(m), home.windows()) | |
| 613 | - | } | |
| 614 | - | if id, _ := m.Focused(); id != 1 { | |
| 615 | - | t.Fatal(tc.name, "focus after", id) | |
| 616 | - | } | |
| 617 | - | } | |
| 618 | - | }) | |
| 619 | - | t.Run("a joined window lands after its stack when columns shifted", func(t *testing.T) { | |
| 620 | - | m := fixed() | |
| 621 | - | w := m.Current() | |
| 622 | - | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{5}}, {Windows: []WindowID{2, 3}}, {Windows: []WindowID{6}}} | |
| 623 | - | w.FocusID(3) | |
| 624 | - | m.ToggleFullscreen() | |
| 625 | - | m.Current().joinFullscreen(7) | |
| 626 | - | m.RemoveWindow(1) | |
| 627 | - | m.ToggleFullscreen() | |
| 628 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{5, 2, 3, 7, 6}, {}}) { | |
| 629 | - | t.Fatal(got) | |
| 630 | - | } | |
| 631 | - | }) | |
| 632 | - | t.Run("the bind from a dialog returns the fullscreen window", func(t *testing.T) { | |
| 633 | - | m := fixed() | |
| 634 | - | m.Current().FocusID(2) | |
| 635 | - | m.ToggleFullscreen() | |
| 636 | - | m.AddFloating(4, 10, 10) // focused dialog | |
| 637 | - | m.ToggleFullscreen() | |
| 638 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) || m.Current().floatIndex(4) != 0 { | |
| 639 | - | t.Fatal(got, m.Current().Floats) | |
| 640 | - | } | |
| 641 | - | }) | |
| 642 | - | t.Run("a dialog asking fullscreen keeps the link", func(t *testing.T) { | |
| 643 | - | m := fixed() | |
| 644 | - | m.Current().FocusID(2) | |
| 645 | - | m.ToggleFullscreen() | |
| 646 | - | m.AddFloating(4, 10, 10) | |
| 647 | - | m.SetFullscreen(4, true) | |
| 648 | - | m.SetFullscreen(4, false) | |
| 649 | - | m.ToggleFullscreen() | |
| 650 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) { | |
| 651 | - | t.Fatal(got) | |
| 652 | - | } | |
| 653 | - | }) | |
| 654 | - | t.Run("moved in by hand it floats", func(t *testing.T) { | |
| 655 | - | m := fixed() | |
| 656 | - | m.Current().FocusID(2) | |
| 657 | - | m.ToggleFullscreen() | |
| 658 | - | m.Apply(ActionFocusWorkspaceUp) | |
| 659 | - | m.Current().FocusID(1) | |
| 660 | - | m.Apply(ActionMoveWindowToWorkspaceDown) | |
| 661 | - | if fs := m.Workspaces[1]; fs.floatIndex(1) != 0 || fs.origin == nil { | |
| 662 | - | t.Fatal(fs.Floats) | |
| 663 | - | } | |
| 664 | - | }) | |
| 665 | - | t.Run("a moved column floats window by window", func(t *testing.T) { | |
| 666 | - | m := fixed() | |
| 667 | - | m.Current().FocusID(2) | |
| 668 | - | m.ToggleFullscreen() | |
| 669 | - | m.Apply(ActionFocusWorkspaceUp) | |
| 670 | - | w := m.Current() | |
| 671 | - | ids := w.windows() | |
| 672 | - | w.Columns = []Column{{Windows: ids}} | |
| 673 | - | w.Focus = 0 | |
| 674 | - | m.Apply(ActionMoveColumnToWorkspaceDown) | |
| 675 | - | fs := m.Workspaces[1] | |
| 676 | - | for _, id := range ids { | |
| 677 | - | if fs.floatIndex(id) < 0 { | |
| 678 | - | t.Fatal(id, fs.Floats) | |
| 679 | - | } | |
| 680 | - | } | |
| 681 | - | if fs.origin == nil || len(ids) < 2 { | |
| 682 | - | t.Fatal(fs.origin, ids) | |
| 683 | - | } | |
| 684 | - | }) | |
| 685 | - | t.Run("closing it brings its dialogs home, below a focused float", func(t *testing.T) { | |
| 686 | - | m := fixed() | |
| 687 | - | m.Current().FocusID(2) | |
| 688 | - | m.ToggleFullscreen() | |
| 689 | - | m.AddFloating(4, 10, 10) | |
| 690 | - | m.Apply(ActionFocusWorkspaceUp) | |
| 691 | - | m.AddFloating(9, 10, 10) | |
| 692 | - | m.RemoveWindow(2) | |
| 693 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {}}) || m.Current().floatIndex(4) != 0 { | |
| 694 | - | t.Fatal(got, m.Current().Floats) | |
| 695 | - | } | |
| 696 | - | if id, _ := m.Focused(); id != 9 { | |
| 697 | - | t.Fatal("focus", id) | |
| 698 | - | } | |
| 699 | - | }) | |
| 700 | - | t.Run("stack of three keeps its column", func(t *testing.T) { | |
| 701 | - | m := fixed() | |
| 702 | - | w := m.Current() | |
| 703 | - | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3, 5}}} | |
| 704 | - | w.FocusID(3) | |
| 705 | - | m.ToggleFullscreen() | |
| 706 | - | m.RemoveWindow(2) | |
| 707 | - | m.ToggleFullscreen() | |
| 708 | - | if len(w.Columns) != 2 || !slices.Equal(w.Columns[1].Windows, []WindowID{3, 5}) { | |
| 709 | - | t.Fatal(w.Columns) | |
| 710 | - | } | |
| 711 | - | }) | |
| 712 | - | t.Run("client exit keeps the user's focus", func(t *testing.T) { | |
| 713 | - | m := fixed() | |
| 714 | - | m.Current().FocusID(1) | |
| 715 | - | m.SetFullscreen(3, true) | |
| 716 | - | m.SetFullscreen(3, false) | |
| 717 | - | if id, _ := m.Focused(); id != 1 || m.Active != 0 { | |
| 718 | - | t.Fatal("focus moved to", id, m.Active) | |
| 719 | - | } | |
| 720 | - | m.Current().FocusID(3) | |
| 721 | - | m.SetFullscreen(1, true) | |
| 722 | - | m.SetFullscreen(1, false) | |
| 723 | - | if id, _ := m.Focused(); id != 3 { | |
| 724 | - | t.Fatal("focus moved to", id) | |
| 725 | - | } | |
| 726 | - | }) | |
| 727 | - | t.Run("stacked slot window returns to its row", func(t *testing.T) { | |
| 728 | - | m := fixed() | |
| 729 | - | w := m.Current() | |
| 730 | - | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Slot: 2}} | |
| 731 | - | w.FocusID(3) | |
| 732 | - | m.ToggleFullscreen() | |
| 733 | - | m.ToggleFullscreen() | |
| 734 | - | if !reflect.DeepEqual(w.Columns[1], Column{Windows: []WindowID{2, 3}, Focus: 1, Slot: 2}) || w.Focus != 1 { | |
| 735 | - | t.Fatal(w.Columns, w.Focus) | |
| 736 | - | } | |
| 737 | - | }) | |
| 738 | - | t.Run("moved out by hand unlinks it", func(t *testing.T) { | |
| 739 | - | m := fixed() | |
| 740 | - | m.Current().FocusID(2) | |
| 741 | - | m.ToggleFullscreen() | |
| 742 | - | m.Apply(ActionMoveWindowToWorkspaceUp) | |
| 743 | - | m.AddWindow(4) | |
| 744 | - | m.RemoveWindow(4) | |
| 745 | - | if m.Active != 1 { | |
| 746 | - | t.Fatal("view jumped to", m.Active) | |
| 747 | - | } | |
| 748 | - | }) | |
| 749 | - | t.Run("client exit on screen keeps the window focused", func(t *testing.T) { | |
| 750 | - | m := fixed() | |
| 751 | - | m.Current().FocusID(1) | |
| 752 | - | m.ToggleFullscreen() | |
| 753 | - | m.SetFullscreen(1, false) | |
| 754 | - | if id, _ := m.Focused(); id != 1 || m.Active != 0 { | |
| 755 | - | t.Fatal("focus", id, m.Active) | |
| 756 | - | } | |
| 757 | - | }) | |
| 758 | - | t.Run("stack gone: back as a column", func(t *testing.T) { | |
| 759 | - | m := fixed() | |
| 760 | - | w := m.Current() | |
| 761 | - | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}}, {Windows: []WindowID{4}}} | |
| 762 | - | w.FocusID(3) | |
| 763 | - | m.ToggleFullscreen() | |
| 764 | - | m.RemoveWindow(2) | |
| 765 | - | m.ToggleFullscreen() | |
| 766 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3, 4}, {}}) || len(m.Current().Columns) != 3 { | |
| 767 | - | t.Fatal(got, m.Current().Columns) | |
| 768 | - | } | |
| 769 | - | }) | |
| 770 | - | t.Run("floating window", func(t *testing.T) { | |
| 771 | - | m := fixed() | |
| 772 | - | m.AddFloating(4, 10, 10) | |
| 773 | - | m.ToggleFullscreen() | |
| 774 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}, {}}) || m.Active != 1 || m.Workspaces[1].floatIndex(4) != 0 { | |
| 775 | - | t.Fatal(got, m.Active) | |
| 776 | - | } | |
| 777 | - | m.ToggleFullscreen() | |
| 778 | - | if w := m.Current(); m.Active != 0 || w.floatIndex(4) != 0 || !w.floatFocus { | |
| 779 | - | t.Fatal(m.Active, w.Floats) | |
| 780 | - | } | |
| 781 | - | }) | |
| 782 | - | t.Run("origin gone unlinks it", func(t *testing.T) { | |
| 783 | - | m := fixed() | |
| 784 | - | m.Current().FocusID(2) | |
| 785 | - | m.ToggleFullscreen() | |
| 786 | - | m.take(m.Workspaces[0]) | |
| 787 | - | m.ToggleFullscreen() | |
| 788 | - | if id, _ := m.Focused(); id != 2 || m.Current().fullscreen != 0 { | |
| 789 | - | t.Fatal(id) | |
| 790 | - | } | |
| 791 | - | }) | |
| 792 | - | } | |
| 793 | - | ||
| 794 | 386 | // Cmd+F on a window that made itself fullscreen returns it to its column | |
| 795 | 387 | // (Wine opens monitor-sized apps fullscreen); the next press maximizes. | |
| 796 | 388 | func TestMaximizeLeavesClientFullscreen(t *testing.T) { | |
| @@ -820,49 +412,82 @@ func TestMaximizeLeavesClientFullscreen(t *testing.T) { | |||
| 820 | 412 | } | |
| 821 | 413 | } | |
| 822 | 414 | ||
| 823 | - | // A move that the view follows into a fixed-overflow fullscreen workspace | |
| 824 | - | // sends its window home first: the moved window must be seen, tiled or | |
| 825 | - | // floating. | |
| 826 | - | func TestFollowMoveIntoFullscreenLands(t *testing.T) { | |
| 827 | - | for _, floating := range []bool{false, true} { | |
| 828 | - | m := newMonitor("", "") | |
| 829 | - | m.SetOutput(100, 80) | |
| 830 | - | m.SetOverflow(OverflowFixed) | |
| 831 | - | m.SetFollowMove(true) | |
| 832 | - | m.AddWindow(1) | |
| 833 | - | m.AddWindow(2) | |
| 834 | - | m.ToggleFullscreen() // 2 on its own workspace, below 1's | |
| 835 | - | m.Focus(0) | |
| 836 | - | m.Focus(len(m.Workspaces) - 1) | |
| 837 | - | if floating { | |
| 838 | - | m.AddFloating(5, 10, 10) | |
| 839 | - | } else { | |
| 840 | - | m.AddWindow(5) | |
| 841 | - | } | |
| 842 | - | m.MoveToWorkspace(1, true) | |
| 843 | - | w := m.Current() | |
| 844 | - | if id, _ := w.Focused(); id != 5 || w.cover() != 0 { | |
| 845 | - | t.Fatalf("floating %v: focused %d cover %d, workspaces %v", floating, id, w.cover(), windows(m)) | |
| 846 | - | } | |
| 847 | - | for _, p := range m.Layout() { | |
| 848 | - | if p.ID == 5 && p.Hidden { | |
| 849 | - | t.Fatalf("floating %v: moved window hidden", floating) | |
| 850 | - | } | |
| 415 | + | // Ranging over every workspace allocates nothing: normalize and the | |
| 416 | + | // per-frame paths use it. | |
| 417 | + | func TestMonitorAllAllocations(t *testing.T) { | |
| 418 | + | m := monitor() | |
| 419 | + | m.AddWindow(1) | |
| 420 | + | m.SetNamed([]NamedWorkspace{{Name: "s"}}) | |
| 421 | + | n := 0 | |
| 422 | + | if a := testing.AllocsPerRun(100, func() { | |
| 423 | + | for range m.all() { | |
| 424 | + | n++ | |
| 851 | 425 | } | |
| 426 | + | m.byName("s") | |
| 427 | + | }); a != 0 { | |
| 428 | + | t.Fatalf("%v allocs", a) | |
| 852 | 429 | } | |
| 853 | - | // Without follow, the fullscreen workspace keeps its window: the moved | |
| 854 | - | // one waits there, hidden, and the view stays. | |
| 855 | - | m := newMonitor("", "") | |
| 856 | - | m.SetOutput(100, 80) | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | // Fixed overflow keeps a fullscreen window in place: no workspace is | |
| 433 | + | // added, a window opening meanwhile waits hidden, and the bind or a client | |
| 434 | + | // exit restores the tiles with the user's focus where it was. | |
| 435 | + | func TestFixedFullscreenStaysInPlace(t *testing.T) { | |
| 436 | + | m := monitor() | |
| 857 | 437 | m.SetOverflow(OverflowFixed) | |
| 858 | - | m.AddWindow(1) | |
| 859 | - | m.AddWindow(2) | |
| 438 | + | for id := WindowID(1); id <= 3; id++ { | |
| 439 | + | m.AddWindow(id) | |
| 440 | + | } | |
| 441 | + | w := m.Current() | |
| 442 | + | w.FocusID(2) | |
| 443 | + | m.Apply(ActionCycleColumnWidth) // expanded: kept through fullscreen | |
| 444 | + | m.ToggleFullscreen() | |
| 445 | + | m.AddWindow(4) | |
| 446 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3, 4}, {}}) || m.Current() != w || w.cover() != 2 { | |
| 447 | + | t.Fatal(got, w.cover()) | |
| 448 | + | } | |
| 449 | + | if id, _ := m.Focused(); id != 2 { | |
| 450 | + | t.Fatal("focus", id) | |
| 451 | + | } | |
| 860 | 452 | m.ToggleFullscreen() | |
| 861 | - | m.Focus(len(m.Workspaces) - 1) | |
| 862 | - | m.AddWindow(5) | |
| 863 | - | from := m.Current() | |
| 864 | - | m.MoveToWorkspace(1, true) | |
| 865 | - | if fs := m.Workspaces[1]; m.Current() != from || fs.cover() != 2 || fs.floatIndex(5) < 0 { | |
| 866 | - | t.Fatalf("no follow: workspaces %v floats %v", windows(m), fs.Floats) | |
| 453 | + | if id, _ := m.Focused(); id != 2 || w.fullscreen != 0 || !w.Columns[1].Expanded { | |
| 454 | + | t.Fatal("after bind", id, w.Columns) | |
| 455 | + | } | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | // A client fullscreen request never moves the view or the focus (ADR | |
| 459 | + | // 011). In fixed overflow, where it would cover the screen and take the | |
| 460 | + | // keyboard, only the focused window's request applies. | |
| 461 | + | func TestFullscreenRequestStaysPut(t *testing.T) { | |
| 462 | + | for _, tc := range []struct { | |
| 463 | + | name string | |
| 464 | + | overflow Overflow | |
| 465 | + | id WindowID // requests fullscreen; 1 is focused | |
| 466 | + | applied bool | |
| 467 | + | }{ | |
| 468 | + | {"scroll focused", OverflowScroll, 1, true}, | |
| 469 | + | {"scroll unfocused", OverflowScroll, 2, true}, | |
| 470 | + | {"scroll other workspace", OverflowScroll, 3, true}, | |
| 471 | + | {"fixed focused", OverflowFixed, 1, true}, | |
| 472 | + | {"fixed unfocused", OverflowFixed, 2, false}, | |
| 473 | + | {"fixed other workspace", OverflowFixed, 3, true}, | |
| 474 | + | } { | |
| 475 | + | t.Run(tc.name, func(t *testing.T) { | |
| 476 | + | m := monitor() | |
| 477 | + | m.SetOverflow(tc.overflow) | |
| 478 | + | m.AddWindow(3) // alone, so focused on workspace 1 | |
| 479 | + | m.FocusNumber(2) | |
| 480 | + | m.AddWindow(2) | |
| 481 | + | m.AddWindow(1) | |
| 482 | + | w, _ := m.find(tc.id) | |
| 483 | + | m.SetFullscreen(tc.id, true) | |
| 484 | + | if id, _ := m.Focused(); m.Active != 1 || id != 1 || (w.fullscreen == tc.id) != tc.applied { | |
| 485 | + | t.Fatal("active", m.Active, "focused", id, "fullscreen", w.fullscreen) | |
| 486 | + | } | |
| 487 | + | m.SetFullscreen(tc.id, false) | |
| 488 | + | if id, _ := m.Focused(); id != 1 || w.fullscreen != 0 || len(m.Workspaces) != 3 { | |
| 489 | + | t.Fatal("round trip", id, windows(m)) | |
| 490 | + | } | |
| 491 | + | }) | |
| 867 | 492 | } | |
| 868 | 493 | } | |
| @@ -2,7 +2,6 @@ package core | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "reflect" | 4 | "reflect" | ||
| 5 | - | "slices" | |||
| 6 | "testing" | 5 | "testing" | ||
| 7 | 6 | ||||
| 8 | "github.com/bnema/neferwl/internal/ports" | 7 | "github.com/bnema/neferwl/internal/ports" | ||
| @@ -123,20 +122,6 @@ func TestMonitorLayoutHidesOtherWorkspaces(t *testing.T) { | |||||
| 123 | } | 122 | } | ||
| 124 | } | 123 | } | ||
| 125 | 124 | ||||
| 126 | - | func TestMonitorFullscreenRequestStaysPut(t *testing.T) { | |||
| 127 | - | m := monitor() | |||
| 128 | - | m.AddWindow(1) | |||
| 129 | - | m.FocusNumber(2) | |||
| 130 | - | m.AddWindow(2) | |||
| 131 | - | m.SetFullscreen(1, true) | |||
| 132 | - | if m.Active != 1 { | |||
| 133 | - | t.Fatal("client request switched workspace") | |||
| 134 | - | } | |||
| 135 | - | if m.Workspaces[0].fullscreen != 1 { | |||
| 136 | - | t.Fatal("fullscreen not applied") | |||
| 137 | - | } | |||
| 138 | - | } | |||
| 139 | - | ||||
| 140 | func TestMonitorActions(t *testing.T) { | 125 | func TestMonitorActions(t *testing.T) { | ||
| 141 | m := monitor() | 126 | m := monitor() | ||
| 142 | m.AddWindow(1) | 127 | m.AddWindow(1) | ||
| @@ -291,19 +276,6 @@ func TestWorkspaceArg(t *testing.T) { | |||||
| 291 | } | 276 | } | ||
| 292 | } | 277 | } | ||
| 293 | 278 | ||||
| 294 | - | func TestFullscreenRequestKeepsFocus(t *testing.T) { | |||
| 295 | - | m := monitor() | |||
| 296 | - | m.AddWindow(1) | |||
| 297 | - | m.AddWindow(2) | |||
| 298 | - | m.SetFullscreen(1, true) | |||
| 299 | - | if id, _ := m.Focused(); id != 2 { | |||
| 300 | - | t.Fatal(id) | |||
| 301 | - | } | |||
| 302 | - | if m.Current().fullscreen != 1 { | |||
| 303 | - | t.Fatal("not fullscreen") | |||
| 304 | - | } | |||
| 305 | - | } | |||
| 306 | - | ||||
| 307 | func TestLonePresetColumnKeepsWidth(t *testing.T) { | 279 | func TestLonePresetColumnKeepsWidth(t *testing.T) { | ||
| 308 | m := monitor() | 280 | m := monitor() | ||
| 309 | m.SetPresets([]Width{{Num: 1, Den: 2}}) | 281 | m.SetPresets([]Width{{Num: 1, Den: 2}}) | ||
| @@ -411,386 +383,6 @@ func TestNamedWorkspaceSettings(t *testing.T) { | |||||
| 411 | } | 383 | } | ||
| 412 | } | 384 | } | ||
| 413 | 385 | ||||
| 414 | - | // The expanded column comes back expanded from its fullscreen workspace. | |||
| 415 | - | func TestMonitorFixedFullscreenKeepsExpanded(t *testing.T) { | |||
| 416 | - | m := monitor() | |||
| 417 | - | m.SetOverflow(OverflowFixed) | |||
| 418 | - | m.SetMaxColumns(3) | |||
| 419 | - | for id := WindowID(1); id <= 3; id++ { | |||
| 420 | - | m.AddWindow(id) | |||
| 421 | - | } | |||
| 422 | - | m.Current().FocusID(2) | |||
| 423 | - | m.Apply(ActionCycleColumnWidth) | |||
| 424 | - | m.ToggleFullscreen() | |||
| 425 | - | m.ToggleFullscreen() | |||
| 426 | - | if c := m.Current().Columns; len(c) != 3 || !c[1].Expanded || c[0].Expanded || c[2].Expanded { | |||
| 427 | - | t.Fatalf("%+v", c) | |||
| 428 | - | } | |||
| 429 | - | } | |||
| 430 | - | ||||
| 431 | - | // A window of an expanded stacked column comes back expanded when the rest | |||
| 432 | - | // of its column closed meanwhile, unless another column took the expansion. | |||
| 433 | - | func TestMonitorFixedFullscreenStackedExpanded(t *testing.T) { | |||
| 434 | - | for _, other := range []bool{false, true} { | |||
| 435 | - | m := monitor() | |||
| 436 | - | m.SetOverflow(OverflowFixed) | |||
| 437 | - | m.SetMaxColumns(3) | |||
| 438 | - | for id := WindowID(1); id <= 4; id++ { | |||
| 439 | - | m.AddWindow(id) | |||
| 440 | - | } | |||
| 441 | - | w := m.Current() | |||
| 442 | - | w.FocusID(2) | |||
| 443 | - | w.stackWindow(2, 1) // column {1, 2}, then {3}, {4} | |||
| 444 | - | w.FocusID(2) | |||
| 445 | - | m.Apply(ActionCycleColumnWidth) | |||
| 446 | - | m.ToggleFullscreen() | |||
| 447 | - | w.RemoveWindow(1) | |||
| 448 | - | if other { | |||
| 449 | - | w.FocusID(3) | |||
| 450 | - | w.CycleWidth() | |||
| 451 | - | } | |||
| 452 | - | m.ToggleFullscreen() | |||
| 453 | - | c := m.Current().Columns | |||
| 454 | - | if len(c) != 3 || c[0].Windows[0] != 2 || c[0].Expanded == other || c[1].Expanded != other || c[2].Expanded { | |||
| 455 | - | t.Fatalf("other=%v %+v", other, c) | |||
| 456 | - | } | |||
| 457 | - | } | |||
| 458 | - | } | |||
| 459 | - | ||||
| 460 | - | func TestMonitorFixedFullscreenOwnWorkspace(t *testing.T) { | |||
| 461 | - | m := monitor() | |||
| 462 | - | m.SetOverflow(OverflowFixed) | |||
| 463 | - | for id := WindowID(1); id <= 3; id++ { | |||
| 464 | - | m.AddWindow(id) | |||
| 465 | - | } | |||
| 466 | - | m.Current().FocusID(2) | |||
| 467 | - | m.ToggleFullscreen() | |||
| 468 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {2}, {}}) || m.Active != 1 { | |||
| 469 | - | t.Fatal(got, m.Active) | |||
| 470 | - | } | |||
| 471 | - | if p := m.Layout(); !slices.ContainsFunc(p, func(p Placement) bool { return p.ID == 2 && p.Fullscreen && !p.Hidden }) { | |||
| 472 | - | t.Fatal(p) | |||
| 473 | - | } | |||
| 474 | - | m.Apply(ActionFocusWorkspaceUp) | |||
| 475 | - | if m.Active != 0 { | |||
| 476 | - | t.Fatal("stuck on the fullscreen workspace") | |||
| 477 | - | } | |||
| 478 | - | m.Apply(ActionFocusWorkspaceDown) | |||
| 479 | - | m.ToggleFullscreen() | |||
| 480 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) || m.Active != 0 { | |||
| 481 | - | t.Fatal(got, m.Active) | |||
| 482 | - | } | |||
| 483 | - | if id, _ := m.Focused(); id != 2 { | |||
| 484 | - | t.Fatal("focus", id) | |||
| 485 | - | } | |||
| 486 | - | ||||
| 487 | - | // A client request moves it too, without taking the user along | |||
| 488 | - | // unless the window is the focused one on screen. | |||
| 489 | - | m.Current().FocusID(1) | |||
| 490 | - | m.SetFullscreen(3, true) | |||
| 491 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {3}, {}}) || m.Active != 0 { | |||
| 492 | - | t.Fatal(got, m.Active) | |||
| 493 | - | } | |||
| 494 | - | m.SetFullscreen(3, false) | |||
| 495 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) { | |||
| 496 | - | t.Fatal(got) | |||
| 497 | - | } | |||
| 498 | - | ||||
| 499 | - | // Closing the fullscreen window drops its workspace and returns. | |||
| 500 | - | m.Current().FocusID(3) | |||
| 501 | - | m.ToggleFullscreen() | |||
| 502 | - | m.RemoveWindow(3) | |||
| 503 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {}}) || m.Active != 0 { | |||
| 504 | - | t.Fatal(got, m.Active) | |||
| 505 | - | } | |||
| 506 | - | ||||
| 507 | - | // Scroll overflow keeps the niri behaviour: same workspace. | |||
| 508 | - | m.SetOverflow(OverflowScroll) | |||
| 509 | - | m.ToggleFullscreen() | |||
| 510 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {}}) { | |||
| 511 | - | t.Fatal(got) | |||
| 512 | - | } | |||
| 513 | - | } | |||
| 514 | - | ||||
| 515 | - | // The fullscreen workspace link survives only while it holds just its | |||
| 516 | - | // window; exits restore the exact place and never move the user's focus. | |||
| 517 | - | func TestMonitorFixedFullscreenEdges(t *testing.T) { | |||
| 518 | - | fixed := func() *Monitor { | |||
| 519 | - | m := monitor() | |||
| 520 | - | m.SetOverflow(OverflowFixed) | |||
| 521 | - | for id := WindowID(1); id <= 3; id++ { | |||
| 522 | - | m.AddWindow(id) | |||
| 523 | - | } | |||
| 524 | - | return m | |||
| 525 | - | } | |||
| 526 | - | t.Run("a window opened there tiles at home, the view stays", func(t *testing.T) { | |||
| 527 | - | m := fixed() | |||
| 528 | - | m.Current().FocusID(2) | |||
| 529 | - | m.ToggleFullscreen() | |||
| 530 | - | m.AddWindow(4) | |||
| 531 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3, 4}, {2}, {}}) || m.Active != 1 { | |||
| 532 | - | t.Fatal(got, m.Active) | |||
| 533 | - | } | |||
| 534 | - | if id, _ := m.Focused(); id != 2 { | |||
| 535 | - | t.Fatal("focus", id) | |||
| 536 | - | } | |||
| 537 | - | m.SetFullscreen(2, false) | |||
| 538 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3, 4}, {}}) || m.Active != 0 { | |||
| 539 | - | t.Fatal(got, m.Active) | |||
| 540 | - | } | |||
| 541 | - | }) | |||
| 542 | - | t.Run("a window opened over a lone fullscreen moves it to its own workspace", func(t *testing.T) { | |||
| 543 | - | for _, client := range []bool{false, true} { | |||
| 544 | - | m := monitor() | |||
| 545 | - | m.SetOverflow(OverflowFixed) | |||
| 546 | - | m.AddWindow(1) | |||
| 547 | - | if client { | |||
| 548 | - | m.SetFullscreen(1, true) | |||
| 549 | - | } else { | |||
| 550 | - | m.ToggleFullscreen() | |||
| 551 | - | } | |||
| 552 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1}, {}}) || m.Current().fullscreen != 1 { | |||
| 553 | - | t.Fatal("in place", got) | |||
| 554 | - | } | |||
| 555 | - | m.AddWindow(2) | |||
| 556 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{2}, {1}, {}}) || m.Active != 1 { | |||
| 557 | - | t.Fatal(client, got, m.Active) | |||
| 558 | - | } | |||
| 559 | - | if id, _ := m.Focused(); id != 1 || m.Current().cover() != 1 { | |||
| 560 | - | t.Fatal("focus", id) | |||
| 561 | - | } | |||
| 562 | - | m.SetFullscreen(1, false) | |||
| 563 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {}}) || m.Active != 0 { | |||
| 564 | - | t.Fatal(client, got, m.Active) | |||
| 565 | - | } | |||
| 566 | - | if id, _ := m.Focused(); id != 1 { | |||
| 567 | - | t.Fatal("focus after", id) | |||
| 568 | - | } | |||
| 569 | - | } | |||
| 570 | - | }) | |||
| 571 | - | t.Run("a window opened over a lone fullscreen off screen stays there", func(t *testing.T) { | |||
| 572 | - | m := monitor() | |||
| 573 | - | m.SetOverflow(OverflowFixed) | |||
| 574 | - | m.AddWindow(1) | |||
| 575 | - | m.ToggleFullscreen() | |||
| 576 | - | m.Focus(1) | |||
| 577 | - | m.AddWindow(2) | |||
| 578 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1}, {2}, {}}) || m.Active != 1 { | |||
| 579 | - | t.Fatal(got, m.Active) | |||
| 580 | - | } | |||
| 581 | - | }) | |||
| 582 | - | t.Run("arrivals over a lone fullscreen: slot, named workspace, float", func(t *testing.T) { | |||
| 583 | - | for _, tc := range []struct { | |||
| 584 | - | name string | |||
| 585 | - | setup func(m *Monitor) *Workspace | |||
| 586 | - | add func(w *Workspace) | |||
| 587 | - | }{ | |||
| 588 | - | {"slot", func(m *Monitor) *Workspace { m.AddWindow(1); return m.Current() }, | |||
| 589 | - | func(w *Workspace) { w.AddSlotWindow(2, 1, Width{}) }}, | |||
| 590 | - | {"hidden named", func(m *Monitor) *Workspace { | |||
| 591 | - | m.SetNamed([]NamedWorkspace{{Name: "dev", Overflow: OverflowFixed}}) | |||
| 592 | - | m.Apply("workspace dev") | |||
| 593 | - | m.AddWindow(1) | |||
| 594 | - | return m.Current() | |||
| 595 | - | }, func(w *Workspace) { w.AddWindow(2) }}, | |||
| 596 | - | {"float", func(m *Monitor) *Workspace { m.AddFloating(1, 10, 10); return m.Current() }, | |||
| 597 | - | func(w *Workspace) { w.AddWindow(2) }}, | |||
| 598 | - | } { | |||
| 599 | - | m := monitor() | |||
| 600 | - | m.SetOverflow(OverflowFixed) | |||
| 601 | - | home := tc.setup(m) | |||
| 602 | - | m.SetFullscreen(1, true) | |||
| 603 | - | m.arrive(home, tc.add) | |||
| 604 | - | if fs := m.Current(); fs == home || fs.cover() != 1 || fs.origin != home { | |||
| 605 | - | t.Fatal(tc.name, "fullscreen not on its own workspace", windows(m)) | |||
| 606 | - | } | |||
| 607 | - | if id, _ := m.Focused(); id != 1 || !home.has(2) || home.has(1) { | |||
| 608 | - | t.Fatal(tc.name, "focus", id, home.windows()) | |||
| 609 | - | } | |||
| 610 | - | m.SetFullscreen(1, false) | |||
| 611 | - | if m.Current() != home || !home.has(1) || !home.has(2) { | |||
| 612 | - | t.Fatal(tc.name, "not home", windows(m), home.windows()) | |||
| 613 | - | } | |||
| 614 | - | if id, _ := m.Focused(); id != 1 { | |||
| 615 | - | t.Fatal(tc.name, "focus after", id) | |||
| 616 | - | } | |||
| 617 | - | } | |||
| 618 | - | }) | |||
| 619 | - | t.Run("a joined window lands after its stack when columns shifted", func(t *testing.T) { | |||
| 620 | - | m := fixed() | |||
| 621 | - | w := m.Current() | |||
| 622 | - | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{5}}, {Windows: []WindowID{2, 3}}, {Windows: []WindowID{6}}} | |||
| 623 | - | w.FocusID(3) | |||
| 624 | - | m.ToggleFullscreen() | |||
| 625 | - | m.Current().joinFullscreen(7) | |||
| 626 | - | m.RemoveWindow(1) | |||
| 627 | - | m.ToggleFullscreen() | |||
| 628 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{5, 2, 3, 7, 6}, {}}) { | |||
| 629 | - | t.Fatal(got) | |||
| 630 | - | } | |||
| 631 | - | }) | |||
| 632 | - | t.Run("the bind from a dialog returns the fullscreen window", func(t *testing.T) { | |||
| 633 | - | m := fixed() | |||
| 634 | - | m.Current().FocusID(2) | |||
| 635 | - | m.ToggleFullscreen() | |||
| 636 | - | m.AddFloating(4, 10, 10) // focused dialog | |||
| 637 | - | m.ToggleFullscreen() | |||
| 638 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) || m.Current().floatIndex(4) != 0 { | |||
| 639 | - | t.Fatal(got, m.Current().Floats) | |||
| 640 | - | } | |||
| 641 | - | }) | |||
| 642 | - | t.Run("a dialog asking fullscreen keeps the link", func(t *testing.T) { | |||
| 643 | - | m := fixed() | |||
| 644 | - | m.Current().FocusID(2) | |||
| 645 | - | m.ToggleFullscreen() | |||
| 646 | - | m.AddFloating(4, 10, 10) | |||
| 647 | - | m.SetFullscreen(4, true) | |||
| 648 | - | m.SetFullscreen(4, false) | |||
| 649 | - | m.ToggleFullscreen() | |||
| 650 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) { | |||
| 651 | - | t.Fatal(got) | |||
| 652 | - | } | |||
| 653 | - | }) | |||
| 654 | - | t.Run("moved in by hand it floats", func(t *testing.T) { | |||
| 655 | - | m := fixed() | |||
| 656 | - | m.Current().FocusID(2) | |||
| 657 | - | m.ToggleFullscreen() | |||
| 658 | - | m.Apply(ActionFocusWorkspaceUp) | |||
| 659 | - | m.Current().FocusID(1) | |||
| 660 | - | m.Apply(ActionMoveWindowToWorkspaceDown) | |||
| 661 | - | if fs := m.Workspaces[1]; fs.floatIndex(1) != 0 || fs.origin == nil { | |||
| 662 | - | t.Fatal(fs.Floats) | |||
| 663 | - | } | |||
| 664 | - | }) | |||
| 665 | - | t.Run("a moved column floats window by window", func(t *testing.T) { | |||
| 666 | - | m := fixed() | |||
| 667 | - | m.Current().FocusID(2) | |||
| 668 | - | m.ToggleFullscreen() | |||
| 669 | - | m.Apply(ActionFocusWorkspaceUp) | |||
| 670 | - | w := m.Current() | |||
| 671 | - | ids := w.windows() | |||
| 672 | - | w.Columns = []Column{{Windows: ids}} | |||
| 673 | - | w.Focus = 0 | |||
| 674 | - | m.Apply(ActionMoveColumnToWorkspaceDown) | |||
| 675 | - | fs := m.Workspaces[1] | |||
| 676 | - | for _, id := range ids { | |||
| 677 | - | if fs.floatIndex(id) < 0 { | |||
| 678 | - | t.Fatal(id, fs.Floats) | |||
| 679 | - | } | |||
| 680 | - | } | |||
| 681 | - | if fs.origin == nil || len(ids) < 2 { | |||
| 682 | - | t.Fatal(fs.origin, ids) | |||
| 683 | - | } | |||
| 684 | - | }) | |||
| 685 | - | t.Run("closing it brings its dialogs home, below a focused float", func(t *testing.T) { | |||
| 686 | - | m := fixed() | |||
| 687 | - | m.Current().FocusID(2) | |||
| 688 | - | m.ToggleFullscreen() | |||
| 689 | - | m.AddFloating(4, 10, 10) | |||
| 690 | - | m.Apply(ActionFocusWorkspaceUp) | |||
| 691 | - | m.AddFloating(9, 10, 10) | |||
| 692 | - | m.RemoveWindow(2) | |||
| 693 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {}}) || m.Current().floatIndex(4) != 0 { | |||
| 694 | - | t.Fatal(got, m.Current().Floats) | |||
| 695 | - | } | |||
| 696 | - | if id, _ := m.Focused(); id != 9 { | |||
| 697 | - | t.Fatal("focus", id) | |||
| 698 | - | } | |||
| 699 | - | }) | |||
| 700 | - | t.Run("stack of three keeps its column", func(t *testing.T) { | |||
| 701 | - | m := fixed() | |||
| 702 | - | w := m.Current() | |||
| 703 | - | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3, 5}}} | |||
| 704 | - | w.FocusID(3) | |||
| 705 | - | m.ToggleFullscreen() | |||
| 706 | - | m.RemoveWindow(2) | |||
| 707 | - | m.ToggleFullscreen() | |||
| 708 | - | if len(w.Columns) != 2 || !slices.Equal(w.Columns[1].Windows, []WindowID{3, 5}) { | |||
| 709 | - | t.Fatal(w.Columns) | |||
| 710 | - | } | |||
| 711 | - | }) | |||
| 712 | - | t.Run("client exit keeps the user's focus", func(t *testing.T) { | |||
| 713 | - | m := fixed() | |||
| 714 | - | m.Current().FocusID(1) | |||
| 715 | - | m.SetFullscreen(3, true) | |||
| 716 | - | m.SetFullscreen(3, false) | |||
| 717 | - | if id, _ := m.Focused(); id != 1 || m.Active != 0 { | |||
| 718 | - | t.Fatal("focus moved to", id, m.Active) | |||
| 719 | - | } | |||
| 720 | - | m.Current().FocusID(3) | |||
| 721 | - | m.SetFullscreen(1, true) | |||
| 722 | - | m.SetFullscreen(1, false) | |||
| 723 | - | if id, _ := m.Focused(); id != 3 { | |||
| 724 | - | t.Fatal("focus moved to", id) | |||
| 725 | - | } | |||
| 726 | - | }) | |||
| 727 | - | t.Run("stacked slot window returns to its row", func(t *testing.T) { | |||
| 728 | - | m := fixed() | |||
| 729 | - | w := m.Current() | |||
| 730 | - | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Slot: 2}} | |||
| 731 | - | w.FocusID(3) | |||
| 732 | - | m.ToggleFullscreen() | |||
| 733 | - | m.ToggleFullscreen() | |||
| 734 | - | if !reflect.DeepEqual(w.Columns[1], Column{Windows: []WindowID{2, 3}, Focus: 1, Slot: 2}) || w.Focus != 1 { | |||
| 735 | - | t.Fatal(w.Columns, w.Focus) | |||
| 736 | - | } | |||
| 737 | - | }) | |||
| 738 | - | t.Run("moved out by hand unlinks it", func(t *testing.T) { | |||
| 739 | - | m := fixed() | |||
| 740 | - | m.Current().FocusID(2) | |||
| 741 | - | m.ToggleFullscreen() | |||
| 742 | - | m.Apply(ActionMoveWindowToWorkspaceUp) | |||
| 743 | - | m.AddWindow(4) | |||
| 744 | - | m.RemoveWindow(4) | |||
| 745 | - | if m.Active != 1 { | |||
| 746 | - | t.Fatal("view jumped to", m.Active) | |||
| 747 | - | } | |||
| 748 | - | }) | |||
| 749 | - | t.Run("client exit on screen keeps the window focused", func(t *testing.T) { | |||
| 750 | - | m := fixed() | |||
| 751 | - | m.Current().FocusID(1) | |||
| 752 | - | m.ToggleFullscreen() | |||
| 753 | - | m.SetFullscreen(1, false) | |||
| 754 | - | if id, _ := m.Focused(); id != 1 || m.Active != 0 { | |||
| 755 | - | t.Fatal("focus", id, m.Active) | |||
| 756 | - | } | |||
| 757 | - | }) | |||
| 758 | - | t.Run("stack gone: back as a column", func(t *testing.T) { | |||
| 759 | - | m := fixed() | |||
| 760 | - | w := m.Current() | |||
| 761 | - | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}}, {Windows: []WindowID{4}}} | |||
| 762 | - | w.FocusID(3) | |||
| 763 | - | m.ToggleFullscreen() | |||
| 764 | - | m.RemoveWindow(2) | |||
| 765 | - | m.ToggleFullscreen() | |||
| 766 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3, 4}, {}}) || len(m.Current().Columns) != 3 { | |||
| 767 | - | t.Fatal(got, m.Current().Columns) | |||
| 768 | - | } | |||
| 769 | - | }) | |||
| 770 | - | t.Run("floating window", func(t *testing.T) { | |||
| 771 | - | m := fixed() | |||
| 772 | - | m.AddFloating(4, 10, 10) | |||
| 773 | - | m.ToggleFullscreen() | |||
| 774 | - | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}, {}}) || m.Active != 1 || m.Workspaces[1].floatIndex(4) != 0 { | |||
| 775 | - | t.Fatal(got, m.Active) | |||
| 776 | - | } | |||
| 777 | - | m.ToggleFullscreen() | |||
| 778 | - | if w := m.Current(); m.Active != 0 || w.floatIndex(4) != 0 || !w.floatFocus { | |||
| 779 | - | t.Fatal(m.Active, w.Floats) | |||
| 780 | - | } | |||
| 781 | - | }) | |||
| 782 | - | t.Run("origin gone unlinks it", func(t *testing.T) { | |||
| 783 | - | m := fixed() | |||
| 784 | - | m.Current().FocusID(2) | |||
| 785 | - | m.ToggleFullscreen() | |||
| 786 | - | m.take(m.Workspaces[0]) | |||
| 787 | - | m.ToggleFullscreen() | |||
| 788 | - | if id, _ := m.Focused(); id != 2 || m.Current().fullscreen != 0 { | |||
| 789 | - | t.Fatal(id) | |||
| 790 | - | } | |||
| 791 | - | }) | |||
| 792 | - | } | |||
| 793 | - | ||||
| 794 | // Cmd+F on a window that made itself fullscreen returns it to its column | 386 | // Cmd+F on a window that made itself fullscreen returns it to its column | ||
| 795 | // (Wine opens monitor-sized apps fullscreen); the next press maximizes. | 387 | // (Wine opens monitor-sized apps fullscreen); the next press maximizes. | ||
| 796 | func TestMaximizeLeavesClientFullscreen(t *testing.T) { | 388 | func TestMaximizeLeavesClientFullscreen(t *testing.T) { | ||
| @@ -820,49 +412,82 @@ func TestMaximizeLeavesClientFullscreen(t *testing.T) { | |||||
| 820 | } | 412 | } | ||
| 821 | } | 413 | } | ||
| 822 | 414 | ||||
| 823 | - | // A move that the view follows into a fixed-overflow fullscreen workspace | 415 | + | // Ranging over every workspace allocates nothing: normalize and the |
| 824 | - | // sends its window home first: the moved window must be seen, tiled or | 416 | + | // per-frame paths use it. |
| 825 | - | // floating. | 417 | + | func TestMonitorAllAllocations(t *testing.T) { |
| 826 | - | func TestFollowMoveIntoFullscreenLands(t *testing.T) { | 418 | + | m := monitor() |
| 827 | - | for _, floating := range []bool{false, true} { | 419 | + | m.AddWindow(1) |
| 828 | - | m := newMonitor("", "") | 420 | + | m.SetNamed([]NamedWorkspace{{Name: "s"}}) |
| 829 | - | m.SetOutput(100, 80) | 421 | + | n := 0 |
| 830 | - | m.SetOverflow(OverflowFixed) | 422 | + | if a := testing.AllocsPerRun(100, func() { |
| 831 | - | m.SetFollowMove(true) | 423 | + | for range m.all() { |
| 832 | - | m.AddWindow(1) | 424 | + | n++ |
| 833 | - | m.AddWindow(2) | |||
| 834 | - | m.ToggleFullscreen() // 2 on its own workspace, below 1's | |||
| 835 | - | m.Focus(0) | |||
| 836 | - | m.Focus(len(m.Workspaces) - 1) | |||
| 837 | - | if floating { | |||
| 838 | - | m.AddFloating(5, 10, 10) | |||
| 839 | - | } else { | |||
| 840 | - | m.AddWindow(5) | |||
| 841 | - | } | |||
| 842 | - | m.MoveToWorkspace(1, true) | |||
| 843 | - | w := m.Current() | |||
| 844 | - | if id, _ := w.Focused(); id != 5 || w.cover() != 0 { | |||
| 845 | - | t.Fatalf("floating %v: focused %d cover %d, workspaces %v", floating, id, w.cover(), windows(m)) | |||
| 846 | - | } | |||
| 847 | - | for _, p := range m.Layout() { | |||
| 848 | - | if p.ID == 5 && p.Hidden { | |||
| 849 | - | t.Fatalf("floating %v: moved window hidden", floating) | |||
| 850 | - | } | |||
| 851 | } | 425 | } | ||
| 426 | + | m.byName("s") | |||
| 427 | + | }); a != 0 { | |||
| 428 | + | t.Fatalf("%v allocs", a) | |||
| 852 | } | 429 | } | ||
| 853 | - | // Without follow, the fullscreen workspace keeps its window: the moved | 430 | + | } |
| 854 | - | // one waits there, hidden, and the view stays. | 431 | + | |
| 855 | - | m := newMonitor("", "") | 432 | + | // Fixed overflow keeps a fullscreen window in place: no workspace is |
| 856 | - | m.SetOutput(100, 80) | 433 | + | // added, a window opening meanwhile waits hidden, and the bind or a client |
| 434 | + | // exit restores the tiles with the user's focus where it was. | |||
| 435 | + | func TestFixedFullscreenStaysInPlace(t *testing.T) { | |||
| 436 | + | m := monitor() | |||
| 857 | m.SetOverflow(OverflowFixed) | 437 | m.SetOverflow(OverflowFixed) | ||
| 858 | - | m.AddWindow(1) | 438 | + | for id := WindowID(1); id <= 3; id++ { |
| 859 | - | m.AddWindow(2) | 439 | + | m.AddWindow(id) |
| 440 | + | } | |||
| 441 | + | w := m.Current() | |||
| 442 | + | w.FocusID(2) | |||
| 443 | + | m.Apply(ActionCycleColumnWidth) // expanded: kept through fullscreen | |||
| 444 | + | m.ToggleFullscreen() | |||
| 445 | + | m.AddWindow(4) | |||
| 446 | + | if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3, 4}, {}}) || m.Current() != w || w.cover() != 2 { | |||
| 447 | + | t.Fatal(got, w.cover()) | |||
| 448 | + | } | |||
| 449 | + | if id, _ := m.Focused(); id != 2 { | |||
| 450 | + | t.Fatal("focus", id) | |||
| 451 | + | } | |||
| 860 | m.ToggleFullscreen() | 452 | m.ToggleFullscreen() | ||
| 861 | - | m.Focus(len(m.Workspaces) - 1) | 453 | + | if id, _ := m.Focused(); id != 2 || w.fullscreen != 0 || !w.Columns[1].Expanded { |
| 862 | - | m.AddWindow(5) | 454 | + | t.Fatal("after bind", id, w.Columns) |
| 863 | - | from := m.Current() | 455 | + | } |
| 864 | - | m.MoveToWorkspace(1, true) | 456 | + | } |
| 865 | - | if fs := m.Workspaces[1]; m.Current() != from || fs.cover() != 2 || fs.floatIndex(5) < 0 { | 457 | + | |
| 866 | - | t.Fatalf("no follow: workspaces %v floats %v", windows(m), fs.Floats) | 458 | + | // A client fullscreen request never moves the view or the focus (ADR |
| 459 | + | // 011). In fixed overflow, where it would cover the screen and take the | |||
| 460 | + | // keyboard, only the focused window's request applies. | |||
| 461 | + | func TestFullscreenRequestStaysPut(t *testing.T) { | |||
| 462 | + | for _, tc := range []struct { | |||
| 463 | + | name string | |||
| 464 | + | overflow Overflow | |||
| 465 | + | id WindowID // requests fullscreen; 1 is focused | |||
| 466 | + | applied bool | |||
| 467 | + | }{ | |||
| 468 | + | {"scroll focused", OverflowScroll, 1, true}, | |||
| 469 | + | {"scroll unfocused", OverflowScroll, 2, true}, | |||
| 470 | + | {"scroll other workspace", OverflowScroll, 3, true}, | |||
| 471 | + | {"fixed focused", OverflowFixed, 1, true}, | |||
| 472 | + | {"fixed unfocused", OverflowFixed, 2, false}, | |||
| 473 | + | {"fixed other workspace", OverflowFixed, 3, true}, | |||
| 474 | + | } { | |||
| 475 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 476 | + | m := monitor() | |||
| 477 | + | m.SetOverflow(tc.overflow) | |||
| 478 | + | m.AddWindow(3) // alone, so focused on workspace 1 | |||
| 479 | + | m.FocusNumber(2) | |||
| 480 | + | m.AddWindow(2) | |||
| 481 | + | m.AddWindow(1) | |||
| 482 | + | w, _ := m.find(tc.id) | |||
| 483 | + | m.SetFullscreen(tc.id, true) | |||
| 484 | + | if id, _ := m.Focused(); m.Active != 1 || id != 1 || (w.fullscreen == tc.id) != tc.applied { | |||
| 485 | + | t.Fatal("active", m.Active, "focused", id, "fullscreen", w.fullscreen) | |||
| 486 | + | } | |||
| 487 | + | m.SetFullscreen(tc.id, false) | |||
| 488 | + | if id, _ := m.Focused(); id != 1 || w.fullscreen != 0 || len(m.Workspaces) != 3 { | |||
| 489 | + | t.Fatal("round trip", id, windows(m)) | |||
| 490 | + | } | |||
| 491 | + | }) | |||
| 867 | } | 492 | } | ||
| 868 | } | 493 | } | ||
M internal/core/overview.go +1 -1
| @@ -461,7 +461,7 @@ func (m *Monitor) overviewLayout() []Placement { | |||
| 461 | 461 | cur := m.Current() | |
| 462 | 462 | rows, _ := m.overviewRows() | |
| 463 | 463 | var result []Placement | |
| 464 | - | for _, w := range m.all() { | |
| 464 | + | for w := range m.all() { | |
| 465 | 465 | ry, shown := rows[w] | |
| 466 | 466 | if shown && w.pinned() { | |
| 467 | 467 | // Only the covering window shows, as on screen. | |
| @@ -461,7 +461,7 @@ func (m *Monitor) overviewLayout() []Placement { | |||||
| 461 | cur := m.Current() | 461 | cur := m.Current() | ||
| 462 | rows, _ := m.overviewRows() | 462 | rows, _ := m.overviewRows() | ||
| 463 | var result []Placement | 463 | var result []Placement | ||
| 464 | - | for _, w := range m.all() { | 464 | + | for w := range m.all() { |
| 465 | ry, shown := rows[w] | 465 | ry, shown := rows[w] | ||
| 466 | if shown && w.pinned() { | 466 | if shown && w.pinned() { | ||
| 467 | // Only the covering window shows, as on screen. | 467 | // Only the covering window shows, as on screen. | ||
M internal/core/overview_test.go +19 -30
| @@ -126,37 +126,27 @@ func TestOverviewPick(t *testing.T) { | |||
| 126 | 126 | } | |
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | - | // gameMonitor has workspace 1 with column 1 on a 300x200 output and a | |
| 130 | - | // floating game 2 (fixed size) fullscreen on its own workspace below, or | |
| 131 | - | // alone in place on workspace 2 when inPlace is set. | |
| 132 | - | func gameMonitor(o Overflow, inPlace bool) *Monitor { | |
| 129 | + | // gameMonitor has, on a 300x200 output, window 1 on workspace 1 and, on | |
| 130 | + | // workspace 2, a floating game 2 (fixed size) fullscreen; workspace 1 is | |
| 131 | + | // on screen. | |
| 132 | + | func gameMonitor(o Overflow) *Monitor { | |
| 133 | 133 | m := newMonitor("", "") | |
| 134 | 134 | m.SetOutput(300, 200) | |
| 135 | 135 | m.SetOverflow(o) | |
| 136 | 136 | m.AddWindow(1) | |
| 137 | - | if inPlace { | |
| 138 | - | m.Focus(1) | |
| 139 | - | } | |
| 137 | + | m.Focus(1) | |
| 140 | 138 | m.AddFloating(2, 300, 200) | |
| 141 | 139 | m.SetFullscreen(2, true) | |
| 142 | 140 | m.Focus(0) | |
| 143 | 141 | return m | |
| 144 | 142 | } | |
| 145 | 143 | ||
| 146 | - | // A floating game covering its workspace shows as that row's preview, | |
| 147 | - | // dedicated or in place: the row is not empty. | |
| 144 | + | // A floating game covering its workspace shows as that row's preview: the | |
| 145 | + | // row is not empty. | |
| 148 | 146 | func TestOverviewFullscreenFloat(t *testing.T) { | |
| 149 | - | for _, tt := range []struct { | |
| 150 | - | name string | |
| 151 | - | o Overflow | |
| 152 | - | inPlace bool | |
| 153 | - | }{ | |
| 154 | - | {"own workspace", OverflowFixed, false}, | |
| 155 | - | {"in place fixed", OverflowFixed, true}, | |
| 156 | - | {"in place scroll", OverflowScroll, true}, | |
| 157 | - | } { | |
| 158 | - | t.Run(tt.name, func(t *testing.T) { | |
| 159 | - | m := gameMonitor(tt.o, tt.inPlace) | |
| 147 | + | for _, o := range []Overflow{OverflowFixed, OverflowScroll} { | |
| 148 | + | t.Run(string(o), func(t *testing.T) { | |
| 149 | + | m := gameMonitor(o) | |
| 160 | 150 | m.ToggleOverview() | |
| 161 | 151 | ps := m.Layout() | |
| 162 | 152 | cur, game := previewOf(t, ps, 1), previewOf(t, ps, 2) | |
| @@ -180,7 +170,7 @@ func TestOverviewFullscreenFloat(t *testing.T) { | |||
| 180 | 170 | // A click picks the game; Escape from its row returns where the overview | |
| 181 | 171 | // opened. | |
| 182 | 172 | func TestOverviewFullscreenFloatPickAndCancel(t *testing.T) { | |
| 183 | - | m := gameMonitor(OverflowFixed, false) | |
| 173 | + | m := gameMonitor(OverflowFixed) | |
| 184 | 174 | m.ToggleOverview() | |
| 185 | 175 | r := previewOf(t, m.Layout(), 2).Rect | |
| 186 | 176 | if id := m.overviewAt(float64(r.X+1), float64(r.Y+1)); id != 2 { | |
| @@ -201,7 +191,7 @@ func TestOverviewFullscreenFloatPickAndCancel(t *testing.T) { | |||
| 201 | 191 | // The overview opens over the game too: the game is the selected row, | |
| 202 | 192 | // the workspace above shows dimmed, and Escape returns to the game. | |
| 203 | 193 | func TestOverviewOpensOverFullscreenFloat(t *testing.T) { | |
| 204 | - | m := gameMonitor(OverflowFixed, false) | |
| 194 | + | m := gameMonitor(OverflowFixed) | |
| 205 | 195 | m.Focus(1) | |
| 206 | 196 | m.ToggleOverview() | |
| 207 | 197 | if !m.ov.open { | |
| @@ -218,16 +208,16 @@ func TestOverviewOpensOverFullscreenFloat(t *testing.T) { | |||
| 218 | 208 | } | |
| 219 | 209 | } | |
| 220 | 210 | ||
| 221 | - | // A tiled fullscreen window on its own workspace (fixed overflow) is its | |
| 222 | - | // row's only preview too: the selection cannot leave it for a hidden | |
| 223 | - | // column, and Return keeps it fullscreen. | |
| 211 | + | // A tiled fullscreen window (fixed overflow) is its row's only preview | |
| 212 | + | // too: the selection cannot leave it for a hidden column, and Return keeps | |
| 213 | + | // it fullscreen. | |
| 224 | 214 | func TestOverviewOpensOverFullscreenTile(t *testing.T) { | |
| 225 | 215 | m := newMonitor("", "") | |
| 226 | 216 | m.SetOutput(300, 200) | |
| 227 | 217 | m.SetOverflow(OverflowFixed) | |
| 228 | 218 | m.AddWindow(1) | |
| 229 | 219 | m.AddWindow(2) | |
| 230 | - | m.ToggleFullscreen() // 2 to its own workspace | |
| 220 | + | m.ToggleFullscreen() | |
| 231 | 221 | m.ToggleOverview() | |
| 232 | 222 | game := previewOf(t, m.Layout(), 2) | |
| 233 | 223 | if !m.ov.open || !game.Focused || game.Peek || !game.Fullscreen { | |
| @@ -235,7 +225,7 @@ func TestOverviewOpensOverFullscreenTile(t *testing.T) { | |||
| 235 | 225 | } | |
| 236 | 226 | m.OverviewMove(-1, 0) | |
| 237 | 227 | m.ToggleOverview() | |
| 238 | - | if f, _ := m.Focused(); f != 2 || m.Active != 1 || !previewOf(t, m.Layout(), 2).Fullscreen { | |
| 228 | + | if f, _ := m.Focused(); f != 2 || m.Active != 0 || !previewOf(t, m.Layout(), 2).Fullscreen { | |
| 239 | 229 | t.Fatalf("confirm: %d on %d", f, m.Active) | |
| 240 | 230 | } | |
| 241 | 231 | } | |
| @@ -261,10 +251,9 @@ func TestOverviewOverScrollFullscreenPicksColumn(t *testing.T) { | |||
| 261 | 251 | } | |
| 262 | 252 | ||
| 263 | 253 | // Only the game shows on its row: its dialogs and the columns under it | |
| 264 | - | // stay hidden, as on screen. (Under fixed overflow a new column sends | |
| 265 | - | // the game to its own workspace instead.) | |
| 254 | + | // stay hidden, as on screen. | |
| 266 | 255 | func TestOverviewFullscreenFloatHidesOthers(t *testing.T) { | |
| 267 | - | m := gameMonitor(OverflowScroll, true) | |
| 256 | + | m := gameMonitor(OverflowScroll) | |
| 268 | 257 | m.Focus(1) | |
| 269 | 258 | m.AddWindow(5) | |
| 270 | 259 | m.Current().FocusID(2) // back on the game, over column 5 | |
| @@ -126,37 +126,27 @@ func TestOverviewPick(t *testing.T) { | |||||
| 126 | } | 126 | } | ||
| 127 | } | 127 | } | ||
| 128 | 128 | ||||
| 129 | - | // gameMonitor has workspace 1 with column 1 on a 300x200 output and a | 129 | + | // gameMonitor has, on a 300x200 output, window 1 on workspace 1 and, on |
| 130 | - | // floating game 2 (fixed size) fullscreen on its own workspace below, or | 130 | + | // workspace 2, a floating game 2 (fixed size) fullscreen; workspace 1 is |
| 131 | - | // alone in place on workspace 2 when inPlace is set. | 131 | + | // on screen. |
| 132 | - | func gameMonitor(o Overflow, inPlace bool) *Monitor { | 132 | + | func gameMonitor(o Overflow) *Monitor { |
| 133 | m := newMonitor("", "") | 133 | m := newMonitor("", "") | ||
| 134 | m.SetOutput(300, 200) | 134 | m.SetOutput(300, 200) | ||
| 135 | m.SetOverflow(o) | 135 | m.SetOverflow(o) | ||
| 136 | m.AddWindow(1) | 136 | m.AddWindow(1) | ||
| 137 | - | if inPlace { | 137 | + | m.Focus(1) |
| 138 | - | m.Focus(1) | |||
| 139 | - | } | |||
| 140 | m.AddFloating(2, 300, 200) | 138 | m.AddFloating(2, 300, 200) | ||
| 141 | m.SetFullscreen(2, true) | 139 | m.SetFullscreen(2, true) | ||
| 142 | m.Focus(0) | 140 | m.Focus(0) | ||
| 143 | return m | 141 | return m | ||
| 144 | } | 142 | } | ||
| 145 | 143 | ||||
| 146 | - | // A floating game covering its workspace shows as that row's preview, | 144 | + | // A floating game covering its workspace shows as that row's preview: the |
| 147 | - | // dedicated or in place: the row is not empty. | 145 | + | // row is not empty. |
| 148 | func TestOverviewFullscreenFloat(t *testing.T) { | 146 | func TestOverviewFullscreenFloat(t *testing.T) { | ||
| 149 | - | for _, tt := range []struct { | 147 | + | for _, o := range []Overflow{OverflowFixed, OverflowScroll} { |
| 150 | - | name string | 148 | + | t.Run(string(o), func(t *testing.T) { |
| 151 | - | o Overflow | 149 | + | m := gameMonitor(o) |
| 152 | - | inPlace bool | |||
| 153 | - | }{ | |||
| 154 | - | {"own workspace", OverflowFixed, false}, | |||
| 155 | - | {"in place fixed", OverflowFixed, true}, | |||
| 156 | - | {"in place scroll", OverflowScroll, true}, | |||
| 157 | - | } { | |||
| 158 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 159 | - | m := gameMonitor(tt.o, tt.inPlace) | |||
| 160 | m.ToggleOverview() | 150 | m.ToggleOverview() | ||
| 161 | ps := m.Layout() | 151 | ps := m.Layout() | ||
| 162 | cur, game := previewOf(t, ps, 1), previewOf(t, ps, 2) | 152 | cur, game := previewOf(t, ps, 1), previewOf(t, ps, 2) | ||
| @@ -180,7 +170,7 @@ func TestOverviewFullscreenFloat(t *testing.T) { | |||||
| 180 | // A click picks the game; Escape from its row returns where the overview | 170 | // A click picks the game; Escape from its row returns where the overview | ||
| 181 | // opened. | 171 | // opened. | ||
| 182 | func TestOverviewFullscreenFloatPickAndCancel(t *testing.T) { | 172 | func TestOverviewFullscreenFloatPickAndCancel(t *testing.T) { | ||
| 183 | - | m := gameMonitor(OverflowFixed, false) | 173 | + | m := gameMonitor(OverflowFixed) |
| 184 | m.ToggleOverview() | 174 | m.ToggleOverview() | ||
| 185 | r := previewOf(t, m.Layout(), 2).Rect | 175 | r := previewOf(t, m.Layout(), 2).Rect | ||
| 186 | if id := m.overviewAt(float64(r.X+1), float64(r.Y+1)); id != 2 { | 176 | if id := m.overviewAt(float64(r.X+1), float64(r.Y+1)); id != 2 { | ||
| @@ -201,7 +191,7 @@ func TestOverviewFullscreenFloatPickAndCancel(t *testing.T) { | |||||
| 201 | // The overview opens over the game too: the game is the selected row, | 191 | // The overview opens over the game too: the game is the selected row, | ||
| 202 | // the workspace above shows dimmed, and Escape returns to the game. | 192 | // the workspace above shows dimmed, and Escape returns to the game. | ||
| 203 | func TestOverviewOpensOverFullscreenFloat(t *testing.T) { | 193 | func TestOverviewOpensOverFullscreenFloat(t *testing.T) { | ||
| 204 | - | m := gameMonitor(OverflowFixed, false) | 194 | + | m := gameMonitor(OverflowFixed) |
| 205 | m.Focus(1) | 195 | m.Focus(1) | ||
| 206 | m.ToggleOverview() | 196 | m.ToggleOverview() | ||
| 207 | if !m.ov.open { | 197 | if !m.ov.open { | ||
| @@ -218,16 +208,16 @@ func TestOverviewOpensOverFullscreenFloat(t *testing.T) { | |||||
| 218 | } | 208 | } | ||
| 219 | } | 209 | } | ||
| 220 | 210 | ||||
| 221 | - | // A tiled fullscreen window on its own workspace (fixed overflow) is its | 211 | + | // A tiled fullscreen window (fixed overflow) is its row's only preview |
| 222 | - | // row's only preview too: the selection cannot leave it for a hidden | 212 | + | // too: the selection cannot leave it for a hidden column, and Return keeps |
| 223 | - | // column, and Return keeps it fullscreen. | 213 | + | // it fullscreen. |
| 224 | func TestOverviewOpensOverFullscreenTile(t *testing.T) { | 214 | func TestOverviewOpensOverFullscreenTile(t *testing.T) { | ||
| 225 | m := newMonitor("", "") | 215 | m := newMonitor("", "") | ||
| 226 | m.SetOutput(300, 200) | 216 | m.SetOutput(300, 200) | ||
| 227 | m.SetOverflow(OverflowFixed) | 217 | m.SetOverflow(OverflowFixed) | ||
| 228 | m.AddWindow(1) | 218 | m.AddWindow(1) | ||
| 229 | m.AddWindow(2) | 219 | m.AddWindow(2) | ||
| 230 | - | m.ToggleFullscreen() // 2 to its own workspace | 220 | + | m.ToggleFullscreen() |
| 231 | m.ToggleOverview() | 221 | m.ToggleOverview() | ||
| 232 | game := previewOf(t, m.Layout(), 2) | 222 | game := previewOf(t, m.Layout(), 2) | ||
| 233 | if !m.ov.open || !game.Focused || game.Peek || !game.Fullscreen { | 223 | if !m.ov.open || !game.Focused || game.Peek || !game.Fullscreen { | ||
| @@ -235,7 +225,7 @@ func TestOverviewOpensOverFullscreenTile(t *testing.T) { | |||||
| 235 | } | 225 | } | ||
| 236 | m.OverviewMove(-1, 0) | 226 | m.OverviewMove(-1, 0) | ||
| 237 | m.ToggleOverview() | 227 | m.ToggleOverview() | ||
| 238 | - | if f, _ := m.Focused(); f != 2 || m.Active != 1 || !previewOf(t, m.Layout(), 2).Fullscreen { | 228 | + | if f, _ := m.Focused(); f != 2 || m.Active != 0 || !previewOf(t, m.Layout(), 2).Fullscreen { |
| 239 | t.Fatalf("confirm: %d on %d", f, m.Active) | 229 | t.Fatalf("confirm: %d on %d", f, m.Active) | ||
| 240 | } | 230 | } | ||
| 241 | } | 231 | } | ||
| @@ -261,10 +251,9 @@ func TestOverviewOverScrollFullscreenPicksColumn(t *testing.T) { | |||||
| 261 | } | 251 | } | ||
| 262 | 252 | ||||
| 263 | // Only the game shows on its row: its dialogs and the columns under it | 253 | // Only the game shows on its row: its dialogs and the columns under it | ||
| 264 | - | // stay hidden, as on screen. (Under fixed overflow a new column sends | 254 | + | // stay hidden, as on screen. |
| 265 | - | // the game to its own workspace instead.) | |||
| 266 | func TestOverviewFullscreenFloatHidesOthers(t *testing.T) { | 255 | func TestOverviewFullscreenFloatHidesOthers(t *testing.T) { | ||
| 267 | - | m := gameMonitor(OverflowScroll, true) | 256 | + | m := gameMonitor(OverflowScroll) |
| 268 | m.Focus(1) | 257 | m.Focus(1) | ||
| 269 | m.AddWindow(5) | 258 | m.AddWindow(5) | ||
| 270 | m.Current().FocusID(2) // back on the game, over column 5 | 259 | m.Current().FocusID(2) // back on the game, over column 5 | ||
M internal/core/screens.go +8 -26
| @@ -140,7 +140,7 @@ func (c *Core) isPrimary(name string) bool { | |||
| 140 | 140 | // anyWindow reports whether any workspace holds a window. | |
| 141 | 141 | func (c *Core) anyWindow() bool { | |
| 142 | 142 | for _, s := range c.screens { | |
| 143 | - | for _, w := range s.mon.all() { | |
| 143 | + | for w := range s.mon.all() { | |
| 144 | 144 | if !w.empty() { | |
| 145 | 145 | return true | |
| 146 | 146 | } | |
| @@ -276,7 +276,7 @@ func (c *Core) settleGuests() { | |||
| 276 | 276 | if host == home { | |
| 277 | 277 | continue | |
| 278 | 278 | } | |
| 279 | - | for _, w := range host.mon.all() { | |
| 279 | + | for w := range host.mon.all() { | |
| 280 | 280 | if home.mon.matches(w.home) && host.mon.Current() != w { | |
| 281 | 281 | list = append(list, guest{w: w, anchor: w.overviewAfter, host: host.mon, hidden: host.mon.isHidden(w)}) | |
| 282 | 282 | } | |
| @@ -345,9 +345,9 @@ func (c *Core) named() { | |||
| 345 | 345 | ||
| 346 | 346 | // bringNamed prepares a "workspace <name>" toggle from the focused screen. | |
| 347 | 347 | // The workspace goes to its home monitor when connected, else to the focused | |
| 348 | - | // screen, with the fullscreen workspaces that came out of it; that screen | |
| 349 | - | // takes the focus. It reports true when the workspace is already on screen | |
| 350 | - | // there after a focus change: reaching it must not toggle it away. | |
| 348 | + | // screen; that screen takes the focus. It reports true when the workspace | |
| 349 | + | // is already on screen there after a focus change: reaching it must not | |
| 350 | + | // toggle it away. | |
| 351 | 351 | func (c *Core) bringNamed(name string) bool { | |
| 352 | 352 | src, w := c.byName(name) | |
| 353 | 353 | i := slices.IndexFunc(c.specs, func(s NamedWorkspace) bool { return s.Name == name }) | |
| @@ -371,32 +371,14 @@ func (c *Core) bringNamed(name string) bool { | |||
| 371 | 371 | return false | |
| 372 | 372 | } | |
| 373 | 373 | ||
| 374 | - | // moveNamed moves the named workspace w and its fullscreen siblings from src | |
| 375 | - | // to dst, without changing what dst shows. home is their new w.home. | |
| 374 | + | // moveNamed moves the named workspace w from src to dst, without changing | |
| 375 | + | // what dst shows. home is its new w.home: a guest keeps waiting for its | |
| 376 | + | // home; any other forgets the output it was unplugged from. | |
| 376 | 377 | func moveNamed(src, dst *screen, w *Workspace, home string) { | |
| 377 | - | // Fullscreen siblings (always numbered) leave first: without their | |
| 378 | - | // origin on the source, normalize would unlink them. take edits the | |
| 379 | - | // list, so collect first. | |
| 380 | - | var buf [4]*Workspace | |
| 381 | - | siblings := buf[:0] | |
| 382 | - | for _, fs := range src.mon.Workspaces { | |
| 383 | - | if fs.origin == w { | |
| 384 | - | siblings = append(siblings, fs) | |
| 385 | - | } | |
| 386 | - | } | |
| 387 | - | for _, fs := range siblings { | |
| 388 | - | src.mon.take(fs) | |
| 389 | - | } | |
| 390 | 378 | hidden := src.mon.isHidden(w) | |
| 391 | 379 | src.mon.take(w) | |
| 392 | - | // A guest keeps waiting for its home; any other forgets the output it | |
| 393 | - | // was unplugged from. Siblings take the same home. | |
| 394 | 380 | w.home = home | |
| 395 | 381 | dst.mon.adopt(w, hidden, len(dst.mon.Workspaces)) | |
| 396 | - | for _, fs := range siblings { | |
| 397 | - | fs.home = home | |
| 398 | - | dst.mon.adopt(fs, false, len(dst.mon.Workspaces)) | |
| 399 | - | } | |
| 400 | 382 | } | |
| 401 | 383 | ||
| 402 | 384 | // settings applies output-wide config to a monitor. | |
| @@ -140,7 +140,7 @@ func (c *Core) isPrimary(name string) bool { | |||||
| 140 | // anyWindow reports whether any workspace holds a window. | 140 | // anyWindow reports whether any workspace holds a window. | ||
| 141 | func (c *Core) anyWindow() bool { | 141 | func (c *Core) anyWindow() bool { | ||
| 142 | for _, s := range c.screens { | 142 | for _, s := range c.screens { | ||
| 143 | - | for _, w := range s.mon.all() { | 143 | + | for w := range s.mon.all() { |
| 144 | if !w.empty() { | 144 | if !w.empty() { | ||
| 145 | return true | 145 | return true | ||
| 146 | } | 146 | } | ||
| @@ -276,7 +276,7 @@ func (c *Core) settleGuests() { | |||||
| 276 | if host == home { | 276 | if host == home { | ||
| 277 | continue | 277 | continue | ||
| 278 | } | 278 | } | ||
| 279 | - | for _, w := range host.mon.all() { | 279 | + | for w := range host.mon.all() { |
| 280 | if home.mon.matches(w.home) && host.mon.Current() != w { | 280 | if home.mon.matches(w.home) && host.mon.Current() != w { | ||
| 281 | list = append(list, guest{w: w, anchor: w.overviewAfter, host: host.mon, hidden: host.mon.isHidden(w)}) | 281 | list = append(list, guest{w: w, anchor: w.overviewAfter, host: host.mon, hidden: host.mon.isHidden(w)}) | ||
| 282 | } | 282 | } | ||
| @@ -345,9 +345,9 @@ func (c *Core) named() { | |||||
| 345 | 345 | ||||
| 346 | // bringNamed prepares a "workspace <name>" toggle from the focused screen. | 346 | // bringNamed prepares a "workspace <name>" toggle from the focused screen. | ||
| 347 | // The workspace goes to its home monitor when connected, else to the focused | 347 | // The workspace goes to its home monitor when connected, else to the focused | ||
| 348 | - | // screen, with the fullscreen workspaces that came out of it; that screen | 348 | + | // screen; that screen takes the focus. It reports true when the workspace |
| 349 | - | // takes the focus. It reports true when the workspace is already on screen | 349 | + | // is already on screen there after a focus change: reaching it must not |
| 350 | - | // there after a focus change: reaching it must not toggle it away. | 350 | + | // toggle it away. |
| 351 | func (c *Core) bringNamed(name string) bool { | 351 | func (c *Core) bringNamed(name string) bool { | ||
| 352 | src, w := c.byName(name) | 352 | src, w := c.byName(name) | ||
| 353 | i := slices.IndexFunc(c.specs, func(s NamedWorkspace) bool { return s.Name == name }) | 353 | i := slices.IndexFunc(c.specs, func(s NamedWorkspace) bool { return s.Name == name }) | ||
| @@ -371,32 +371,14 @@ func (c *Core) bringNamed(name string) bool { | |||||
| 371 | return false | 371 | return false | ||
| 372 | } | 372 | } | ||
| 373 | 373 | ||||
| 374 | - | // moveNamed moves the named workspace w and its fullscreen siblings from src | 374 | + | // moveNamed moves the named workspace w from src to dst, without changing |
| 375 | - | // to dst, without changing what dst shows. home is their new w.home. | 375 | + | // what dst shows. home is its new w.home: a guest keeps waiting for its |
| 376 | + | // home; any other forgets the output it was unplugged from. | |||
| 376 | func moveNamed(src, dst *screen, w *Workspace, home string) { | 377 | func moveNamed(src, dst *screen, w *Workspace, home string) { | ||
| 377 | - | // Fullscreen siblings (always numbered) leave first: without their | |||
| 378 | - | // origin on the source, normalize would unlink them. take edits the | |||
| 379 | - | // list, so collect first. | |||
| 380 | - | var buf [4]*Workspace | |||
| 381 | - | siblings := buf[:0] | |||
| 382 | - | for _, fs := range src.mon.Workspaces { | |||
| 383 | - | if fs.origin == w { | |||
| 384 | - | siblings = append(siblings, fs) | |||
| 385 | - | } | |||
| 386 | - | } | |||
| 387 | - | for _, fs := range siblings { | |||
| 388 | - | src.mon.take(fs) | |||
| 389 | - | } | |||
| 390 | hidden := src.mon.isHidden(w) | 378 | hidden := src.mon.isHidden(w) | ||
| 391 | src.mon.take(w) | 379 | src.mon.take(w) | ||
| 392 | - | // A guest keeps waiting for its home; any other forgets the output it | |||
| 393 | - | // was unplugged from. Siblings take the same home. | |||
| 394 | w.home = home | 380 | w.home = home | ||
| 395 | dst.mon.adopt(w, hidden, len(dst.mon.Workspaces)) | 381 | dst.mon.adopt(w, hidden, len(dst.mon.Workspaces)) | ||
| 396 | - | for _, fs := range siblings { | |||
| 397 | - | fs.home = home | |||
| 398 | - | dst.mon.adopt(fs, false, len(dst.mon.Workspaces)) | |||
| 399 | - | } | |||
| 400 | } | 382 | } | ||
| 401 | 383 | ||||
| 402 | // settings applies output-wide config to a monitor. | 384 | // settings applies output-wide config to a monitor. | ||
M internal/core/screens_test.go +33 -4
| @@ -807,23 +807,23 @@ func TestExpelCrossesToFullNeighbor(t *testing.T) { | |||
| 807 | 807 | } | |
| 808 | 808 | } | |
| 809 | 809 | ||
| 810 | - | func TestExpelOntoFixedFullscreenFloats(t *testing.T) { | |
| 810 | + | func TestExpelOntoFixedFullscreenLeavesIt(t *testing.T) { | |
| 811 | 811 | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right) | |
| 812 | 812 | // DP-1: [1] [2 3], focus on 3. | |
| 813 | 813 | r.mapWindow(t, 1) | |
| 814 | 814 | r.mapWindow(t, 2) | |
| 815 | 815 | r.mapWindow(t, 3) | |
| 816 | 816 | r.key(t, "bracketleft", ports.ModAlt) | |
| 817 | - | // DP-2: [4] [5]; 5 goes fullscreen on its own workspace. | |
| 817 | + | // DP-2: [4] [5]; 5 goes fullscreen. | |
| 818 | 818 | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 819 | 819 | r.mapWindow(t, 4) | |
| 820 | 820 | r.mapWindow(t, 5) | |
| 821 | 821 | r.key(t, "f", ports.ModAlt|ports.ModShift) | |
| 822 | 822 | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | |
| 823 | - | // 3 floats above the fullscreen window, visible and focused. | |
| 823 | + | // 3 joins as a tile: fullscreen ends so it is seen, focused. | |
| 824 | 824 | set := r.key(t, "bracketright", ports.ModAlt) | |
| 825 | 825 | got, focused := windowsOf(set, "DP-2") | |
| 826 | - | if !slices.Contains(got, 3) || focused != 3 { | |
| 826 | + | if !slices.Contains(got, 3) || !slices.Contains(got, 4) || focused != 3 { | |
| 827 | 827 | t.Fatal(got, focused) | |
| 828 | 828 | } | |
| 829 | 829 | } | |
| @@ -1146,3 +1146,32 @@ func TestNamedOnScreenAtHomeStaysOnBindFromElsewhere(t *testing.T) { | |||
| 1146 | 1146 | t.Fatal(got) | |
| 1147 | 1147 | } | |
| 1148 | 1148 | } | |
| 1149 | + | ||
| 1150 | + | // From a fullscreen window, focus-column-left/right leaves fullscreen for | |
| 1151 | + | // the column on that side; at the workspace edge it goes to the neighbor | |
| 1152 | + | // monitor and the window stays fullscreen. | |
| 1153 | + | func TestFocusColumnLeavesFullscreenBeforeMonitor(t *testing.T) { | |
| 1154 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right) | |
| 1155 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 1156 | + | r.mapWindow(t, 1) | |
| 1157 | + | r.mapWindow(t, 2) | |
| 1158 | + | r.key(t, "f", ports.ModAlt|ports.ModShift) | |
| 1159 | + | // Right edge: DP-2 has no neighbor there, the game stays fullscreen. | |
| 1160 | + | if got, focused := windowsOf(r.key(t, "Right", ports.ModAlt), "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 { | |
| 1161 | + | t.Fatal("edge:", got, focused) | |
| 1162 | + | } | |
| 1163 | + | // Left: window 1 is there, fullscreen ends. | |
| 1164 | + | if got, focused := windowsOf(r.key(t, "Left", ports.ModAlt), "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) || focused != 1 { | |
| 1165 | + | t.Fatal("left:", got, focused) | |
| 1166 | + | } | |
| 1167 | + | // Fullscreen on window 1, the left edge: DP-1 gets the focus and | |
| 1168 | + | // window 1 stays fullscreen on DP-2. | |
| 1169 | + | r.key(t, "f", ports.ModAlt|ports.ModShift) | |
| 1170 | + | set := r.key(t, "Left", ports.ModAlt) | |
| 1171 | + | if got, _ := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{1}) { | |
| 1172 | + | t.Fatal("monitor edge:", got) | |
| 1173 | + | } | |
| 1174 | + | if got, _ := windowsOf(r.mapWindow(t, 3), "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{3}) { | |
| 1175 | + | t.Fatal("focus not on DP-1:", got) | |
| 1176 | + | } | |
| 1177 | + | } | |
| @@ -807,23 +807,23 @@ func TestExpelCrossesToFullNeighbor(t *testing.T) { | |||||
| 807 | } | 807 | } | ||
| 808 | } | 808 | } | ||
| 809 | 809 | ||||
| 810 | - | func TestExpelOntoFixedFullscreenFloats(t *testing.T) { | 810 | + | func TestExpelOntoFixedFullscreenLeavesIt(t *testing.T) { |
| 811 | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right) | 811 | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right) | ||
| 812 | // DP-1: [1] [2 3], focus on 3. | 812 | // DP-1: [1] [2 3], focus on 3. | ||
| 813 | r.mapWindow(t, 1) | 813 | r.mapWindow(t, 1) | ||
| 814 | r.mapWindow(t, 2) | 814 | r.mapWindow(t, 2) | ||
| 815 | r.mapWindow(t, 3) | 815 | r.mapWindow(t, 3) | ||
| 816 | r.key(t, "bracketleft", ports.ModAlt) | 816 | r.key(t, "bracketleft", ports.ModAlt) | ||
| 817 | - | // DP-2: [4] [5]; 5 goes fullscreen on its own workspace. | 817 | + | // DP-2: [4] [5]; 5 goes fullscreen. |
| 818 | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | 818 | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | ||
| 819 | r.mapWindow(t, 4) | 819 | r.mapWindow(t, 4) | ||
| 820 | r.mapWindow(t, 5) | 820 | r.mapWindow(t, 5) | ||
| 821 | r.key(t, "f", ports.ModAlt|ports.ModShift) | 821 | r.key(t, "f", ports.ModAlt|ports.ModShift) | ||
| 822 | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | 822 | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) | ||
| 823 | - | // 3 floats above the fullscreen window, visible and focused. | 823 | + | // 3 joins as a tile: fullscreen ends so it is seen, focused. |
| 824 | set := r.key(t, "bracketright", ports.ModAlt) | 824 | set := r.key(t, "bracketright", ports.ModAlt) | ||
| 825 | got, focused := windowsOf(set, "DP-2") | 825 | got, focused := windowsOf(set, "DP-2") | ||
| 826 | - | if !slices.Contains(got, 3) || focused != 3 { | 826 | + | if !slices.Contains(got, 3) || !slices.Contains(got, 4) || focused != 3 { |
| 827 | t.Fatal(got, focused) | 827 | t.Fatal(got, focused) | ||
| 828 | } | 828 | } | ||
| 829 | } | 829 | } | ||
| @@ -1146,3 +1146,32 @@ func TestNamedOnScreenAtHomeStaysOnBindFromElsewhere(t *testing.T) { | |||||
| 1146 | t.Fatal(got) | 1146 | t.Fatal(got) | ||
| 1147 | } | 1147 | } | ||
| 1148 | } | 1148 | } | ||
| 1149 | + | ||||
| 1150 | + | // From a fullscreen window, focus-column-left/right leaves fullscreen for | |||
| 1151 | + | // the column on that side; at the workspace edge it goes to the neighbor | |||
| 1152 | + | // monitor and the window stays fullscreen. | |||
| 1153 | + | func TestFocusColumnLeavesFullscreenBeforeMonitor(t *testing.T) { | |||
| 1154 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right) | |||
| 1155 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 1156 | + | r.mapWindow(t, 1) | |||
| 1157 | + | r.mapWindow(t, 2) | |||
| 1158 | + | r.key(t, "f", ports.ModAlt|ports.ModShift) | |||
| 1159 | + | // Right edge: DP-2 has no neighbor there, the game stays fullscreen. | |||
| 1160 | + | if got, focused := windowsOf(r.key(t, "Right", ports.ModAlt), "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 { | |||
| 1161 | + | t.Fatal("edge:", got, focused) | |||
| 1162 | + | } | |||
| 1163 | + | // Left: window 1 is there, fullscreen ends. | |||
| 1164 | + | if got, focused := windowsOf(r.key(t, "Left", ports.ModAlt), "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) || focused != 1 { | |||
| 1165 | + | t.Fatal("left:", got, focused) | |||
| 1166 | + | } | |||
| 1167 | + | // Fullscreen on window 1, the left edge: DP-1 gets the focus and | |||
| 1168 | + | // window 1 stays fullscreen on DP-2. | |||
| 1169 | + | r.key(t, "f", ports.ModAlt|ports.ModShift) | |||
| 1170 | + | set := r.key(t, "Left", ports.ModAlt) | |||
| 1171 | + | if got, _ := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{1}) { | |||
| 1172 | + | t.Fatal("monitor edge:", got) | |||
| 1173 | + | } | |||
| 1174 | + | if got, _ := windowsOf(r.mapWindow(t, 3), "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{3}) { | |||
| 1175 | + | t.Fatal("focus not on DP-1:", got) | |||
| 1176 | + | } | |||
| 1177 | + | } | |||
M internal/core/security.go +13 -1
| @@ -34,7 +34,7 @@ func (c *Core) syncSecurity() bool { | |||
| 34 | 34 | c.swipe = nil | |
| 35 | 35 | for _, sc := range c.screens { | |
| 36 | 36 | sc.mon.stopSwitch() | |
| 37 | - | for _, w := range sc.mon.all() { | |
| 37 | + | for w := range sc.mon.all() { | |
| 38 | 38 | w.stopSlide() | |
| 39 | 39 | } | |
| 40 | 40 | } | |
| @@ -69,6 +69,18 @@ func (c *Core) securityCheckpoint(ctx context.Context) error { | |||
| 69 | 69 | return c.publish(ctx) | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | + | // blockProtected reports whether a protected session drops ev. A dropped | |
| 73 | + | // fullscreen request is still answered, at the first publish after unlock. | |
| 74 | + | func (c *Core) blockProtected(ev ports.ClientEvent) bool { | |
| 75 | + | if !c.security.Protected || !blockedProtectedEvent(ev) { | |
| 76 | + | return false | |
| 77 | + | } | |
| 78 | + | if v, ok := ev.(ports.WindowFullscreenRequest); ok { | |
| 79 | + | c.configures.answer[v.ID] = true | |
| 80 | + | } | |
| 81 | + | return true | |
| 82 | + | } | |
| 83 | + | ||
| 72 | 84 | func blockedProtectedEvent(ev ports.ClientEvent) bool { | |
| 73 | 85 | switch ev.(type) { | |
| 74 | 86 | case ports.WindowActivate, ports.WorkspaceActivate, ports.PointerWarp, ports.PointerConstrained, | |
| @@ -34,7 +34,7 @@ func (c *Core) syncSecurity() bool { | |||||
| 34 | c.swipe = nil | 34 | c.swipe = nil | ||
| 35 | for _, sc := range c.screens { | 35 | for _, sc := range c.screens { | ||
| 36 | sc.mon.stopSwitch() | 36 | sc.mon.stopSwitch() | ||
| 37 | - | for _, w := range sc.mon.all() { | 37 | + | for w := range sc.mon.all() { |
| 38 | w.stopSlide() | 38 | w.stopSlide() | ||
| 39 | } | 39 | } | ||
| 40 | } | 40 | } | ||
| @@ -69,6 +69,18 @@ func (c *Core) securityCheckpoint(ctx context.Context) error { | |||||
| 69 | return c.publish(ctx) | 69 | return c.publish(ctx) | ||
| 70 | } | 70 | } | ||
| 71 | 71 | ||||
| 72 | + | // blockProtected reports whether a protected session drops ev. A dropped | |||
| 73 | + | // fullscreen request is still answered, at the first publish after unlock. | |||
| 74 | + | func (c *Core) blockProtected(ev ports.ClientEvent) bool { | |||
| 75 | + | if !c.security.Protected || !blockedProtectedEvent(ev) { | |||
| 76 | + | return false | |||
| 77 | + | } | |||
| 78 | + | if v, ok := ev.(ports.WindowFullscreenRequest); ok { | |||
| 79 | + | c.configures.answer[v.ID] = true | |||
| 80 | + | } | |||
| 81 | + | return true | |||
| 82 | + | } | |||
| 83 | + | ||||
| 72 | func blockedProtectedEvent(ev ports.ClientEvent) bool { | 84 | func blockedProtectedEvent(ev ports.ClientEvent) bool { | ||
| 73 | switch ev.(type) { | 85 | switch ev.(type) { | ||
| 74 | case ports.WindowActivate, ports.WorkspaceActivate, ports.PointerWarp, ports.PointerConstrained, | 86 | case ports.WindowActivate, ports.WorkspaceActivate, ports.PointerWarp, ports.PointerConstrained, | ||
M internal/core/security_internal_test.go +17 -0
| @@ -97,3 +97,20 @@ func TestWiredZeroEpochRejectsRawInputAndCommandKeepsOwnerEpoch(t *testing.T) { | |||
| 97 | 97 | t.Fatal("stale input admitted") | |
| 98 | 98 | } | |
| 99 | 99 | } | |
| 100 | + | ||
| 101 | + | // A fullscreen request dropped while locked is still answered with a | |
| 102 | + | // configure once unlocked (xdg-shell). | |
| 103 | + | func TestProtectedFullscreenRequestAnswered(t *testing.T) { | |
| 104 | + | c, _, _ := hiddenCaptureCommands(t) | |
| 105 | + | p := Placement{ID: 1, Rect: Rect{W: 10, H: 10}} | |
| 106 | + | v, _ := c.configures.next(p, configureTarget{}) | |
| 107 | + | c.configures.mark(v) | |
| 108 | + | c.security = ports.SecurityState{Generation: 1, Protected: true} | |
| 109 | + | if !c.blockProtected(ports.WindowFullscreenRequest{ID: 1, Fullscreen: true}) { | |
| 110 | + | t.Fatal("fullscreen request allowed while locked") | |
| 111 | + | } | |
| 112 | + | c.security = ports.SecurityState{Generation: 2} | |
| 113 | + | if _, send := c.configures.next(p, configureTarget{}); !send { | |
| 114 | + | t.Fatal("locked request not answered") | |
| 115 | + | } | |
| 116 | + | } | |
| @@ -97,3 +97,20 @@ func TestWiredZeroEpochRejectsRawInputAndCommandKeepsOwnerEpoch(t *testing.T) { | |||||
| 97 | t.Fatal("stale input admitted") | 97 | t.Fatal("stale input admitted") | ||
| 98 | } | 98 | } | ||
| 99 | } | 99 | } | ||
| 100 | + | ||||
| 101 | + | // A fullscreen request dropped while locked is still answered with a | |||
| 102 | + | // configure once unlocked (xdg-shell). | |||
| 103 | + | func TestProtectedFullscreenRequestAnswered(t *testing.T) { | |||
| 104 | + | c, _, _ := hiddenCaptureCommands(t) | |||
| 105 | + | p := Placement{ID: 1, Rect: Rect{W: 10, H: 10}} | |||
| 106 | + | v, _ := c.configures.next(p, configureTarget{}) | |||
| 107 | + | c.configures.mark(v) | |||
| 108 | + | c.security = ports.SecurityState{Generation: 1, Protected: true} | |||
| 109 | + | if !c.blockProtected(ports.WindowFullscreenRequest{ID: 1, Fullscreen: true}) { | |||
| 110 | + | t.Fatal("fullscreen request allowed while locked") | |||
| 111 | + | } | |||
| 112 | + | c.security = ports.SecurityState{Generation: 2} | |||
| 113 | + | if _, send := c.configures.next(p, configureTarget{}); !send { | |||
| 114 | + | t.Fatal("locked request not answered") | |||
| 115 | + | } | |||
| 116 | + | } | |||
M internal/core/slots.go +2 -3
| @@ -119,9 +119,8 @@ func (c *Core) releaseSlots() { | |||
| 119 | 119 | if st.window == 0 { | |
| 120 | 120 | continue | |
| 121 | 121 | } | |
| 122 | - | sc, w := c.byName(key.workspace) | |
| 123 | - | // A slot window away in its own fullscreen workspace keeps its slot. | |
| 124 | - | if w == nil || !w.inSlot(st.window, key.index) && !sc.mon.awayInSlot(w, st.window, key.index) { | |
| 122 | + | _, w := c.byName(key.workspace) | |
| 123 | + | if w == nil || !w.inSlot(st.window, key.index) { | |
| 125 | 124 | st.window = 0 | |
| 126 | 125 | } | |
| 127 | 126 | } | |
| @@ -119,9 +119,8 @@ func (c *Core) releaseSlots() { | |||||
| 119 | if st.window == 0 { | 119 | if st.window == 0 { | ||
| 120 | continue | 120 | continue | ||
| 121 | } | 121 | } | ||
| 122 | - | sc, w := c.byName(key.workspace) | 122 | + | _, w := c.byName(key.workspace) |
| 123 | - | // A slot window away in its own fullscreen workspace keeps its slot. | 123 | + | if w == nil || !w.inSlot(st.window, key.index) { |
| 124 | - | if w == nil || !w.inSlot(st.window, key.index) && !sc.mon.awayInSlot(w, st.window, key.index) { | |||
| 125 | st.window = 0 | 124 | st.window = 0 | ||
| 126 | } | 125 | } | ||
| 127 | } | 126 | } | ||
M internal/core/slots_test.go +8 -3
| @@ -142,7 +142,12 @@ func visible(s ports.Scene) map[ports.WindowID]ports.Rect { | |||
| 142 | 142 | func TestWorkspaceActivateRetriesPendingSlot(t *testing.T) { | |
| 143 | 143 | r := startSlots(t) | |
| 144 | 144 | first, second := receive(t, r.spawn), receive(t, r.spawn) | |
| 145 | + | // The channel keeps the latest snapshot: one from before the output | |
| 146 | + | // may still be there. | |
| 145 | 147 | initial := receive(t, r.workspaces) | |
| 148 | + | for len(initial.Outputs) == 0 { | |
| 149 | + | initial = receive(t, r.workspaces) | |
| 150 | + | } | |
| 146 | 151 | var dev uint64 | |
| 147 | 152 | for _, w := range initial.Outputs[0].Workspaces { | |
| 148 | 153 | if w.Configured == "dev" { | |
| @@ -386,9 +391,9 @@ func TestStartupCommands(t *testing.T) { | |||
| 386 | 391 | } | |
| 387 | 392 | } | |
| 388 | 393 | ||
| 389 | - | // A slot window in its own fullscreen workspace (fixed overflow) keeps its | |
| 390 | - | // slot: coming back to dev spawns no duplicate. | |
| 391 | - | func TestSlotKeptWhileFullscreenAway(t *testing.T) { | |
| 394 | + | // A fullscreen slot window (fixed overflow) keeps its slot: coming back to | |
| 395 | + | // dev spawns no duplicate. | |
| 396 | + | func TestSlotKeptWhileFullscreen(t *testing.T) { | |
| 392 | 397 | r := startSlots(t, true, true) | |
| 393 | 398 | r.fill(t) | |
| 394 | 399 | r.press(t, "d") // on dev, focus on code (slot 1) | |
| @@ -142,7 +142,12 @@ func visible(s ports.Scene) map[ports.WindowID]ports.Rect { | |||||
| 142 | func TestWorkspaceActivateRetriesPendingSlot(t *testing.T) { | 142 | func TestWorkspaceActivateRetriesPendingSlot(t *testing.T) { | ||
| 143 | r := startSlots(t) | 143 | r := startSlots(t) | ||
| 144 | first, second := receive(t, r.spawn), receive(t, r.spawn) | 144 | first, second := receive(t, r.spawn), receive(t, r.spawn) | ||
| 145 | + | // The channel keeps the latest snapshot: one from before the output | |||
| 146 | + | // may still be there. | |||
| 145 | initial := receive(t, r.workspaces) | 147 | initial := receive(t, r.workspaces) | ||
| 148 | + | for len(initial.Outputs) == 0 { | |||
| 149 | + | initial = receive(t, r.workspaces) | |||
| 150 | + | } | |||
| 146 | var dev uint64 | 151 | var dev uint64 | ||
| 147 | for _, w := range initial.Outputs[0].Workspaces { | 152 | for _, w := range initial.Outputs[0].Workspaces { | ||
| 148 | if w.Configured == "dev" { | 153 | if w.Configured == "dev" { | ||
| @@ -386,9 +391,9 @@ func TestStartupCommands(t *testing.T) { | |||||
| 386 | } | 391 | } | ||
| 387 | } | 392 | } | ||
| 388 | 393 | ||||
| 389 | - | // A slot window in its own fullscreen workspace (fixed overflow) keeps its | 394 | + | // A fullscreen slot window (fixed overflow) keeps its slot: coming back to |
| 390 | - | // slot: coming back to dev spawns no duplicate. | 395 | + | // dev spawns no duplicate. |
| 391 | - | func TestSlotKeptWhileFullscreenAway(t *testing.T) { | 396 | + | func TestSlotKeptWhileFullscreen(t *testing.T) { |
| 392 | r := startSlots(t, true, true) | 397 | r := startSlots(t, true, true) | ||
| 393 | r.fill(t) | 398 | r.fill(t) | ||
| 394 | r.press(t, "d") // on dev, focus on code (slot 1) | 399 | r.press(t, "d") // on dev, focus on code (slot 1) | ||
M internal/core/spawn_placement.go +5 -3
| @@ -83,8 +83,9 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||
| 83 | 83 | if target.terminal != nil { | |
| 84 | 84 | delete(p.pending, v.Slot) | |
| 85 | 85 | for _, s := range c.screens { | |
| 86 | - | if slices.Contains(s.mon.all(), target.terminal) { | |
| 87 | - | s.mon.arrive(target.terminal, func(w *Workspace) { w.AddWindow(v.ID) }) | |
| 86 | + | if s.mon.has(target.terminal) { | |
| 87 | + | target.terminal.AddWindow(v.ID) | |
| 88 | + | s.mon.normalize() | |
| 88 | 89 | return | |
| 89 | 90 | } | |
| 90 | 91 | } | |
| @@ -92,7 +93,8 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||
| 92 | 93 | if sc, w := c.byName(target.slot.workspace); w != nil { | |
| 93 | 94 | p.dropSlot(target.slot) | |
| 94 | 95 | st.window = v.ID | |
| 95 | - | sc.mon.arrive(w, func(w *Workspace) { w.AddSlotWindow(v.ID, target.slot.index, st.width) }) | |
| 96 | + | w.AddSlotWindow(v.ID, target.slot.index, st.width) | |
| 97 | + | sc.mon.normalize() | |
| 96 | 98 | return | |
| 97 | 99 | } | |
| 98 | 100 | } | |
| @@ -83,8 +83,9 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||||
| 83 | if target.terminal != nil { | 83 | if target.terminal != nil { | ||
| 84 | delete(p.pending, v.Slot) | 84 | delete(p.pending, v.Slot) | ||
| 85 | for _, s := range c.screens { | 85 | for _, s := range c.screens { | ||
| 86 | - | if slices.Contains(s.mon.all(), target.terminal) { | 86 | + | if s.mon.has(target.terminal) { |
| 87 | - | s.mon.arrive(target.terminal, func(w *Workspace) { w.AddWindow(v.ID) }) | 87 | + | target.terminal.AddWindow(v.ID) |
| 88 | + | s.mon.normalize() | |||
| 88 | return | 89 | return | ||
| 89 | } | 90 | } | ||
| 90 | } | 91 | } | ||
| @@ -92,7 +93,8 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||||
| 92 | if sc, w := c.byName(target.slot.workspace); w != nil { | 93 | if sc, w := c.byName(target.slot.workspace); w != nil { | ||
| 93 | p.dropSlot(target.slot) | 94 | p.dropSlot(target.slot) | ||
| 94 | st.window = v.ID | 95 | st.window = v.ID | ||
| 95 | - | sc.mon.arrive(w, func(w *Workspace) { w.AddSlotWindow(v.ID, target.slot.index, st.width) }) | 96 | + | w.AddSlotWindow(v.ID, target.slot.index, st.width) |
| 97 | + | sc.mon.normalize() | |||
| 96 | return | 98 | return | ||
| 97 | } | 99 | } | ||
| 98 | } | 100 | } | ||
M internal/core/stash.go +6 -1
| @@ -79,6 +79,11 @@ func (w *Workspace) unstash(i int) { | |||
| 79 | 79 | w.restore(f.ID, f.back) | |
| 80 | 80 | } | |
| 81 | 81 | ||
| 82 | + | // holdsStack reports whether c holds a window of the stack left behind. | |
| 83 | + | func (p origPlace) holdsStack(c Column) bool { | |
| 84 | + | return slices.ContainsFunc(c.Windows, func(v WindowID) bool { return slices.Contains(p.stacked, v) }) | |
| 85 | + | } | |
| 86 | + | ||
| 82 | 87 | // restore tiles id back where back says: its row in the column that still | |
| 83 | 88 | // holds its former neighbors, else a column of its own at its former | |
| 84 | 89 | // index. It takes the focus. | |
| @@ -105,7 +110,7 @@ func (w *Workspace) restore(id WindowID, back *origPlace) { | |||
| 105 | 110 | return | |
| 106 | 111 | } | |
| 107 | 112 | // Its column is gone: it comes back expanded, unless another column | |
| 108 | - | // was expanded meanwhile (as leaveFullscreen). | |
| 113 | + | // was expanded meanwhile. | |
| 109 | 114 | expanded := back.expanded && !slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Expanded }) | |
| 110 | 115 | w.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded}) | |
| 111 | 116 | if back.fullWidth { | |
| @@ -79,6 +79,11 @@ func (w *Workspace) unstash(i int) { | |||||
| 79 | w.restore(f.ID, f.back) | 79 | w.restore(f.ID, f.back) | ||
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
| 82 | + | // holdsStack reports whether c holds a window of the stack left behind. | |||
| 83 | + | func (p origPlace) holdsStack(c Column) bool { | |||
| 84 | + | return slices.ContainsFunc(c.Windows, func(v WindowID) bool { return slices.Contains(p.stacked, v) }) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 82 | // restore tiles id back where back says: its row in the column that still | 87 | // restore tiles id back where back says: its row in the column that still | ||
| 83 | // holds its former neighbors, else a column of its own at its former | 88 | // holds its former neighbors, else a column of its own at its former | ||
| 84 | // index. It takes the focus. | 89 | // index. It takes the focus. | ||
| @@ -105,7 +110,7 @@ func (w *Workspace) restore(id WindowID, back *origPlace) { | |||||
| 105 | return | 110 | return | ||
| 106 | } | 111 | } | ||
| 107 | // Its column is gone: it comes back expanded, unless another column | 112 | // Its column is gone: it comes back expanded, unless another column | ||
| 108 | - | // was expanded meanwhile (as leaveFullscreen). | 113 | + | // was expanded meanwhile. |
| 109 | expanded := back.expanded && !slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Expanded }) | 114 | expanded := back.expanded && !slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Expanded }) | ||
| 110 | w.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded}) | 115 | w.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded}) | ||
| 111 | if back.fullWidth { | 116 | if back.fullWidth { | ||
M internal/core/stash_test.go +77 -25
| @@ -3,6 +3,8 @@ package core | |||
| 3 | 3 | import ( | |
| 4 | 4 | "slices" | |
| 5 | 5 | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | // stashIDs lists the stash of w, left to right. | |
| @@ -73,33 +75,26 @@ func TestStashFollowsWorkspace(t *testing.T) { | |||
| 73 | 75 | } | |
| 74 | 76 | } | |
| 75 | 77 | ||
| 76 | - | // Fixed overflow gives a fullscreen window its own workspace: a stashed | |
| 77 | - | // one returns to its place in the stash, selected when the user was on it. | |
| 78 | + | // A stashed window goes fullscreen in place and stays in its stash slot, | |
| 79 | + | // selected when the user was on it; a client exit while the user is on | |
| 80 | + | // another workspace leaves the view there. | |
| 78 | 81 | func TestStashFullscreenRoundTrip(t *testing.T) { | |
| 79 | 82 | m := stashMonitor(OverflowFixed) | |
| 80 | 83 | w := m.Current() | |
| 81 | 84 | w.FocusID(2) | |
| 82 | 85 | m.ToggleFullscreen() | |
| 83 | - | if m.Current() == w || !slices.Equal(stashIDs(w), []WindowID{3}) { | |
| 84 | - | t.Fatalf("fullscreen stayed: %v", stashIDs(w)) | |
| 86 | + | if m.Current() != w || w.cover() != 2 || !slices.Equal(stashIDs(w), []WindowID{2, 3}) { | |
| 87 | + | t.Fatalf("fullscreen: cover %d stash %v", w.cover(), stashIDs(w)) | |
| 85 | 88 | } | |
| 86 | 89 | m.ToggleFullscreen() | |
| 87 | - | if m.Current() != w || !slices.Equal(stashIDs(w), []WindowID{2, 3}) { | |
| 88 | - | t.Fatalf("back %v", stashIDs(w)) | |
| 89 | - | } | |
| 90 | - | if id, _ := w.Focused(); id != 2 || w.stashAt != 0 { | |
| 90 | + | if id, _ := w.Focused(); id != 2 || w.stashAt != 0 || w.fullscreen != 0 { | |
| 91 | 91 | t.Fatalf("focused %d at %d", id, w.stashAt) | |
| 92 | 92 | } | |
| 93 | - | // Left by a client request while the user is elsewhere: the selection | |
| 94 | - | // stays on the same window. | |
| 95 | - | w.FocusID(2) | |
| 96 | 93 | m.ToggleFullscreen() | |
| 97 | - | fs := m.Current() | |
| 98 | - | m.Focus(indexOf(m.Workspaces, w)) | |
| 99 | - | w.FocusID(3) | |
| 94 | + | m.Focus(1) | |
| 100 | 95 | m.SetFullscreen(2, false) | |
| 101 | - | if !slices.Equal(stashIDs(w), []WindowID{2, 3}) || w.Stash[w.stashAt].ID != 3 || m.has(fs) { | |
| 102 | - | t.Fatalf("request: %v at %d", stashIDs(w), w.stashAt) | |
| 96 | + | if !slices.Equal(stashIDs(w), []WindowID{2, 3}) || w.fullscreen != 0 || m.Active != 1 { | |
| 97 | + | t.Fatalf("request: %v on %d", stashIDs(w), m.Active) | |
| 103 | 98 | } | |
| 104 | 99 | } | |
| 105 | 100 | ||
| @@ -114,9 +109,8 @@ func TestStashAloneHasFocus(t *testing.T) { | |||
| 114 | 109 | if id, _ := w.Focused(); id != 3 { | |
| 115 | 110 | t.Fatalf("focused %d, want the selected stashed window", id) | |
| 116 | 111 | } | |
| 117 | - | // Fixed overflow: the stashed window goes fullscreen on its own | |
| 118 | - | // workspace, its only tile closes, then the client leaves fullscreen | |
| 119 | - | // while the user is elsewhere. | |
| 112 | + | // Fixed overflow: the stashed window goes fullscreen, its only tile | |
| 113 | + | // closes, then the client leaves fullscreen. | |
| 120 | 114 | m = newMonitor("", "") | |
| 121 | 115 | m.SetOutput(100, 80) | |
| 122 | 116 | m.SetOverflow(OverflowFixed) | |
| @@ -125,15 +119,13 @@ func TestStashAloneHasFocus(t *testing.T) { | |||
| 125 | 119 | w = m.Current() | |
| 126 | 120 | w.ToggleWindowStash() | |
| 127 | 121 | m.ToggleFullscreen() | |
| 128 | - | fs := m.Current() | |
| 129 | - | if fs == w || fs.origin != w { | |
| 130 | - | t.Fatal("no fullscreen workspace") | |
| 122 | + | if w.cover() != 2 { | |
| 123 | + | t.Fatal("not fullscreen") | |
| 131 | 124 | } | |
| 132 | 125 | m.RemoveWindow(1) | |
| 133 | - | m.Focus(indexOf(m.Workspaces, fs) + 1) | |
| 134 | 126 | m.SetFullscreen(2, false) | |
| 135 | - | if len(w.Columns) != 0 || !slices.Equal(stashIDs(w), []WindowID{2}) || m.has(fs) { | |
| 136 | - | t.Fatalf("origin %+v", w) | |
| 127 | + | if len(w.Columns) != 0 || !slices.Equal(stashIDs(w), []WindowID{2}) { | |
| 128 | + | t.Fatalf("workspace %+v", w) | |
| 137 | 129 | } | |
| 138 | 130 | if id, ok := w.Focused(); !ok || id != 2 { | |
| 139 | 131 | t.Fatalf("focused %d %v", id, ok) | |
| @@ -241,3 +233,63 @@ func TestStashWidth(t *testing.T) { | |||
| 241 | 233 | t.Fatalf("clamped high %d", p.Rect.W) | |
| 242 | 234 | } | |
| 243 | 235 | } | |
| 236 | + | ||
| 237 | + | // focus-column from a fullscreen stashed window at the end of the stash | |
| 238 | + | // goes to the neighbor monitor and keeps fullscreen; inside the stash it | |
| 239 | + | // leaves fullscreen for the next stashed window. | |
| 240 | + | func TestFullscreenStashEdgeGoesToMonitor(t *testing.T) { | |
| 241 | + | cfg := ports.Config{} | |
| 242 | + | cfg.Keyboard.CmdKey = "super" | |
| 243 | + | cfg.Layout.MaxColumns = 2 | |
| 244 | + | cfg.Layout.Overflow = string(OverflowFixed) | |
| 245 | + | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}) | |
| 246 | + | if err != nil { | |
| 247 | + | t.Fatal(err) | |
| 248 | + | } | |
| 249 | + | c.addScreen(ports.OutputInfo{Name: "A", Width: 100, Height: 80}) | |
| 250 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 100, Height: 80}) | |
| 251 | + | m := c.screens[0].mon | |
| 252 | + | for id := WindowID(1); id <= 3; id++ { | |
| 253 | + | m.AddWindow(id) | |
| 254 | + | } | |
| 255 | + | w := m.Current() | |
| 256 | + | w.FocusID(2) | |
| 257 | + | w.ToggleWindowStash() | |
| 258 | + | w.FocusID(3) | |
| 259 | + | w.ToggleWindowStash() // stash [2 3], 3 selected | |
| 260 | + | m.ToggleFullscreen() | |
| 261 | + | if w.cover() != 3 { | |
| 262 | + | t.Fatal("setup", w.cover()) | |
| 263 | + | } | |
| 264 | + | c.applyAction(ActionFocusColumnRight) | |
| 265 | + | if c.focusScreen != 1 || w.cover() != 3 { | |
| 266 | + | t.Fatalf("edge: screen %d cover %d", c.focusScreen, w.cover()) | |
| 267 | + | } | |
| 268 | + | c.focusScreen = 0 | |
| 269 | + | c.applyAction(ActionFocusColumnLeft) | |
| 270 | + | if id, _ := w.Focused(); id != 2 || w.fullscreen != 0 || c.focusScreen != 0 { | |
| 271 | + | t.Fatalf("inside: focused %d fullscreen %d", id, w.fullscreen) | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // Activating a hidden stashed window over a scroll fullscreen tile ends | |
| 276 | + | // the fullscreen like any exit: the view leaves the fullscreen alignment. | |
| 277 | + | func TestActivateHiddenStashEndsScrollFullscreen(t *testing.T) { | |
| 278 | + | w := workspace() | |
| 279 | + | w.AddWindow(1) | |
| 280 | + | w.AddWindow(2) | |
| 281 | + | w.AddWindow(3) | |
| 282 | + | w.ToggleWindowStash() // 3 stashed | |
| 283 | + | w.ToggleStashVisible() | |
| 284 | + | w.FocusID(2) | |
| 285 | + | w.SetFullscreen(2, true) | |
| 286 | + | w.Activate(3) | |
| 287 | + | if id, _ := w.Focused(); id != 3 || w.fullscreen != 0 { | |
| 288 | + | t.Fatalf("focused %d fullscreen %d", id, w.fullscreen) | |
| 289 | + | } | |
| 290 | + | view := w.ViewX | |
| 291 | + | w.scroll() | |
| 292 | + | if w.ViewX != view { | |
| 293 | + | t.Fatalf("view %d, settled %d", view, w.ViewX) | |
| 294 | + | } | |
| 295 | + | } | |
| @@ -3,6 +3,8 @@ package core | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "slices" | 4 | "slices" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 6 | ) | 8 | ) | ||
| 7 | 9 | ||||
| 8 | // stashIDs lists the stash of w, left to right. | 10 | // stashIDs lists the stash of w, left to right. | ||
| @@ -73,33 +75,26 @@ func TestStashFollowsWorkspace(t *testing.T) { | |||||
| 73 | } | 75 | } | ||
| 74 | } | 76 | } | ||
| 75 | 77 | ||||
| 76 | - | // Fixed overflow gives a fullscreen window its own workspace: a stashed | 78 | + | // A stashed window goes fullscreen in place and stays in its stash slot, |
| 77 | - | // one returns to its place in the stash, selected when the user was on it. | 79 | + | // selected when the user was on it; a client exit while the user is on |
| 80 | + | // another workspace leaves the view there. | |||
| 78 | func TestStashFullscreenRoundTrip(t *testing.T) { | 81 | func TestStashFullscreenRoundTrip(t *testing.T) { | ||
| 79 | m := stashMonitor(OverflowFixed) | 82 | m := stashMonitor(OverflowFixed) | ||
| 80 | w := m.Current() | 83 | w := m.Current() | ||
| 81 | w.FocusID(2) | 84 | w.FocusID(2) | ||
| 82 | m.ToggleFullscreen() | 85 | m.ToggleFullscreen() | ||
| 83 | - | if m.Current() == w || !slices.Equal(stashIDs(w), []WindowID{3}) { | 86 | + | if m.Current() != w || w.cover() != 2 || !slices.Equal(stashIDs(w), []WindowID{2, 3}) { |
| 84 | - | t.Fatalf("fullscreen stayed: %v", stashIDs(w)) | 87 | + | t.Fatalf("fullscreen: cover %d stash %v", w.cover(), stashIDs(w)) |
| 85 | } | 88 | } | ||
| 86 | m.ToggleFullscreen() | 89 | m.ToggleFullscreen() | ||
| 87 | - | if m.Current() != w || !slices.Equal(stashIDs(w), []WindowID{2, 3}) { | 90 | + | if id, _ := w.Focused(); id != 2 || w.stashAt != 0 || w.fullscreen != 0 { |
| 88 | - | t.Fatalf("back %v", stashIDs(w)) | |||
| 89 | - | } | |||
| 90 | - | if id, _ := w.Focused(); id != 2 || w.stashAt != 0 { | |||
| 91 | t.Fatalf("focused %d at %d", id, w.stashAt) | 91 | t.Fatalf("focused %d at %d", id, w.stashAt) | ||
| 92 | } | 92 | } | ||
| 93 | - | // Left by a client request while the user is elsewhere: the selection | |||
| 94 | - | // stays on the same window. | |||
| 95 | - | w.FocusID(2) | |||
| 96 | m.ToggleFullscreen() | 93 | m.ToggleFullscreen() | ||
| 97 | - | fs := m.Current() | 94 | + | m.Focus(1) |
| 98 | - | m.Focus(indexOf(m.Workspaces, w)) | |||
| 99 | - | w.FocusID(3) | |||
| 100 | m.SetFullscreen(2, false) | 95 | m.SetFullscreen(2, false) | ||
| 101 | - | if !slices.Equal(stashIDs(w), []WindowID{2, 3}) || w.Stash[w.stashAt].ID != 3 || m.has(fs) { | 96 | + | if !slices.Equal(stashIDs(w), []WindowID{2, 3}) || w.fullscreen != 0 || m.Active != 1 { |
| 102 | - | t.Fatalf("request: %v at %d", stashIDs(w), w.stashAt) | 97 | + | t.Fatalf("request: %v on %d", stashIDs(w), m.Active) |
| 103 | } | 98 | } | ||
| 104 | } | 99 | } | ||
| 105 | 100 | ||||
| @@ -114,9 +109,8 @@ func TestStashAloneHasFocus(t *testing.T) { | |||||
| 114 | if id, _ := w.Focused(); id != 3 { | 109 | if id, _ := w.Focused(); id != 3 { | ||
| 115 | t.Fatalf("focused %d, want the selected stashed window", id) | 110 | t.Fatalf("focused %d, want the selected stashed window", id) | ||
| 116 | } | 111 | } | ||
| 117 | - | // Fixed overflow: the stashed window goes fullscreen on its own | 112 | + | // Fixed overflow: the stashed window goes fullscreen, its only tile |
| 118 | - | // workspace, its only tile closes, then the client leaves fullscreen | 113 | + | // closes, then the client leaves fullscreen. |
| 119 | - | // while the user is elsewhere. | |||
| 120 | m = newMonitor("", "") | 114 | m = newMonitor("", "") | ||
| 121 | m.SetOutput(100, 80) | 115 | m.SetOutput(100, 80) | ||
| 122 | m.SetOverflow(OverflowFixed) | 116 | m.SetOverflow(OverflowFixed) | ||
| @@ -125,15 +119,13 @@ func TestStashAloneHasFocus(t *testing.T) { | |||||
| 125 | w = m.Current() | 119 | w = m.Current() | ||
| 126 | w.ToggleWindowStash() | 120 | w.ToggleWindowStash() | ||
| 127 | m.ToggleFullscreen() | 121 | m.ToggleFullscreen() | ||
| 128 | - | fs := m.Current() | 122 | + | if w.cover() != 2 { |
| 129 | - | if fs == w || fs.origin != w { | 123 | + | t.Fatal("not fullscreen") |
| 130 | - | t.Fatal("no fullscreen workspace") | |||
| 131 | } | 124 | } | ||
| 132 | m.RemoveWindow(1) | 125 | m.RemoveWindow(1) | ||
| 133 | - | m.Focus(indexOf(m.Workspaces, fs) + 1) | |||
| 134 | m.SetFullscreen(2, false) | 126 | m.SetFullscreen(2, false) | ||
| 135 | - | if len(w.Columns) != 0 || !slices.Equal(stashIDs(w), []WindowID{2}) || m.has(fs) { | 127 | + | if len(w.Columns) != 0 || !slices.Equal(stashIDs(w), []WindowID{2}) { |
| 136 | - | t.Fatalf("origin %+v", w) | 128 | + | t.Fatalf("workspace %+v", w) |
| 137 | } | 129 | } | ||
| 138 | if id, ok := w.Focused(); !ok || id != 2 { | 130 | if id, ok := w.Focused(); !ok || id != 2 { | ||
| 139 | t.Fatalf("focused %d %v", id, ok) | 131 | t.Fatalf("focused %d %v", id, ok) | ||
| @@ -241,3 +233,63 @@ func TestStashWidth(t *testing.T) { | |||||
| 241 | t.Fatalf("clamped high %d", p.Rect.W) | 233 | t.Fatalf("clamped high %d", p.Rect.W) | ||
| 242 | } | 234 | } | ||
| 243 | } | 235 | } | ||
| 236 | + | ||||
| 237 | + | // focus-column from a fullscreen stashed window at the end of the stash | |||
| 238 | + | // goes to the neighbor monitor and keeps fullscreen; inside the stash it | |||
| 239 | + | // leaves fullscreen for the next stashed window. | |||
| 240 | + | func TestFullscreenStashEdgeGoesToMonitor(t *testing.T) { | |||
| 241 | + | cfg := ports.Config{} | |||
| 242 | + | cfg.Keyboard.CmdKey = "super" | |||
| 243 | + | cfg.Layout.MaxColumns = 2 | |||
| 244 | + | cfg.Layout.Overflow = string(OverflowFixed) | |||
| 245 | + | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}) | |||
| 246 | + | if err != nil { | |||
| 247 | + | t.Fatal(err) | |||
| 248 | + | } | |||
| 249 | + | c.addScreen(ports.OutputInfo{Name: "A", Width: 100, Height: 80}) | |||
| 250 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 100, Height: 80}) | |||
| 251 | + | m := c.screens[0].mon | |||
| 252 | + | for id := WindowID(1); id <= 3; id++ { | |||
| 253 | + | m.AddWindow(id) | |||
| 254 | + | } | |||
| 255 | + | w := m.Current() | |||
| 256 | + | w.FocusID(2) | |||
| 257 | + | w.ToggleWindowStash() | |||
| 258 | + | w.FocusID(3) | |||
| 259 | + | w.ToggleWindowStash() // stash [2 3], 3 selected | |||
| 260 | + | m.ToggleFullscreen() | |||
| 261 | + | if w.cover() != 3 { | |||
| 262 | + | t.Fatal("setup", w.cover()) | |||
| 263 | + | } | |||
| 264 | + | c.applyAction(ActionFocusColumnRight) | |||
| 265 | + | if c.focusScreen != 1 || w.cover() != 3 { | |||
| 266 | + | t.Fatalf("edge: screen %d cover %d", c.focusScreen, w.cover()) | |||
| 267 | + | } | |||
| 268 | + | c.focusScreen = 0 | |||
| 269 | + | c.applyAction(ActionFocusColumnLeft) | |||
| 270 | + | if id, _ := w.Focused(); id != 2 || w.fullscreen != 0 || c.focusScreen != 0 { | |||
| 271 | + | t.Fatalf("inside: focused %d fullscreen %d", id, w.fullscreen) | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // Activating a hidden stashed window over a scroll fullscreen tile ends | |||
| 276 | + | // the fullscreen like any exit: the view leaves the fullscreen alignment. | |||
| 277 | + | func TestActivateHiddenStashEndsScrollFullscreen(t *testing.T) { | |||
| 278 | + | w := workspace() | |||
| 279 | + | w.AddWindow(1) | |||
| 280 | + | w.AddWindow(2) | |||
| 281 | + | w.AddWindow(3) | |||
| 282 | + | w.ToggleWindowStash() // 3 stashed | |||
| 283 | + | w.ToggleStashVisible() | |||
| 284 | + | w.FocusID(2) | |||
| 285 | + | w.SetFullscreen(2, true) | |||
| 286 | + | w.Activate(3) | |||
| 287 | + | if id, _ := w.Focused(); id != 3 || w.fullscreen != 0 { | |||
| 288 | + | t.Fatalf("focused %d fullscreen %d", id, w.fullscreen) | |||
| 289 | + | } | |||
| 290 | + | view := w.ViewX | |||
| 291 | + | w.scroll() | |||
| 292 | + | if w.ViewX != view { | |||
| 293 | + | t.Fatalf("view %d, settled %d", view, w.ViewX) | |||
| 294 | + | } | |||
| 295 | + | } | |||
M internal/core/state.go +1 -1
| @@ -41,7 +41,7 @@ func (c *Core) state() ports.State { | |||
| 41 | 41 | for _, p := range m.Layout() { | |
| 42 | 42 | shown[p.ID] = onScreen(p, m.Frame()) | |
| 43 | 43 | } | |
| 44 | - | for _, w := range m.all() { | |
| 44 | + | for w := range m.all() { | |
| 45 | 45 | n := indexOf(m.Workspaces, w) + 1 | |
| 46 | 46 | for _, id := range w.windows() { | |
| 47 | 47 | rec := c.windows.lookup(id) | |
| @@ -41,7 +41,7 @@ func (c *Core) state() ports.State { | |||||
| 41 | for _, p := range m.Layout() { | 41 | for _, p := range m.Layout() { | ||
| 42 | shown[p.ID] = onScreen(p, m.Frame()) | 42 | shown[p.ID] = onScreen(p, m.Frame()) | ||
| 43 | } | 43 | } | ||
| 44 | - | for _, w := range m.all() { | 44 | + | for w := range m.all() { |
| 45 | n := indexOf(m.Workspaces, w) + 1 | 45 | n := indexOf(m.Workspaces, w) + 1 | ||
| 46 | for _, id := range w.windows() { | 46 | for _, id := range w.windows() { | ||
| 47 | rec := c.windows.lookup(id) | 47 | rec := c.windows.lookup(id) | ||
M internal/core/workspace_size_internal_test.go +0 -80
| @@ -65,48 +65,6 @@ func TestSizedNeighborDisablesSlideButInheritedNeighborDoesNot(t *testing.T) { | |||
| 65 | 65 | } | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | - | // A fullscreen sibling detached from its origin keeps its size on reload; | |
| 69 | - | // one still tied to a named origin follows the config. | |
| 70 | - | func TestDetachedFullscreenSiblingKeepsSizeOnReload(t *testing.T) { | |
| 71 | - | m := sizedMonitor(OverflowFixed) | |
| 72 | - | m.AddWindow(1) | |
| 73 | - | m.AddWindow(2) | |
| 74 | - | m.ToggleFullscreen() | |
| 75 | - | fs := m.Current() | |
| 76 | - | if fs.origin == nil || fs.size != [2]int{100, 100} { | |
| 77 | - | t.Fatalf("sibling %+v size %v", fs.origin, fs.size) | |
| 78 | - | } | |
| 79 | - | m.SetNamed([]NamedWorkspace{{Name: "s", Size: [2]int{120, 80}}}) | |
| 80 | - | if fs.size != [2]int{120, 80} { | |
| 81 | - | t.Fatalf("attached sibling did not follow config: %v", fs.size) | |
| 82 | - | } | |
| 83 | - | fs.origin, fs.back = nil, origPlace{} // moved away or home gone | |
| 84 | - | m.SetNamed([]NamedWorkspace{{Name: "s", Size: [2]int{50, 50}}}) | |
| 85 | - | if fs.size != [2]int{120, 80} { | |
| 86 | - | t.Fatalf("detached sibling resized: %v", fs.size) | |
| 87 | - | } | |
| 88 | - | // A plain unnamed workspace is never resized by config. | |
| 89 | - | if u := m.Workspaces[0]; u.size != ([2]int{}) { | |
| 90 | - | t.Fatalf("unnamed size %v", u.size) | |
| 91 | - | } | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | // Removing a named origin restores inherited geometry on its sibling. | |
| 95 | - | func TestFullscreenSiblingDropsRemovedNamedSize(t *testing.T) { | |
| 96 | - | m := sizedMonitor(OverflowFixed) | |
| 97 | - | m.AddWindow(1) | |
| 98 | - | m.AddWindow(2) | |
| 99 | - | m.ToggleFullscreen() | |
| 100 | - | fs := m.Current() | |
| 101 | - | if fs.Output == m.Output() || fs.origin == nil { | |
| 102 | - | t.Fatal("test did not create sized fullscreen sibling") | |
| 103 | - | } | |
| 104 | - | m.SetNamed(nil) | |
| 105 | - | if fs.Output != m.Output() || fs.size != [2]int{} { | |
| 106 | - | t.Fatalf("removed declaration retained size: %+v", fs.Output) | |
| 107 | - | } | |
| 108 | - | } | |
| 109 | - | ||
| 110 | 68 | // The overview keeps monitor-wide placement and real client aspect ratios. | |
| 111 | 69 | func TestOverviewIgnoresWorkspaceSize(t *testing.T) { | |
| 112 | 70 | m := sizedMonitor(OverflowScroll) | |
| @@ -162,44 +120,6 @@ func TestShownClientRectIsClippedToFrame(t *testing.T) { | |||
| 162 | 120 | } | |
| 163 | 121 | } | |
| 164 | 122 | ||
| 165 | - | // A named workspace following the focus to another screen takes its | |
| 166 | - | // fullscreen siblings along, still linked and sized by the declaration. | |
| 167 | - | func TestBringNamedMovesFullscreenSiblings(t *testing.T) { | |
| 168 | - | var cfg ports.Config | |
| 169 | - | cfg.Keyboard.CmdKey = "super" | |
| 170 | - | cfg.Layout.MaxColumns = 2 | |
| 171 | - | cfg.Layout.Overflow = string(OverflowFixed) | |
| 172 | - | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "s", Size: [2]int{100, 100}}} | |
| 173 | - | c := newSizedCore(t, cfg) | |
| 174 | - | c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200}) | |
| 175 | - | c.addScreen(ports.OutputInfo{Name: "B", Width: 600, Height: 400}) | |
| 176 | - | a, b := c.screens[0], c.screens[1] | |
| 177 | - | a.mon.ToggleNamed("s") | |
| 178 | - | named := a.mon.Current() | |
| 179 | - | a.mon.AddWindow(1) | |
| 180 | - | a.mon.AddWindow(2) | |
| 181 | - | a.mon.ToggleFullscreen() | |
| 182 | - | fs := a.mon.Current() | |
| 183 | - | if fs.origin != named { | |
| 184 | - | t.Fatal("test did not create fullscreen sibling") | |
| 185 | - | } | |
| 186 | - | c.focusScreen = 1 | |
| 187 | - | c.bringNamed("s") | |
| 188 | - | if a.mon.has(named) || a.mon.has(fs) || !b.mon.has(named) || !b.mon.has(fs) { | |
| 189 | - | t.Fatal("named workspace or sibling left behind") | |
| 190 | - | } | |
| 191 | - | if fs.origin != named || fs.size != [2]int{100, 100} || named.Output.W != 100 { | |
| 192 | - | t.Fatalf("sibling %+v size %v, named %+v", fs.origin, fs.size, named.Output) | |
| 193 | - | } | |
| 194 | - | // Siblings drop the home of the screen they left, like the workspace. | |
| 195 | - | named.home, fs.home = "A", "A" | |
| 196 | - | c.focusScreen = 0 | |
| 197 | - | c.bringNamed("s") | |
| 198 | - | if named.home != "" || fs.home != "" || !a.mon.has(fs) || fs.origin != named { | |
| 199 | - | t.Fatalf("home %q sibling home %q", named.home, fs.home) | |
| 200 | - | } | |
| 201 | - | } | |
| 202 | - | ||
| 203 | 123 | // Toggling a named workspace that is already on the focused screen, or on | |
| 204 | 124 | // its connected home, allocates nothing to find it. | |
| 205 | 125 | func TestBringNamedLookupAllocations(t *testing.T) { | |
| @@ -65,48 +65,6 @@ func TestSizedNeighborDisablesSlideButInheritedNeighborDoesNot(t *testing.T) { | |||||
| 65 | } | 65 | } | ||
| 66 | } | 66 | } | ||
| 67 | 67 | ||||
| 68 | - | // A fullscreen sibling detached from its origin keeps its size on reload; | |||
| 69 | - | // one still tied to a named origin follows the config. | |||
| 70 | - | func TestDetachedFullscreenSiblingKeepsSizeOnReload(t *testing.T) { | |||
| 71 | - | m := sizedMonitor(OverflowFixed) | |||
| 72 | - | m.AddWindow(1) | |||
| 73 | - | m.AddWindow(2) | |||
| 74 | - | m.ToggleFullscreen() | |||
| 75 | - | fs := m.Current() | |||
| 76 | - | if fs.origin == nil || fs.size != [2]int{100, 100} { | |||
| 77 | - | t.Fatalf("sibling %+v size %v", fs.origin, fs.size) | |||
| 78 | - | } | |||
| 79 | - | m.SetNamed([]NamedWorkspace{{Name: "s", Size: [2]int{120, 80}}}) | |||
| 80 | - | if fs.size != [2]int{120, 80} { | |||
| 81 | - | t.Fatalf("attached sibling did not follow config: %v", fs.size) | |||
| 82 | - | } | |||
| 83 | - | fs.origin, fs.back = nil, origPlace{} // moved away or home gone | |||
| 84 | - | m.SetNamed([]NamedWorkspace{{Name: "s", Size: [2]int{50, 50}}}) | |||
| 85 | - | if fs.size != [2]int{120, 80} { | |||
| 86 | - | t.Fatalf("detached sibling resized: %v", fs.size) | |||
| 87 | - | } | |||
| 88 | - | // A plain unnamed workspace is never resized by config. | |||
| 89 | - | if u := m.Workspaces[0]; u.size != ([2]int{}) { | |||
| 90 | - | t.Fatalf("unnamed size %v", u.size) | |||
| 91 | - | } | |||
| 92 | - | } | |||
| 93 | - | ||||
| 94 | - | // Removing a named origin restores inherited geometry on its sibling. | |||
| 95 | - | func TestFullscreenSiblingDropsRemovedNamedSize(t *testing.T) { | |||
| 96 | - | m := sizedMonitor(OverflowFixed) | |||
| 97 | - | m.AddWindow(1) | |||
| 98 | - | m.AddWindow(2) | |||
| 99 | - | m.ToggleFullscreen() | |||
| 100 | - | fs := m.Current() | |||
| 101 | - | if fs.Output == m.Output() || fs.origin == nil { | |||
| 102 | - | t.Fatal("test did not create sized fullscreen sibling") | |||
| 103 | - | } | |||
| 104 | - | m.SetNamed(nil) | |||
| 105 | - | if fs.Output != m.Output() || fs.size != [2]int{} { | |||
| 106 | - | t.Fatalf("removed declaration retained size: %+v", fs.Output) | |||
| 107 | - | } | |||
| 108 | - | } | |||
| 109 | - | ||||
| 110 | // The overview keeps monitor-wide placement and real client aspect ratios. | 68 | // The overview keeps monitor-wide placement and real client aspect ratios. | ||
| 111 | func TestOverviewIgnoresWorkspaceSize(t *testing.T) { | 69 | func TestOverviewIgnoresWorkspaceSize(t *testing.T) { | ||
| 112 | m := sizedMonitor(OverflowScroll) | 70 | m := sizedMonitor(OverflowScroll) | ||
| @@ -162,44 +120,6 @@ func TestShownClientRectIsClippedToFrame(t *testing.T) { | |||||
| 162 | } | 120 | } | ||
| 163 | } | 121 | } | ||
| 164 | 122 | ||||
| 165 | - | // A named workspace following the focus to another screen takes its | |||
| 166 | - | // fullscreen siblings along, still linked and sized by the declaration. | |||
| 167 | - | func TestBringNamedMovesFullscreenSiblings(t *testing.T) { | |||
| 168 | - | var cfg ports.Config | |||
| 169 | - | cfg.Keyboard.CmdKey = "super" | |||
| 170 | - | cfg.Layout.MaxColumns = 2 | |||
| 171 | - | cfg.Layout.Overflow = string(OverflowFixed) | |||
| 172 | - | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "s", Size: [2]int{100, 100}}} | |||
| 173 | - | c := newSizedCore(t, cfg) | |||
| 174 | - | c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200}) | |||
| 175 | - | c.addScreen(ports.OutputInfo{Name: "B", Width: 600, Height: 400}) | |||
| 176 | - | a, b := c.screens[0], c.screens[1] | |||
| 177 | - | a.mon.ToggleNamed("s") | |||
| 178 | - | named := a.mon.Current() | |||
| 179 | - | a.mon.AddWindow(1) | |||
| 180 | - | a.mon.AddWindow(2) | |||
| 181 | - | a.mon.ToggleFullscreen() | |||
| 182 | - | fs := a.mon.Current() | |||
| 183 | - | if fs.origin != named { | |||
| 184 | - | t.Fatal("test did not create fullscreen sibling") | |||
| 185 | - | } | |||
| 186 | - | c.focusScreen = 1 | |||
| 187 | - | c.bringNamed("s") | |||
| 188 | - | if a.mon.has(named) || a.mon.has(fs) || !b.mon.has(named) || !b.mon.has(fs) { | |||
| 189 | - | t.Fatal("named workspace or sibling left behind") | |||
| 190 | - | } | |||
| 191 | - | if fs.origin != named || fs.size != [2]int{100, 100} || named.Output.W != 100 { | |||
| 192 | - | t.Fatalf("sibling %+v size %v, named %+v", fs.origin, fs.size, named.Output) | |||
| 193 | - | } | |||
| 194 | - | // Siblings drop the home of the screen they left, like the workspace. | |||
| 195 | - | named.home, fs.home = "A", "A" | |||
| 196 | - | c.focusScreen = 0 | |||
| 197 | - | c.bringNamed("s") | |||
| 198 | - | if named.home != "" || fs.home != "" || !a.mon.has(fs) || fs.origin != named { | |||
| 199 | - | t.Fatalf("home %q sibling home %q", named.home, fs.home) | |||
| 200 | - | } | |||
| 201 | - | } | |||
| 202 | - | ||||
| 203 | // Toggling a named workspace that is already on the focused screen, or on | 123 | // Toggling a named workspace that is already on the focused screen, or on | ||
| 204 | // its connected home, allocates nothing to find it. | 124 | // its connected home, allocates nothing to find it. | ||
| 205 | func TestBringNamedLookupAllocations(t *testing.T) { | 125 | func TestBringNamedLookupAllocations(t *testing.T) { | ||
M internal/core/workspace_size_test.go +3 -3
| @@ -91,20 +91,20 @@ func TestWorkspaceSizeFullscreenFillsFrame(t *testing.T) { | |||
| 91 | 91 | } | |
| 92 | 92 | } | |
| 93 | 93 | ||
| 94 | - | func TestWorkspaceSizeFixedFullscreenSiblingKeepsFrame(t *testing.T) { | |
| 94 | + | func TestWorkspaceSizeFixedFullscreenKeepsFrame(t *testing.T) { | |
| 95 | 95 | r, _ := sizedRig(t, "fixed", nil) | |
| 96 | 96 | r.mapWindow(t, 1) | |
| 97 | 97 | r.mapWindow(t, 2) | |
| 98 | 98 | s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0] | |
| 99 | 99 | if s.WorkspaceClip != frame { | |
| 100 | - | t.Fatalf("sibling clip %+v", s.WorkspaceClip) | |
| 100 | + | t.Fatalf("fullscreen clip %+v", s.WorkspaceClip) | |
| 101 | 101 | } | |
| 102 | 102 | for _, w := range s.Windows { | |
| 103 | 103 | if w.Fullscreen && w.Rect != frame { | |
| 104 | 104 | t.Fatalf("%+v", w) | |
| 105 | 105 | } | |
| 106 | 106 | } | |
| 107 | - | // Reload keeps the origin's size on the sibling; removing it frees both. | |
| 107 | + | // Reload keeps the size; removing it frees the workspace. | |
| 108 | 108 | cfg := r.cfg | |
| 109 | 109 | r.reload <- ports.ConfigChanged{Config: cfg} | |
| 110 | 110 | if s := receive(t, r.scenes)[0]; s.WorkspaceClip != frame { | |
| @@ -91,20 +91,20 @@ func TestWorkspaceSizeFullscreenFillsFrame(t *testing.T) { | |||||
| 91 | } | 91 | } | ||
| 92 | } | 92 | } | ||
| 93 | 93 | ||||
| 94 | - | func TestWorkspaceSizeFixedFullscreenSiblingKeepsFrame(t *testing.T) { | 94 | + | func TestWorkspaceSizeFixedFullscreenKeepsFrame(t *testing.T) { |
| 95 | r, _ := sizedRig(t, "fixed", nil) | 95 | r, _ := sizedRig(t, "fixed", nil) | ||
| 96 | r.mapWindow(t, 1) | 96 | r.mapWindow(t, 1) | ||
| 97 | r.mapWindow(t, 2) | 97 | r.mapWindow(t, 2) | ||
| 98 | s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0] | 98 | s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0] | ||
| 99 | if s.WorkspaceClip != frame { | 99 | if s.WorkspaceClip != frame { | ||
| 100 | - | t.Fatalf("sibling clip %+v", s.WorkspaceClip) | 100 | + | t.Fatalf("fullscreen clip %+v", s.WorkspaceClip) |
| 101 | } | 101 | } | ||
| 102 | for _, w := range s.Windows { | 102 | for _, w := range s.Windows { | ||
| 103 | if w.Fullscreen && w.Rect != frame { | 103 | if w.Fullscreen && w.Rect != frame { | ||
| 104 | t.Fatalf("%+v", w) | 104 | t.Fatalf("%+v", w) | ||
| 105 | } | 105 | } | ||
| 106 | } | 106 | } | ||
| 107 | - | // Reload keeps the origin's size on the sibling; removing it frees both. | 107 | + | // Reload keeps the size; removing it frees the workspace. |
| 108 | cfg := r.cfg | 108 | cfg := r.cfg | ||
| 109 | r.reload <- ports.ConfigChanged{Config: cfg} | 109 | r.reload <- ports.ConfigChanged{Config: cfg} | ||
| 110 | if s := receive(t, r.scenes)[0]; s.WorkspaceClip != frame { | 110 | if s := receive(t, r.scenes)[0]; s.WorkspaceClip != frame { | ||
M internal/core/workspaces.go +3 -1
| @@ -21,7 +21,9 @@ func (c *Core) publishWorkspaces() { | |||
| 21 | 21 | } | |
| 22 | 22 | m := sc.mon | |
| 23 | 23 | out := ports.WorkspaceOutput{Name: sc.name(), Workspaces: []ports.WorkspaceInfo{}} | |
| 24 | - | for i, w := range m.all() { | |
| 24 | + | i := -1 | |
| 25 | + | for w := range m.all() { | |
| 26 | + | i++ | |
| 25 | 27 | name := w.Name | |
| 26 | 28 | if name == "" { | |
| 27 | 29 | name = strconv.Itoa(i + 1) | |
| @@ -21,7 +21,9 @@ func (c *Core) publishWorkspaces() { | |||||
| 21 | } | 21 | } | ||
| 22 | m := sc.mon | 22 | m := sc.mon | ||
| 23 | out := ports.WorkspaceOutput{Name: sc.name(), Workspaces: []ports.WorkspaceInfo{}} | 23 | out := ports.WorkspaceOutput{Name: sc.name(), Workspaces: []ports.WorkspaceInfo{}} | ||
| 24 | - | for i, w := range m.all() { | 24 | + | i := -1 |
| 25 | + | for w := range m.all() { | |||
| 26 | + | i++ | |||
| 25 | name := w.Name | 27 | name := w.Name | ||
| 26 | if name == "" { | 28 | if name == "" { | ||
| 27 | name = strconv.Itoa(i + 1) | 29 | name = strconv.Itoa(i + 1) | ||