detached d79bf75

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 UnverifiedB5690EEEBB952194 The 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
117117 | `spawn <command>` | Run a program |
118118 | `spawn-terminal` | Run `terminal` |
119119 | `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 |
121121 | `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 |
122122 | `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 |
123123 | `toggle-overview` | Open the [overview](#overview), or close it on the selected window |
124124 | `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 |
125125 | `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 |
128128 | `move-column-left/right` | Move the focused column |
129129 | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column |
130130 | `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 {
146146 } else {
147147 dir = 1
148148 }
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+ }
149160 // The first move from a float stays here unless nothing is under it:
150161 // 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())
154164 if i := c.neighbor(dir); edge && i >= 0 {
155165 c.focusScreen = i
156166 return Effect{}
@@ -176,7 +186,7 @@ func (c *Core) applyAction(a Action) Effect {
176186 return Effect{}
177187 }
178188 c.focusScreen = i
179- c.cur().mon.landing().expelTo(id, dir)
189+ c.cur().mon.Current().expelTo(id, dir)
180190 from.mon.normalize()
181191 c.cur().mon.normalize()
182192 return Effect{}
@@ -196,7 +206,7 @@ func (c *Core) applyAction(a Action) Effect {
196206 }
197207 from := c.cur()
198208 c.focusScreen = i
199- to := c.cur().mon.landing()
209+ to := c.cur().mon.Current()
200210 at := 0
201211 if dir < 0 {
202212 at = len(to.Columns)
@@ -320,8 +330,6 @@ func (w *Workspace) Apply(a Action) Effect {
320330 case ActionMaximizeColumn:
321331 w.ToggleFullWidth()
322332 case ActionToggleFullscreen:
323- // In place only: binds go through Monitor.Apply, which gives a
324- // fixed-overflow fullscreen its own workspace.
325333 w.ToggleFullscreen()
326334 case ActionToggleWindowStash:
327335 w.ToggleWindowStash()
@@ -146,11 +146,21 @@ func (c *Core) applyAction(a Action) Effect {
146 } else {146 } else {
147 dir = 1147 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 stash161 // a native float leaves for the stash or the columns, and the stash
151- // keeps the focus at its ends. A covering fullscreen window pins162+ // 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 = i165 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 = i188 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 = i208 c.focusScreen = i
199- to := c.cur().mon.landing()209+ to := c.cur().mon.Current()
200 at := 0210 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 {
434434 // workspaceByID finds a workspace, numbered or hidden, on any screen.
435435 func (c *Core) workspaceByID(id uint64) (*screen, *Workspace) {
436436 for _, sc := range c.screens {
437- for _, w := range sc.mon.all() {
437+ for w := range sc.mon.all() {
438438 if w.ID == id {
439439 return sc, w
440440 }
@@ -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, w439 return sc, w
440 }440 }
M internal/core/configures.go +15 -4
@@ -23,12 +23,15 @@ type configureTarget struct {
2323 type configures struct {
2424 sent map[WindowID]ports.ConfigureWindow
2525 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
2629 // cw is the workspace a capture session renders off screen, if any.
2730 cw captureWorkspace
2831 }
2932
3033 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{}}
3235 }
3336
3437 // 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
3740 s.seen[p.ID] = true
3841 old, ok := s.sent[p.ID]
3942 v := build(p, t, old, ok)
40- return v, !ok || v != old
43+ return v, !ok || v != old || s.answer[p.ID]
4144 }
4245
4346 // 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
6669 v = build(p, t, old, ok)
6770 }
6871 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]
7073 }
7174
7275 // 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
113116 }
114117
115118 // 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+}
117123
118124 // prune forgets the windows no next call saw since the last prune: they
119125 // left the layout.
@@ -123,5 +129,10 @@ func (s *configures) prune() {
123129 delete(s.sent, id)
124130 }
125131 }
132+ for id := range s.answer {
133+ if !s.seen[id] {
134+ delete(s.answer, id)
135+ }
136+ }
126137 clear(s.seen)
127138 }
@@ -23,12 +23,15 @@ type configureTarget struct {
23 type configures struct {23 type configures struct {
24 sent map[WindowID]ports.ConfigureWindow24 sent map[WindowID]ports.ConfigureWindow
25 seen map[WindowID]bool25 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 captureWorkspace30 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. The37 // 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] = true40 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 != old43+ 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 session46 // 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 != old72+ 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. It75 // 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: they124 // 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) {
4949 }
5050 }
5151
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+
5268 func TestConfiguresPruneForgetsUnseen(t *testing.T) {
5369 s := newConfigures()
5470 for _, id := range []WindowID{1, 2} {
@@ -67,4 +83,10 @@ func TestConfiguresPruneForgetsUnseen(t *testing.T) {
6783 if _, send := s.next(Placement{ID: 2}, configureTarget{}); !send {
6884 t.Fatal("returning window not configured")
6985 }
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+ }
7092 }
@@ -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 {
524524 var real map[WindowID]Placement
525525 if sc.mon.ov.open {
526526 real = make(map[WindowID]Placement)
527- for _, w := range sc.mon.all() {
527+ for w := range sc.mon.all() {
528528 for _, p := range w.Layout() {
529529 real[p.ID] = p
530530 }
@@ -915,7 +915,7 @@ func (c *Core) Run(ctx context.Context) error {
915915 if c.securityCheckpoint(ctx) != nil {
916916 return nil
917917 }
918- if c.security.Protected && blockedProtectedEvent(ev) {
918+ if c.blockProtected(ev) {
919919 continue
920920 }
921921 switch v := ev.(type) {
@@ -1001,8 +1001,15 @@ func (c *Core) Run(ctx context.Context) error {
10011001 return nil
10021002 }
10031003 case ports.WindowFullscreenRequest:
1004+ c.configures.answer[v.ID] = true
10041005 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
10061013 }
10071014 if s, _ := c.screenOf(v.ID); s != nil {
10081015 s.mon.SetFullscreen(v.ID, v.Fullscreen)
@@ -1014,7 +1021,7 @@ func (c *Core) Run(ctx context.Context) error {
10141021 if sc.name() == "" {
10151022 continue
10161023 }
1017- for _, w := range sc.mon.all() {
1024+ for w := range sc.mon.all() {
10181025 if w.ID == id {
10191026 c.focusScreen = i
10201027 sc.mon.show(w)
@@ -1182,11 +1189,6 @@ func (c *Core) activate(ctx context.Context, id WindowID) error {
11821189 return nil
11831190 }
11841191 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- }
11901192 if w != s.mon.Current() {
11911193 s.mon.show(w)
11921194 }
@@ -524,7 +524,7 @@ func (c *Core) publish(ctx context.Context) error {
524 var real map[WindowID]Placement524 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] = p529 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 nil916 return nil
917 }917 }
918- if c.security.Protected && blockedProtectedEvent(ev) {918+ if c.blockProtected(ev) {
919 continue919 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 nil1001 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- continue1006+ 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 continue1022 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 = i1026 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 nil1189 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) {
837837 }
838838 }
839839 now.Add(1)
840+ // Only the focused window may cover the screen (ADR 011).
841+ client <- ports.WindowUnmapped{ID: 3}
842+ scene(t, scenes)
840843 client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true}
841844 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 }) {
845847 t.Fatalf("later fullscreen ignored: %+v", s.Windows)
846848 }
847849 }
848850
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) {
853856 r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)
854857 r.mapWindow(t, 1)
855858 r.key(t, "f", ports.ModAlt|ports.ModShift)
@@ -862,7 +865,7 @@ func TestFixedFullscreenArrivalTilesAtHome(t *testing.T) {
862865 }
863866 r.client <- ports.WindowActivate{ID: 2}
864867 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 {
866869 t.Fatal("after activate:", got, focused)
867870 }
868871 for {
@@ -870,10 +873,10 @@ func TestFixedFullscreenArrivalTilesAtHome(t *testing.T) {
870873 break
871874 }
872875 }
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)
875878 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)
877880 }
878881 }
879882
@@ -904,6 +907,113 @@ func TestExternalFullscreenAtMapApplies(t *testing.T) {
904907 }
905908 }
906909
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+
9071017 // An activated window on another workspace comes on screen with focus.
9081018 func TestWindowActivateShowsAndFocuses(t *testing.T) {
9091019 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 view845+ // 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 on851+// Under fixed overflow, a window opening over a fullscreen game waits
850-// its home workspace: the game keeps the screen and the keyboard, and an852+// 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 break873 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) {
9898 if dir < 0 {
9999 at = len(w.Columns)
100100 }
101- if !w.full() || w.origin != nil {
101+ if !w.full() {
102102 w.receive(Column{Windows: []WindowID{id}}, at)
103103 return
104104 }
@@ -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 return103 return
104 }104 }
M internal/core/float_order_test.go +11 -19
@@ -91,18 +91,11 @@ func TestDemotedFloatFullscreenRoundTrip(t *testing.T) {
9191 w.FocusID(1)
9292 wantOrder(t, w, 2, 1)
9393 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)
10597 }
98+ m.SetFullscreen(2, false)
10699 wantOrder(t, w, 2, 1)
107100 if id, _ := w.Focused(); id != 1 {
108101 t.Fatalf("focus %d", id)
@@ -216,8 +209,7 @@ func TestFocusWindowWithoutDemotedFloatChangesWorkspace(t *testing.T) {
216209 }
217210
218211 // 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.
221213 func TestFocusWindowUpStashAndPinned(t *testing.T) {
222214 m := monitor()
223215 m.SetBorder(2)
@@ -239,12 +231,12 @@ func TestFocusWindowUpStashAndPinned(t *testing.T) {
239231 m.SetOverflow(OverflowFixed)
240232 m.AddWindow(1)
241233 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() {
244236 t.Fatalf("setup: active %d", m.Active)
245237 }
246238 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 {
248240 t.Fatalf("pinned up focused %d on %d", id, m.Active)
249241 }
250242 }
@@ -263,8 +255,8 @@ func TestCoveringFloatAloneKeepsFocus(t *testing.T) {
263255 wantOrder(t, w, 3)
264256 }
265257
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.
268260 func TestFullscreenReturnRaisesColumns(t *testing.T) {
269261 for _, stacked := range []bool{false, true} {
270262 m := monitor()
@@ -277,7 +269,7 @@ func TestFullscreenReturnRaisesColumns(t *testing.T) {
277269 }
278270 w := m.Current()
279271 w.FocusID(2)
280- m.ToggleFullscreen() // 2 to its own workspace
272+ m.ToggleFullscreen()
281273 w.AddFloating(3, 100, 80)
282274 m.ToggleFullscreen() // back, focused
283275 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 covering211 // 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 below234+ 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 comes258+// 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 workspace272+ m.ToggleFullscreen()
281 w.AddFloating(3, 100, 80)273 w.AddFloating(3, 100, 80)
282 m.ToggleFullscreen() // back, focused274 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) {
3232 m.AddWindow(1)
3333 m.AddFloating(2, 100, 80)
3434 w := m.Current()
35- w.FocusID(1)
35+ w.FocusID(2)
3636 m.SetFullscreen(2, true)
37- m.Focus(1)
38- m.Current().FocusID(2)
37+ w.focusCover()
3938 m.SetFullscreen(2, false)
4039 if id, _ := w.Focused(); id != 2 || !w.floatFocus || w.Floats[0].below {
4140 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) {
228228 }
229229
230230 // 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.
233232 func TestFullscreenPinsFocus(t *testing.T) {
234233 w := workspace()
235234 w.AddWindow(1)
236235 w.AddWindow(2)
237236 w.AddFloating(3, 20, 10)
238237 w.SetFullscreen(3, true)
239- w.FocusColumn(-1)
240- w.FocusWindow(1)
241238 w.focusCover()
242239 w.Apply(ActionMoveColumnLeft)
243240 w.Apply(ActionCycleColumnWidth)
@@ -247,14 +244,48 @@ func TestFullscreenPinsFocus(t *testing.T) {
247244 if e := w.Apply(ActionCloseWindow); e.Close != 3 {
248245 t.Fatalf("close %d, want the fullscreen window", e.Close)
249246 }
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+ }
251261 f := workspace()
252262 f.Overflow = OverflowFixed
253263 f.AddWindow(1)
254264 f.AddWindow(2)
255265 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)
258289 }
259290 // Scroll overflow: moving off a fullscreen column still works; it no
260291 // 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 on231+// 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 = OverflowFixed262 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 no290 // 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) {
323323 m.stopSwitch()
324324 }
325325 }
326- for _, w := range m.all() {
326+ for w := range m.all() {
327327 if w.motion != nil {
328328 v, done := w.motion.at(now)
329329 w.shift = v
@@ -355,7 +355,7 @@ func (m *Monitor) springing() bool {
355355 if m.switchMotion != nil {
356356 return true
357357 }
358- for _, w := range m.all() {
358+ for w := range m.all() {
359359 if w.motion != nil {
360360 return true
361361 }
@@ -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 = v329 w.shift = v
@@ -355,7 +355,7 @@ func (m *Monitor) springing() bool {
355 if m.switchMotion != nil {355 if m.switchMotion != nil {
356 return true356 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 true360 return true
361 }361 }
M internal/core/layout.go +84 -44
@@ -183,19 +183,11 @@ type Workspace struct {
183183 homePos int
184184 // termAt is when core last spawned a terminal for this workspace.
185185 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
190186 }
191187
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.
194190 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
199191 col, row, slot int
200192 // stacked holds the windows left in the column: the window returns to
201193 // its row in the column that still holds one of them.
@@ -204,11 +196,6 @@ type origPlace struct {
204196 expanded bool
205197 // fullWidth is the column's maximize-column state.
206198 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
212199 }
213200
214201 // Float is a floating window and its client size, logical.
@@ -525,10 +512,19 @@ func (w *Workspace) FocusID(id WindowID) bool {
525512 }
526513 return false
527514 }
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 {
529520 if w.pinned() {
530- return
521+ return w.leaveCover(func() { w.focusColumn(dir) })
531522 }
523+ w.focusColumn(dir)
524+ return true
525+}
526+
527+func (w *Workspace) focusColumn(dir int) {
532528 if w.floatFocus {
533529 // The first move leaves the native float for the stash or columns,
534530 // if there is one: otherwise the float keeps the focus.
@@ -577,11 +573,16 @@ func (w *Workspace) onScreenFocus() bool {
577573
578574 // FocusWindow moves focus inside the column, then to a fixed-overflow column
579575 // 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.
581578 func (w *Workspace) FocusWindow(dir int) bool {
582579 if w.pinned() {
583- return false
580+ return w.leaveCover(func() { w.focusWindow(dir) })
584581 }
582+ return w.focusWindow(dir)
583+}
584+
585+func (w *Workspace) focusWindow(dir int) bool {
585586 if w.floatFocus {
586587 return w.leaveFloat()
587588 }
@@ -787,10 +788,23 @@ func (w *Workspace) ToggleFullscreen() {
787788 return
788789 }
789790 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.
790794 w.fullscreen = 0
791- } else {
792- w.fullscreen = id
795+ w.FocusID(id)
796+ w.endFullscreen()
797+ return
793798 }
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()
794808 w.scroll()
795809 }
796810
@@ -801,11 +815,12 @@ func (w *Workspace) Activate(id WindowID) {
801815 // A hidden stashed window must be seen: show the stash with it,
802816 // selected. Another window's fullscreen leaves first, so its own
803817 // pending fullscreen comes back.
804- w.fullscreen, w.stashAt = 0, i
818+ w.endFullscreen()
819+ w.stashAt = i
805820 w.showStash()
806821 }
807822 if w.fullscreen != 0 && w.fullscreen != id {
808- w.fullscreen = 0
823+ w.endFullscreen()
809824 }
810825 w.FocusID(id)
811826 }
@@ -813,6 +828,9 @@ func (w *Workspace) Activate(id WindowID) {
813828 // SetFullscreen applies a client request. It never moves focus: client
814829 // requests are automatic events (ADR 011 golden rule).
815830 func (w *Workspace) SetFullscreen(id WindowID, on bool) {
831+ if on && !w.mayCover(id) {
832+ return
833+ }
816834 if w.isFloat(id) {
817835 if w.stashHidden && w.stashIndex(id) >= 0 {
818836 // Applied when the stash is shown again.
@@ -823,26 +841,19 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) {
823841 }
824842 return
825843 }
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) {
832846 return
833847 }
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()
845851 }
852+ return
853+ }
854+ w.fullscreen = id
855+ if !w.isFloat(id) {
856+ w.scroll()
846857 }
847858 }
848859
@@ -916,15 +927,44 @@ func (w *Workspace) overviewArea() Rect { return w.reserved }
916927
917928 func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }
918929
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.
923946 func (w *Workspace) pinned() bool {
924947 c := w.cover()
925948 return c != 0 && (w.Overflow == OverflowFixed || w.isFloat(c))
926949 }
927950
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+
928968 // focusCover points the focus state at the covering fullscreen window,
929969 // which Focused already reports, so a user action acts on the window on
930970 // screen, not on one it hides.
@@ -183,19 +183,11 @@ type Workspace struct {
183 homePos int183 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.Time185 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 stacked188+// 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 int191 col, row, slot int
200 // stacked holds the windows left in the column: the window returns to192 // 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 bool196 expanded bool
205 // fullWidth is the column's maximize-column state.197 // fullWidth is the column's maximize-column state.
206 fullWidth bool198 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 false513 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- return521+ 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 column574 // 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 covering575 // 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 false580+ 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 return788 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 = 0794 w.fullscreen = 0
791- } else {795+ w.FocusID(id)
792- w.fullscreen = id796+ 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 own816 // 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, i818+ 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 = 0823+ 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: client828 // 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 return842 return
825 }843 }
826- if on {844+ }
827- w.fullscreen = id845+ if !w.has(id) {
828- } else if w.fullscreen == id {
829- w.fullscreen = 0
830- w.reconcileFloats()
831- }
832 return846 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: the930+// mayCover reports whether a client may make id fullscreen. In fixed
920-// covering fullscreen window hides every target. Only in scroll overflow931+// 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 on969 // 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 @@
11 package core
22
3-import "slices"
3+import (
4+ "iter"
5+ "slices"
6+)
47
58 // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers
69 // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits
@@ -71,9 +74,21 @@ func (m *Monitor) matches(home string) bool {
7174 return home != "" && (home == m.Key || home == m.Name)
7275 }
7376
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+ }
7792 }
7893
7994 // isHidden reports whether w is in the hidden list.
@@ -141,7 +156,7 @@ func (m *Monitor) Current() *Workspace {
141156 func (m *Monitor) newWorkspace() *Workspace {
142157 w := m.template
143158 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, ""
145160 w.overviewAfter = nil
146161 w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0
147162 (*m.nextID)++
@@ -153,7 +168,6 @@ func (m *Monitor) newWorkspace() *Workspace {
153168 // unnamed ones that are neither the first nor active. The active workspace
154169 // never changes.
155170 func (m *Monitor) normalize() {
156- m.fullscreenHome()
157171 spare := func(i int) bool { return m.Workspaces[i].empty() && m.Workspaces[i].Name == "" }
158172 for i := len(m.Workspaces) - 2; i >= 1; i-- {
159173 if spare(i) && i != m.Active {
@@ -229,11 +243,9 @@ func (m *Monitor) ToggleNamed(name string) {
229243
230244 // byName returns the configured workspace with that name, or nil.
231245 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
237249 }
238250 }
239251 return nil
@@ -273,26 +285,7 @@ func (m *Monitor) AddWindow(id WindowID) {
273285 if w, _ := m.find(id); w != nil {
274286 return
275287 }
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)
296289 m.normalize()
297290 }
298291
@@ -306,270 +299,25 @@ func (m *Monitor) AddFloating(id WindowID, width, height int) {
306299 }
307300
308301 // 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.
310303 func (m *Monitor) RemoveWindow(id WindowID) {
311304 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)
321306 m.normalize()
322307 }
323308 }
324309
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.
329312 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 {
346314 w.SetFullscreen(id, on)
347315 }
348- m.normalize()
349316 }
350317
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() }
573321
574322 // MoveToWorkspace moves the focused column (or only the focused window) to
575323 // numbered workspace index i. A floating window always moves alone. Focus
@@ -581,35 +329,19 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) {
581329 if !ok || to == cur {
582330 return
583331 }
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- }
594332 if f := cur.floatIndex(id); f >= 0 {
595333 fl := cur.Floats[f]
596334 cur.RemoveWindow(id)
597335 to.AddFloating(id, fl.W, fl.H)
598336 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)
605339 } else if f := cur.stashIndex(id); f >= 0 {
606340 // It joins the end of the stash there, shown and selected.
607341 fl := cur.Stash[f].rehome()
608342 cur.RemoveWindow(id)
609343 to.addStash(fl)
610- if to.origin == nil {
611- to.Activate(id)
612- }
344+ to.Activate(id)
613345 } else {
614346 col := Column{Windows: []WindowID{id}}
615347 ok := true
@@ -631,17 +363,11 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) {
631363 }
632364
633365 // 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.
637368 func (w *Workspace) receive(col Column, at int) {
638369 id := col.Windows[col.Focus]
639370 switch {
640- case w.origin != nil:
641- for _, v := range col.Windows {
642- w.joinFullscreen(v)
643- }
644- return
645371 case at < 0:
646372 w.addColumn(col)
647373 default:
@@ -650,23 +376,6 @@ func (w *Workspace) receive(col Column, at int) {
650376 w.Activate(id)
651377 }
652378
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-
670379 // Focused is the focused window of the workspace on screen; in the
671380 // overview, the selected stash card when it is one.
672381 func (m *Monitor) Focused() (WindowID, bool) {
@@ -683,7 +392,7 @@ func (m *Monitor) Layout() []Placement {
683392 }
684393 var result []Placement
685394 cur := m.Current()
686- for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) {
395+ for w := range m.all() {
687396 if w == cur {
688397 result = append(result, w.Layout()...)
689398 continue
@@ -774,15 +483,9 @@ func (m *Monitor) applyNamed() {
774483 if s.MaxColumns > 0 {
775484 w.SetMaxColumns(s.MaxColumns)
776485 }
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 != "" {
782488 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])
786489 }
787490 })
788491 }
@@ -795,15 +498,6 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) {
795498 for _, s := range specs {
796499 want[s.Name] = s
797500 }
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- }
807501 byName := map[string]*Workspace{}
808502 for _, w := range m.Workspaces {
809503 if _, ok := want[w.Name]; w.Name != "" && !ok {
@@ -1,6 +1,9 @@
1 package core1 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). Numbers8 // 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 sits9 // 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.template157 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 = nil160 w.overviewAfter = nil
146 w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0161 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 workspace168 // 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 nil251 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 return286 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 active301 // 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 window310+// SetFullscreen applies a client request, in place. A client request never
326-// gets its own workspace, as with the bind. A client request never moves311+// 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 window318+// ToggleFullscreen is the bind: the focused window goes fullscreen in place,
352-// moves to a new workspace below the current one, so workspace up/down319+// 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) to322 // MoveToWorkspace moves the focused column (or only the focused window) to
575 // numbered workspace index i. A floating window always moves alone. Focus323 // 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 return330 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] = fl336 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 := true347 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: at365 // 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 fullscreen366+// 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 the379 // 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 []Placement393 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 continue398 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 sibling486+ // Named workspaces take their configured size; any other keeps its own.
778- // that of its named origin. Any other workspace keeps its own: a487+ 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] = s499 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
22
33 import (
44 "reflect"
5- "slices"
65 "testing"
76
87 "github.com/bnema/neferwl/internal/ports"
@@ -123,20 +122,6 @@ func TestMonitorLayoutHidesOtherWorkspaces(t *testing.T) {
123122 }
124123 }
125124
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-
140125 func TestMonitorActions(t *testing.T) {
141126 m := monitor()
142127 m.AddWindow(1)
@@ -291,19 +276,6 @@ func TestWorkspaceArg(t *testing.T) {
291276 }
292277 }
293278
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-
307279 func TestLonePresetColumnKeepsWidth(t *testing.T) {
308280 m := monitor()
309281 m.SetPresets([]Width{{Num: 1, Den: 2}})
@@ -411,386 +383,6 @@ func TestNamedWorkspaceSettings(t *testing.T) {
411383 }
412384 }
413385
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-
794386 // Cmd+F on a window that made itself fullscreen returns it to its column
795387 // (Wine opens monitor-sized apps fullscreen); the next press maximizes.
796388 func TestMaximizeLeavesClientFullscreen(t *testing.T) {
@@ -820,49 +412,82 @@ func TestMaximizeLeavesClientFullscreen(t *testing.T) {
820412 }
821413 }
822414
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++
851425 }
426+ m.byName("s")
427+ }); a != 0 {
428+ t.Fatalf("%v allocs", a)
852429 }
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()
857437 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+ }
860452 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+ })
867492 }
868493 }
@@ -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 column386 // 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 workspace415+// Ranging over every workspace allocates nothing: normalize and the
824-// sends its window home first: the moved window must be seen, tiled or416+// 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 moved430+}
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 {
461461 cur := m.Current()
462462 rows, _ := m.overviewRows()
463463 var result []Placement
464- for _, w := range m.all() {
464+ for w := range m.all() {
465465 ry, shown := rows[w]
466466 if shown && w.pinned() {
467467 // 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 []Placement463 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) {
126126 }
127127 }
128128
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 {
133133 m := newMonitor("", "")
134134 m.SetOutput(300, 200)
135135 m.SetOverflow(o)
136136 m.AddWindow(1)
137- if inPlace {
138- m.Focus(1)
139- }
137+ m.Focus(1)
140138 m.AddFloating(2, 300, 200)
141139 m.SetFullscreen(2, true)
142140 m.Focus(0)
143141 return m
144142 }
145143
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.
148146 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)
160150 m.ToggleOverview()
161151 ps := m.Layout()
162152 cur, game := previewOf(t, ps, 1), previewOf(t, ps, 2)
@@ -180,7 +170,7 @@ func TestOverviewFullscreenFloat(t *testing.T) {
180170 // A click picks the game; Escape from its row returns where the overview
181171 // opened.
182172 func TestOverviewFullscreenFloatPickAndCancel(t *testing.T) {
183- m := gameMonitor(OverflowFixed, false)
173+ m := gameMonitor(OverflowFixed)
184174 m.ToggleOverview()
185175 r := previewOf(t, m.Layout(), 2).Rect
186176 if id := m.overviewAt(float64(r.X+1), float64(r.Y+1)); id != 2 {
@@ -201,7 +191,7 @@ func TestOverviewFullscreenFloatPickAndCancel(t *testing.T) {
201191 // The overview opens over the game too: the game is the selected row,
202192 // the workspace above shows dimmed, and Escape returns to the game.
203193 func TestOverviewOpensOverFullscreenFloat(t *testing.T) {
204- m := gameMonitor(OverflowFixed, false)
194+ m := gameMonitor(OverflowFixed)
205195 m.Focus(1)
206196 m.ToggleOverview()
207197 if !m.ov.open {
@@ -218,16 +208,16 @@ func TestOverviewOpensOverFullscreenFloat(t *testing.T) {
218208 }
219209 }
220210
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.
224214 func TestOverviewOpensOverFullscreenTile(t *testing.T) {
225215 m := newMonitor("", "")
226216 m.SetOutput(300, 200)
227217 m.SetOverflow(OverflowFixed)
228218 m.AddWindow(1)
229219 m.AddWindow(2)
230- m.ToggleFullscreen() // 2 to its own workspace
220+ m.ToggleFullscreen()
231221 m.ToggleOverview()
232222 game := previewOf(t, m.Layout(), 2)
233223 if !m.ov.open || !game.Focused || game.Peek || !game.Fullscreen {
@@ -235,7 +225,7 @@ func TestOverviewOpensOverFullscreenTile(t *testing.T) {
235225 }
236226 m.OverviewMove(-1, 0)
237227 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 {
239229 t.Fatalf("confirm: %d on %d", f, m.Active)
240230 }
241231 }
@@ -261,10 +251,9 @@ func TestOverviewOverScrollFullscreenPicksColumn(t *testing.T) {
261251 }
262252
263253 // 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.
266255 func TestOverviewFullscreenFloatHidesOthers(t *testing.T) {
267- m := gameMonitor(OverflowScroll, true)
256+ m := gameMonitor(OverflowScroll)
268257 m.Focus(1)
269258 m.AddWindow(5)
270259 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 a129+// 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, or130+// 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 m141 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 string148+ t.Run(string(o), func(t *testing.T) {
151- o Overflow149+ 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 overview170 // 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).Rect175 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 its211+// 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 hidden212+// 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 workspace220+ 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 it253 // 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 sends254+// 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 5259 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 {
140140 // anyWindow reports whether any workspace holds a window.
141141 func (c *Core) anyWindow() bool {
142142 for _, s := range c.screens {
143- for _, w := range s.mon.all() {
143+ for w := range s.mon.all() {
144144 if !w.empty() {
145145 return true
146146 }
@@ -276,7 +276,7 @@ func (c *Core) settleGuests() {
276276 if host == home {
277277 continue
278278 }
279- for _, w := range host.mon.all() {
279+ for w := range host.mon.all() {
280280 if home.mon.matches(w.home) && host.mon.Current() != w {
281281 list = append(list, guest{w: w, anchor: w.overviewAfter, host: host.mon, hidden: host.mon.isHidden(w)})
282282 }
@@ -345,9 +345,9 @@ func (c *Core) named() {
345345
346346 // bringNamed prepares a "workspace <name>" toggle from the focused screen.
347347 // 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.
351351 func (c *Core) bringNamed(name string) bool {
352352 src, w := c.byName(name)
353353 i := slices.IndexFunc(c.specs, func(s NamedWorkspace) bool { return s.Name == name })
@@ -371,32 +371,14 @@ func (c *Core) bringNamed(name string) bool {
371371 return false
372372 }
373373
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.
376377 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- }
390378 hidden := src.mon.isHidden(w)
391379 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.
394380 w.home = home
395381 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- }
400382 }
401383
402384 // 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 true145 return true
146 }146 }
@@ -276,7 +276,7 @@ func (c *Core) settleGuests() {
276 if host == home {276 if host == home {
277 continue277 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 focused347 // 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 screen348+// 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 screen349+// 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 false371 return false
372 }372 }
373 373
374-// moveNamed moves the named workspace w and its fullscreen siblings from src374+// 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 = home380 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) {
807807 }
808808 }
809809
810-func TestExpelOntoFixedFullscreenFloats(t *testing.T) {
810+func TestExpelOntoFixedFullscreenLeavesIt(t *testing.T) {
811811 r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
812812 // DP-1: [1] [2 3], focus on 3.
813813 r.mapWindow(t, 1)
814814 r.mapWindow(t, 2)
815815 r.mapWindow(t, 3)
816816 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.
818818 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
819819 r.mapWindow(t, 4)
820820 r.mapWindow(t, 5)
821821 r.key(t, "f", ports.ModAlt|ports.ModShift)
822822 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.
824824 set := r.key(t, "bracketright", ports.ModAlt)
825825 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 {
827827 t.Fatal(got, focused)
828828 }
829829 }
@@ -1146,3 +1146,32 @@ func TestNamedOnScreenAtHomeStaysOnBindFromElsewhere(t *testing.T) {
11461146 t.Fatal(got)
11471147 }
11481148 }
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 {
3434 c.swipe = nil
3535 for _, sc := range c.screens {
3636 sc.mon.stopSwitch()
37- for _, w := range sc.mon.all() {
37+ for w := range sc.mon.all() {
3838 w.stopSlide()
3939 }
4040 }
@@ -69,6 +69,18 @@ func (c *Core) securityCheckpoint(ctx context.Context) error {
6969 return c.publish(ctx)
7070 }
7171
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+
7284 func blockedProtectedEvent(ev ports.ClientEvent) bool {
7385 switch ev.(type) {
7486 case ports.WindowActivate, ports.WorkspaceActivate, ports.PointerWarp, ports.PointerConstrained,
@@ -34,7 +34,7 @@ func (c *Core) syncSecurity() bool {
34 c.swipe = nil34 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) {
9797 t.Fatal("stale input admitted")
9898 }
9999 }
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() {
119119 if st.window == 0 {
120120 continue
121121 }
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) {
125124 st.window = 0
126125 }
127126 }
@@ -119,9 +119,8 @@ func (c *Core) releaseSlots() {
119 if st.window == 0 {119 if st.window == 0 {
120 continue120 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 = 0124 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 {
142142 func TestWorkspaceActivateRetriesPendingSlot(t *testing.T) {
143143 r := startSlots(t)
144144 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.
145147 initial := receive(t, r.workspaces)
148+ for len(initial.Outputs) == 0 {
149+ initial = receive(t, r.workspaces)
150+ }
146151 var dev uint64
147152 for _, w := range initial.Outputs[0].Workspaces {
148153 if w.Configured == "dev" {
@@ -386,9 +391,9 @@ func TestStartupCommands(t *testing.T) {
386391 }
387392 }
388393
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) {
392397 r := startSlots(t, true, true)
393398 r.fill(t)
394399 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 uint64151 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 its394+// 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) {
8383 if target.terminal != nil {
8484 delete(p.pending, v.Slot)
8585 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()
8889 return
8990 }
9091 }
@@ -92,7 +93,8 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
9293 if sc, w := c.byName(target.slot.workspace); w != nil {
9394 p.dropSlot(target.slot)
9495 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()
9698 return
9799 }
98100 }
@@ -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 return89 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.ID95 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 return98 return
97 }99 }
98 }100 }
M internal/core/stash.go +6 -1
@@ -79,6 +79,11 @@ func (w *Workspace) unstash(i int) {
7979 w.restore(f.ID, f.back)
8080 }
8181
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+
8287 // restore tiles id back where back says: its row in the column that still
8388 // holds its former neighbors, else a column of its own at its former
8489 // index. It takes the focus.
@@ -105,7 +110,7 @@ func (w *Workspace) restore(id WindowID, back *origPlace) {
105110 return
106111 }
107112 // Its column is gone: it comes back expanded, unless another column
108- // was expanded meanwhile (as leaveFullscreen).
113+ // was expanded meanwhile.
109114 expanded := back.expanded && !slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Expanded })
110115 w.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded})
111116 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 still87 // 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 former88 // 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 return110 return
106 }111 }
107 // Its column is gone: it comes back expanded, unless another column112 // 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
33 import (
44 "slices"
55 "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
68 )
79
810 // stashIDs lists the stash of w, left to right.
@@ -73,33 +75,26 @@ func TestStashFollowsWorkspace(t *testing.T) {
7375 }
7476 }
7577
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.
7881 func TestStashFullscreenRoundTrip(t *testing.T) {
7982 m := stashMonitor(OverflowFixed)
8083 w := m.Current()
8184 w.FocusID(2)
8285 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))
8588 }
8689 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 {
9191 t.Fatalf("focused %d at %d", id, w.stashAt)
9292 }
93- // Left by a client request while the user is elsewhere: the selection
94- // stays on the same window.
95- w.FocusID(2)
9693 m.ToggleFullscreen()
97- fs := m.Current()
98- m.Focus(indexOf(m.Workspaces, w))
99- w.FocusID(3)
94+ m.Focus(1)
10095 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)
10398 }
10499 }
105100
@@ -114,9 +109,8 @@ func TestStashAloneHasFocus(t *testing.T) {
114109 if id, _ := w.Focused(); id != 3 {
115110 t.Fatalf("focused %d, want the selected stashed window", id)
116111 }
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.
120114 m = newMonitor("", "")
121115 m.SetOutput(100, 80)
122116 m.SetOverflow(OverflowFixed)
@@ -125,15 +119,13 @@ func TestStashAloneHasFocus(t *testing.T) {
125119 w = m.Current()
126120 w.ToggleWindowStash()
127121 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")
131124 }
132125 m.RemoveWindow(1)
133- m.Focus(indexOf(m.Workspaces, fs) + 1)
134126 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)
137129 }
138130 if id, ok := w.Focused(); !ok || id != 2 {
139131 t.Fatalf("focused %d %v", id, ok)
@@ -241,3 +233,63 @@ func TestStashWidth(t *testing.T) {
241233 t.Fatalf("clamped high %d", p.Rect.W)
242234 }
243235 }
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 stashed78+// 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 own112+ // Fixed overflow: the stashed window goes fullscreen, its only tile
118- // workspace, its only tile closes, then the client leaves fullscreen113+ // 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 {
4141 for _, p := range m.Layout() {
4242 shown[p.ID] = onScreen(p, m.Frame())
4343 }
44- for _, w := range m.all() {
44+ for w := range m.all() {
4545 n := indexOf(m.Workspaces, w) + 1
4646 for _, id := range w.windows() {
4747 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) + 145 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) {
6565 }
6666 }
6767
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-
11068 // The overview keeps monitor-wide placement and real client aspect ratios.
11169 func TestOverviewIgnoresWorkspaceSize(t *testing.T) {
11270 m := sizedMonitor(OverflowScroll)
@@ -162,44 +120,6 @@ func TestShownClientRectIsClippedToFrame(t *testing.T) {
162120 }
163121 }
164122
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-
203123 // Toggling a named workspace that is already on the focused screen, or on
204124 // its connected home, allocates nothing to find it.
205125 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 on123 // 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) {
9191 }
9292 }
9393
94-func TestWorkspaceSizeFixedFullscreenSiblingKeepsFrame(t *testing.T) {
94+func TestWorkspaceSizeFixedFullscreenKeepsFrame(t *testing.T) {
9595 r, _ := sizedRig(t, "fixed", nil)
9696 r.mapWindow(t, 1)
9797 r.mapWindow(t, 2)
9898 s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]
9999 if s.WorkspaceClip != frame {
100- t.Fatalf("sibling clip %+v", s.WorkspaceClip)
100+ t.Fatalf("fullscreen clip %+v", s.WorkspaceClip)
101101 }
102102 for _, w := range s.Windows {
103103 if w.Fullscreen && w.Rect != frame {
104104 t.Fatalf("%+v", w)
105105 }
106106 }
107- // Reload keeps the origin's size on the sibling; removing it frees both.
107+ // Reload keeps the size; removing it frees the workspace.
108108 cfg := r.cfg
109109 r.reload <- ports.ConfigChanged{Config: cfg}
110110 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.cfg108 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() {
2121 }
2222 m := sc.mon
2323 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++
2527 name := w.Name
2628 if name == "" {
2729 name = strconv.Itoa(i + 1)
@@ -21,7 +21,9 @@ func (c *Core) publishWorkspaces() {
21 }21 }
22 m := sc.mon22 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.Name27 name := w.Name
26 if name == "" {28 if name == "" {
27 name = strconv.Itoa(i + 1)29 name = strconv.Itoa(i + 1)

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close