detached 5385c18

Merge pull request 'fix(core): keep a fullscreen cover when a stashed window or dialog is activated' (#42) from fix/fullscreen-stash into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/42
commit
5385c186eebad717ad142003ec94de72aad93d99
author
bnema · · 2026-10-13 08:55 UTC
merge of
446d1e3 + 264a8c7 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
8 files · +356 -61
M docs/config.md +3 -3
@@ -258,10 +258,10 @@ In a row, the auto columns share the cells the set ones leave; when no cell is l
258258 | `spawn <command>` | Run a program |
259259 | `spawn-terminal` | Run `terminal` |
260260 | `close-window` | Close the focused window |
261-| `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 wait hidden until it leaves; a window opening meanwhile waits hidden too. The window's own dialogs are the exception: a dialog it opens (a file chooser, a screen-sharing or permission request from the desktop portal) shows over it with the focus, and a focus move goes back to the fullscreen window without leaving fullscreen. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down`, an activation or the overview) leaves fullscreen; the app's own fullscreen requests are then refused until it is focused again. 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 |
261+| `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 wait hidden until it leaves; a window opening meanwhile waits hidden too. The window's own dialogs are the exception: a dialog it opens (a file chooser, a screen-sharing or permission request from the desktop portal), and the dialogs that dialog opens, show over it with the focus, and a focus move goes back to the fullscreen window without leaving fullscreen. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down`, an activation or the overview) leaves fullscreen, and the app's own fullscreen requests are then refused until it is focused again. An activation of a window that can show over it keeps it fullscreen instead: one of its dialogs, or a stashed window or a dialog of one (and their dialogs), which shows the stash over it as `toggle-stash-visible` does, unless that stashed window asked for fullscreen while hidden: it then replaces the fullscreen window. 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 |
262262 | `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 state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first |
263263 | `toggle-floating` | Make the focused tile a [free floating window](#free-floating-windows) at its current size, centred; on a floating window, send it back to its former place in the columns (a native dialog becomes a new column). Does nothing on a stashed or fullscreen window |
264-| `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. Over a fullscreen window, shows the stash on top with the focus; the window stays fullscreen behind (composed, so no direct scanout while the stash shows) and is back when the stash hides or the focus leaves it. `toggle-window-stash` or `toggle-fullscreen` on a stashed window there leaves that fullscreen for it. New windows never join or show over it; the fullscreen window's own dialogs show above it |
264+| `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. Over a fullscreen window, shows the stash on top with the focus; the window stays fullscreen behind (composed, so no direct scanout while the stash shows) and is back when the stash hides or the focus leaves it. `toggle-window-stash` or `toggle-fullscreen` on a stashed window there leaves that fullscreen for it. An activation of a stashed window (an app asking for attention, a taskbar click) shows the stash over the fullscreen window the same way. New windows never join or show over it; dialogs of the fullscreen window or of stashed windows, and their own dialogs, show above it |
265265 | `toggle-overview` | Open the [overview](#overview), or close it on the selected window |
266266 | `switch-column-next` | Switch to the column used before this one on the workspace. Hold Cmd and press again to go further back; after a short hold a strip of cards shows the columns, most recent first, and releasing Cmd focuses the selected one. Escape cancels; clicking a card picks it, and a click elsewhere cancels and is not passed to windows. A click before the cards show cancels and acts as usual. A maximized column hands its maximization to the target. A workspace capture session pauses while the strip shows, as in the overview |
267267 | `switch-column-prev` | The same, in the opposite direction |
@@ -290,7 +290,7 @@ Each workspace has a stash: a horizontal strip of windows set aside with `toggle
290290
291291 While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it, sliding the view, and stop at its ends; `focus-window-up/down` do nothing. Hide it with `toggle-stash-visible`, or with a four-finger swipe down or up, to get back to the tiles; a swipe down shows it again. Scripts see each stashed window's place, and whether the stash is hidden, in the [state file](desktop.md#state-for-scripts).
292292
293-Native floating windows are not in the stash: they stay centred in the usable output area. A window-sized float that fills that area (within two border widths plus two logical pixels per axis) stays below the columns when you focus a tile; `focus-window-up` at the top of a column raises it again. It does not hide bars or pin focus. Small dialogs and file pickers always stay above the columns; shrinking a float promotes it above them. Real fullscreen remains exclusive, except for the fullscreen window's own dialogs. A dialog belongs to a window through `xdg_toplevel.set_parent`, or through `zxdg_importer_v2` when another program opens it, as the desktop portal does. A dialog the app marks modal through `xdg_dialog_v1` (GTK4 does for "Save as…") blocks its window: clicking it, activating it, picking it in the overview or moving the focus onto it within its workspace focuses the dialog instead, and so do nested modal dialogs. A focus move away from the dialog reaches its window once, so the next move can go past it.
293+Native floating windows are not in the stash: they stay centred in the usable output area. A window-sized float that fills that area (within two border widths plus two logical pixels per axis) stays below the columns when you focus a tile; `focus-window-up` at the top of a column raises it again. It does not hide bars or pin focus. Small dialogs and file pickers always stay above the columns; shrinking a float promotes it above them. Real fullscreen remains exclusive, except for the dialogs shown over it: the fullscreen window's own, those of stashed windows while the stash shows over it, and their own dialogs. A dialog belongs to a window through `xdg_toplevel.set_parent`, or through `zxdg_importer_v2` when another program opens it, as the desktop portal does. A dialog the app marks modal through `xdg_dialog_v1` (GTK4 does for "Save as…") blocks its window: clicking it, activating it, picking it in the overview or moving the focus onto it within its workspace focuses the dialog instead, and so do nested modal dialogs. A focus move away from the dialog reaches its window once, so the next move can go past it.
294294
295295 ### Free floating windows
296296
@@ -258,10 +258,10 @@ In a row, the auto columns share the cells the set ones leave; when no cell is l
258 | `spawn <command>` | Run a program |258 | `spawn <command>` | Run a program |
259 | `spawn-terminal` | Run `terminal` |259 | `spawn-terminal` | Run `terminal` |
260 | `close-window` | Close the focused window |260 | `close-window` | Close the focused window |
261-| `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 wait hidden until it leaves; a window opening meanwhile waits hidden too. The window's own dialogs are the exception: a dialog it opens (a file chooser, a screen-sharing or permission request from the desktop portal) shows over it with the focus, and a focus move goes back to the fullscreen window without leaving fullscreen. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down`, an activation or the overview) leaves fullscreen; the app's own fullscreen requests are then refused until it is focused again. 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 |261+| `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 wait hidden until it leaves; a window opening meanwhile waits hidden too. The window's own dialogs are the exception: a dialog it opens (a file chooser, a screen-sharing or permission request from the desktop portal), and the dialogs that dialog opens, show over it with the focus, and a focus move goes back to the fullscreen window without leaving fullscreen. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down`, an activation or the overview) leaves fullscreen, and the app's own fullscreen requests are then refused until it is focused again. An activation of a window that can show over it keeps it fullscreen instead: one of its dialogs, or a stashed window or a dialog of one (and their dialogs), which shows the stash over it as `toggle-stash-visible` does, unless that stashed window asked for fullscreen while hidden: it then replaces the fullscreen window. 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 |
262 | `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 state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first |262 | `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 state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first |
263 | `toggle-floating` | Make the focused tile a [free floating window](#free-floating-windows) at its current size, centred; on a floating window, send it back to its former place in the columns (a native dialog becomes a new column). Does nothing on a stashed or fullscreen window |263 | `toggle-floating` | Make the focused tile a [free floating window](#free-floating-windows) at its current size, centred; on a floating window, send it back to its former place in the columns (a native dialog becomes a new column). Does nothing on a stashed or fullscreen window |
264-| `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. Over a fullscreen window, shows the stash on top with the focus; the window stays fullscreen behind (composed, so no direct scanout while the stash shows) and is back when the stash hides or the focus leaves it. `toggle-window-stash` or `toggle-fullscreen` on a stashed window there leaves that fullscreen for it. New windows never join or show over it; the fullscreen window's own dialogs show above it |264+| `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. Over a fullscreen window, shows the stash on top with the focus; the window stays fullscreen behind (composed, so no direct scanout while the stash shows) and is back when the stash hides or the focus leaves it. `toggle-window-stash` or `toggle-fullscreen` on a stashed window there leaves that fullscreen for it. An activation of a stashed window (an app asking for attention, a taskbar click) shows the stash over the fullscreen window the same way. New windows never join or show over it; dialogs of the fullscreen window or of stashed windows, and their own dialogs, show above it |
265 | `toggle-overview` | Open the [overview](#overview), or close it on the selected window |265 | `toggle-overview` | Open the [overview](#overview), or close it on the selected window |
266 | `switch-column-next` | Switch to the column used before this one on the workspace. Hold Cmd and press again to go further back; after a short hold a strip of cards shows the columns, most recent first, and releasing Cmd focuses the selected one. Escape cancels; clicking a card picks it, and a click elsewhere cancels and is not passed to windows. A click before the cards show cancels and acts as usual. A maximized column hands its maximization to the target. A workspace capture session pauses while the strip shows, as in the overview |266 | `switch-column-next` | Switch to the column used before this one on the workspace. Hold Cmd and press again to go further back; after a short hold a strip of cards shows the columns, most recent first, and releasing Cmd focuses the selected one. Escape cancels; clicking a card picks it, and a click elsewhere cancels and is not passed to windows. A click before the cards show cancels and acts as usual. A maximized column hands its maximization to the target. A workspace capture session pauses while the strip shows, as in the overview |
267 | `switch-column-prev` | The same, in the opposite direction |267 | `switch-column-prev` | The same, in the opposite direction |
@@ -290,7 +290,7 @@ Each workspace has a stash: a horizontal strip of windows set aside with `toggle
290 290
291 While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it, sliding the view, and stop at its ends; `focus-window-up/down` do nothing. Hide it with `toggle-stash-visible`, or with a four-finger swipe down or up, to get back to the tiles; a swipe down shows it again. Scripts see each stashed window's place, and whether the stash is hidden, in the [state file](desktop.md#state-for-scripts).291 While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it, sliding the view, and stop at its ends; `focus-window-up/down` do nothing. Hide it with `toggle-stash-visible`, or with a four-finger swipe down or up, to get back to the tiles; a swipe down shows it again. Scripts see each stashed window's place, and whether the stash is hidden, in the [state file](desktop.md#state-for-scripts).
292 292
293-Native floating windows are not in the stash: they stay centred in the usable output area. A window-sized float that fills that area (within two border widths plus two logical pixels per axis) stays below the columns when you focus a tile; `focus-window-up` at the top of a column raises it again. It does not hide bars or pin focus. Small dialogs and file pickers always stay above the columns; shrinking a float promotes it above them. Real fullscreen remains exclusive, except for the fullscreen window's own dialogs. A dialog belongs to a window through `xdg_toplevel.set_parent`, or through `zxdg_importer_v2` when another program opens it, as the desktop portal does. A dialog the app marks modal through `xdg_dialog_v1` (GTK4 does for "Save as…") blocks its window: clicking it, activating it, picking it in the overview or moving the focus onto it within its workspace focuses the dialog instead, and so do nested modal dialogs. A focus move away from the dialog reaches its window once, so the next move can go past it.293+Native floating windows are not in the stash: they stay centred in the usable output area. A window-sized float that fills that area (within two border widths plus two logical pixels per axis) stays below the columns when you focus a tile; `focus-window-up` at the top of a column raises it again. It does not hide bars or pin focus. Small dialogs and file pickers always stay above the columns; shrinking a float promotes it above them. Real fullscreen remains exclusive, except for the dialogs shown over it: the fullscreen window's own, those of stashed windows while the stash shows over it, and their own dialogs. A dialog belongs to a window through `xdg_toplevel.set_parent`, or through `zxdg_importer_v2` when another program opens it, as the desktop portal does. A dialog the app marks modal through `xdg_dialog_v1` (GTK4 does for "Save as…") blocks its window: clicking it, activating it, picking it in the overview or moving the focus onto it within its workspace focuses the dialog instead, and so do nested modal dialogs. A focus move away from the dialog reaches its window once, so the next move can go past it.
294 294
295 ### Free floating windows295 ### Free floating windows
296 296
M internal/adapters/wayland/foreigntoplevel.go +1 -0
@@ -267,6 +267,7 @@ func (*toplevelRequests) Destroy(*wlr.ZwlrForeignToplevelHandleV1) {}
267267
268268 func (t *toplevelRequests) Activate(*wlr.ZwlrForeignToplevelHandleV1, *wayland.Seat) {
269269 if w := t.window(); w != nil {
270+ t.s.log.Info().Uint64("id", uint64(w.id)).Msg("foreign toplevel activation")
270271 t.s.emit(ports.WindowActivate{ID: w.id})
271272 }
272273 }
@@ -267,6 +267,7 @@ func (*toplevelRequests) Destroy(*wlr.ZwlrForeignToplevelHandleV1) {}
267 267
268 func (t *toplevelRequests) Activate(*wlr.ZwlrForeignToplevelHandleV1, *wayland.Seat) {268 func (t *toplevelRequests) Activate(*wlr.ZwlrForeignToplevelHandleV1, *wayland.Seat) {
269 if w := t.window(); w != nil {269 if w := t.window(); w != nil {
270+ t.s.log.Info().Uint64("id", uint64(w.id)).Msg("foreign toplevel activation")
270 t.s.emit(ports.WindowActivate{ID: w.id})271 t.s.emit(ports.WindowActivate{ID: w.id})
271 }272 }
272 }273 }
M internal/core/floating_test.go +2 -10
@@ -490,8 +490,8 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) {
490490 check(0.3)
491491 }
492492
493-// Activating a hidden stashed window shows the stash and focuses it; the
494-// fullscreen window it hid behind leaves fullscreen.
493+// A stashed float's own fullscreen, asked while the stash was hidden,
494+// comes back with it when it is activated: it replaces the cover.
495495 func TestActivateHiddenFloat(t *testing.T) {
496496 w := workspace()
497497 w.AddWindow(1)
@@ -499,14 +499,6 @@ func TestActivateHiddenFloat(t *testing.T) {
499499 w.ToggleWindowStash()
500500 w.ToggleStashVisible()
501501 w.FocusID(1)
502- w.SetFullscreen(1, true)
503- w.Activate(2)
504- if id, _ := w.Focused(); id != 2 || w.stashHidden || w.fullscreen != 0 {
505- t.Fatalf("focused %d hidden %v fullscreen %d", id, w.stashHidden, w.fullscreen)
506- }
507- // The float's own fullscreen, asked while hidden, comes back with it.
508- w.FocusID(2)
509- w.ToggleStashVisible()
510502 w.SetFullscreen(2, true)
511503 w.SetFullscreen(1, true)
512504 w.Activate(2)
@@ -490,8 +490,8 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) {
490 check(0.3)490 check(0.3)
491 }491 }
492 492
493-// Activating a hidden stashed window shows the stash and focuses it; the493+// A stashed float's own fullscreen, asked while the stash was hidden,
494-// fullscreen window it hid behind leaves fullscreen.494+// comes back with it when it is activated: it replaces the cover.
495 func TestActivateHiddenFloat(t *testing.T) {495 func TestActivateHiddenFloat(t *testing.T) {
496 w := workspace()496 w := workspace()
497 w.AddWindow(1)497 w.AddWindow(1)
@@ -499,14 +499,6 @@ func TestActivateHiddenFloat(t *testing.T) {
499 w.ToggleWindowStash()499 w.ToggleWindowStash()
500 w.ToggleStashVisible()500 w.ToggleStashVisible()
501 w.FocusID(1)501 w.FocusID(1)
502- w.SetFullscreen(1, true)
503- w.Activate(2)
504- if id, _ := w.Focused(); id != 2 || w.stashHidden || w.fullscreen != 0 {
505- t.Fatalf("focused %d hidden %v fullscreen %d", id, w.stashHidden, w.fullscreen)
506- }
507- // The float's own fullscreen, asked while hidden, comes back with it.
508- w.FocusID(2)
509- w.ToggleStashVisible()
510 w.SetFullscreen(2, true)502 w.SetFullscreen(2, true)
511 w.SetFullscreen(1, true)503 w.SetFullscreen(1, true)
512 w.Activate(2)504 w.Activate(2)
M internal/core/fullscreen_left_test.go +0 -20
@@ -111,23 +111,3 @@ func TestLeftFullscreenRemoved(t *testing.T) {
111111 t.Fatalf("left %d", w.left)
112112 }
113113 }
114-
115-// Activating a hidden stashed window leaves another window's fullscreen
116-// with the latch too.
117-func TestActivateHiddenStashLatches(t *testing.T) {
118- m := newMonitor("", "")
119- m.SetOutput(300, 200)
120- m.SetOverflow(OverflowFixed)
121- w := m.Current()
122- w.AddWindow(1)
123- w.AddWindow(5)
124- w.FocusID(5)
125- w.ToggleWindowStash()
126- w.ToggleStashVisible()
127- w.FocusID(1)
128- m.ToggleFullscreen()
129- w.Activate(5)
130- if f, _ := w.Focused(); f != 5 || w.fullscreen != 0 || w.left != 1 {
131- t.Fatalf("focused %d, fullscreen %d, left %d", f, w.fullscreen, w.left)
132- }
133-}
@@ -111,23 +111,3 @@ func TestLeftFullscreenRemoved(t *testing.T) {
111 t.Fatalf("left %d", w.left)111 t.Fatalf("left %d", w.left)
112 }112 }
113 }113 }
114-
115-// Activating a hidden stashed window leaves another window's fullscreen
116-// with the latch too.
117-func TestActivateHiddenStashLatches(t *testing.T) {
118- m := newMonitor("", "")
119- m.SetOutput(300, 200)
120- m.SetOverflow(OverflowFixed)
121- w := m.Current()
122- w.AddWindow(1)
123- w.AddWindow(5)
124- w.FocusID(5)
125- w.ToggleWindowStash()
126- w.ToggleStashVisible()
127- w.FocusID(1)
128- m.ToggleFullscreen()
129- w.Activate(5)
130- if f, _ := w.Focused(); f != 5 || w.fullscreen != 0 || w.left != 1 {
131- t.Fatalf("focused %d, fullscreen %d, left %d", f, w.fullscreen, w.left)
132- }
133-}
M internal/core/layout.go +89 -19
@@ -146,7 +146,8 @@ type Workspace struct {
146146 // is hidden, restored when shown.
147147 hiddenFullscreen WindowID
148148 // stashOver is the covering fullscreen window the stash shows over, a
149- // user override (ToggleStashVisible); 0 without. Bound to that window,
149+ // user override (ToggleStashVisible) or a stashed window's activation
150+ // (Activate); 0 without. Bound to that window,
150151 // it ends with its cover. Focusing another window ends it too.
151152 stashOver WindowID
152153 // home is the monitor (key or connector) the workspace belongs to; ""
@@ -390,31 +391,59 @@ func (w *Workspace) focusModal(from WindowID) {
390391 return
391392 }
392393 if c := w.cover(); c != 0 && c != d {
393- if i := w.floatIndex(d); i >= 0 && !w.Floats[i].coverDialog(c) {
394+ if i := w.floatIndex(d); i >= 0 && !w.overCover(w.Floats[i]) {
394395 // Hidden under another window's fullscreen: leave it, as
395- // Activate does, so the dialog shows.
396+ // Activate does for a window not shown over the cover, so
397+ // the dialog shows.
396398 w.leaveFullscreen()
397399 }
398400 }
399401 w.FocusID(d)
400402 }
401403
402-// coverDialog reports whether f is a dialog of the covering fullscreen
403-// window cover: the one float drawn over it.
404-func (f Float) coverDialog(cover WindowID) bool {
405- return cover != 0 && f.over == cover && f.ID != cover
404+// overCover reports whether float f is drawn over the covering fullscreen
405+// window: a dialog of the cover, or of a stashed window while the stash
406+// shows over the cover (a "Save as…" of the app the user picked there),
407+// or a dialog of such a dialog (an "Overwrite?" over the "Save as…").
408+func (w *Workspace) overCover(f Float) bool {
409+ c := w.cover()
410+ if c == 0 || f.ID == c {
411+ return false
412+ }
413+ owner := w.dialogOwner(f, c)
414+ return owner == c || owner != 0 && w.stashOverCover() && w.stashIndex(owner) >= 0
415+}
416+
417+// dialogOwner is the window float f is a dialog of, through dialogs of
418+// dialogs: the first parent that is no float, or stop. 0 when f is no
419+// dialog, its chain ends on a plain float, or it loops.
420+func (w *Workspace) dialogOwner(f Float, stop WindowID) WindowID {
421+ // Each hop goes to another float: at most len(w.Floats) hops.
422+ for range len(w.Floats) {
423+ if f.over == 0 || f.over == f.ID {
424+ return 0
425+ }
426+ if f.over == stop {
427+ return stop
428+ }
429+ i := w.floatIndex(f.over)
430+ if i < 0 {
431+ return f.over
432+ }
433+ f = w.Floats[i]
434+ }
435+ return 0
406436 }
407437
408438 // coverDialog is the topmost dialog shown over the covering fullscreen
409-// window when the floats have the focus, else 0. The floats it hides cannot
410-// take the focus.
439+// window (overCover) when the floats have the focus, else 0. The floats it
440+// hides cannot take the focus.
411441 func (w *Workspace) coverDialog() WindowID {
412442 if !w.floatFocus {
413443 return 0
414444 }
415- cover := w.cover()
416445 for i := len(w.Floats) - 1; i >= 0; i-- {
417- if f := w.Floats[i]; !f.below && f.coverDialog(cover) {
446+ if f := w.Floats[i]; !f.below && w.overCover(f) {
418447 return f.ID
419448 }
420449 }
@@ -638,9 +667,9 @@ func (w *Workspace) FocusID(id WindowID) bool {
638667 f := w.Floats[i]
639668 f.below = false
640669 w.Floats = append(slices.Delete(w.Floats, i, i+1), f)
641- if !f.coverDialog(w.cover()) {
642- // A dialog of the cover shows over the stash; leaving it
643- // returns there.
670+ if !w.overCover(f) {
671+ // A dialog shown over the cover keeps the stash over it;
672+ // leaving it returns there.
644673 w.dropStashOver()
645674 }
646675 w.floatFocus = true
@@ -679,7 +708,8 @@ func (w *Workspace) FocusColumn(dir int) bool {
679708 from, _ := w.Focused()
680709 defer w.focusModal(from)
681710 if w.coverDialogFocused() {
682- // The first move goes back to the fullscreen window under it.
711+ // The first move goes back to the window under it: the stash
712+ // over the cover, or the cover.
683713 w.floatFocus = false
684714 return true
685715 }
@@ -1234,8 +1264,39 @@ func (w *Workspace) leaveFullscreen() {
12341264 }
12351265
12361266 // Activate focuses a window and makes it visible: it leaves another
1237-// window's fullscreen, which hides everything else.
1267+// window's fullscreen, which hides everything else. Over a covering
1268+// window, a dialog drawn over it, a stashed window or a dialog of one
1269+// shows over it instead, as ToggleStashVisible does: the cover stays
1270+// fullscreen behind, so a game never sees its fullscreen end. A stashed
1271+// window whose own fullscreen waited while hidden replaces the cover
1272+// (coverStays); a dialog of a stashed window activates that window
1273+// first.
12381274 func (w *Workspace) Activate(id WindowID) {
1275+ if c := w.cover(); c != 0 && id != c {
1276+ owner := id
1277+ if f := w.floatIndex(id); f >= 0 {
1278+ owner = w.dialogOwner(w.Floats[f], c)
1279+ }
1280+ if owner != id && owner != c && w.stashIndex(owner) >= 0 && !w.coverStays(owner) {
1281+ w.Activate(owner)
1282+ w.FocusID(id)
1283+ w.focusModal(0)
1284+ return
1285+ }
1286+ if i := w.stashIndex(owner); i >= 0 && w.coverStays(owner) {
1287+ if !w.stashOverCover() {
1288+ w.showStashOver()
1289+ }
1290+ w.stashAt, w.stashFocus, w.floatFocus = i, true, false
1291+ }
1292+ if owner == c || w.stashOverCover() && w.stashIndex(owner) >= 0 {
1293+ if owner != id {
1294+ w.FocusID(id)
1295+ }
1296+ w.focusModal(0)
1297+ return
1298+ }
1299+ }
12391300 if i := w.stashIndex(id); i >= 0 && w.stashHidden {
12401301 // A hidden stashed window must be seen: show the stash with it,
12411302 // selected. Another window's fullscreen leaves first, so its own
@@ -1251,6 +1312,14 @@ func (w *Workspace) Activate(id WindowID) {
12511312 w.focusModal(0)
12521313 }
12531314
1315+// coverStays reports whether activating stashed window id keeps the
1316+// covering window fullscreen under the stash: a cover outside the stash,
1317+// and no pending fullscreen of id's own that would replace it.
1318+func (w *Workspace) coverStays(id WindowID) bool {
1319+ c := w.cover()
1320+ return c != 0 && w.stashIndex(c) < 0 && !(w.stashHidden && w.hiddenFullscreen == id)
1321+}
1322+
12541323 // SetFullscreen applies a client request. It never moves focus: client
12551324 // requests are automatic events (ADR 011 golden rule).
12561325 func (w *Workspace) SetFullscreen(id WindowID, on bool) {
@@ -1770,9 +1839,10 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
17701839 if order != group {
17711840 continue
17721841 }
1773- // A covering fullscreen window hides every float but its own
1774- // dialogs: the others show again when it leaves fullscreen.
1775- p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover && !f.coverDialog(cover)}
1842+ // A covering fullscreen window hides every float but the
1843+ // dialogs over it (overCover): the others show again when it
1844+ // leaves fullscreen.
1845+ p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover && !w.overCover(f)}
17761846 if p.Hidden {
17771847 p.Rect = Rect{}
17781848 result = append(result, p)
@@ -146,7 +146,8 @@ type Workspace struct {
146 // is hidden, restored when shown.146 // is hidden, restored when shown.
147 hiddenFullscreen WindowID147 hiddenFullscreen WindowID
148 // stashOver is the covering fullscreen window the stash shows over, a148 // stashOver is the covering fullscreen window the stash shows over, a
149- // user override (ToggleStashVisible); 0 without. Bound to that window,149+ // user override (ToggleStashVisible) or a stashed window's activation
150+ // (Activate); 0 without. Bound to that window,
150 // it ends with its cover. Focusing another window ends it too.151 // it ends with its cover. Focusing another window ends it too.
151 stashOver WindowID152 stashOver WindowID
152 // home is the monitor (key or connector) the workspace belongs to; ""153 // home is the monitor (key or connector) the workspace belongs to; ""
@@ -390,31 +391,59 @@ func (w *Workspace) focusModal(from WindowID) {
390 return391 return
391 }392 }
392 if c := w.cover(); c != 0 && c != d {393 if c := w.cover(); c != 0 && c != d {
393- if i := w.floatIndex(d); i >= 0 && !w.Floats[i].coverDialog(c) {394+ if i := w.floatIndex(d); i >= 0 && !w.overCover(w.Floats[i]) {
394 // Hidden under another window's fullscreen: leave it, as395 // Hidden under another window's fullscreen: leave it, as
395- // Activate does, so the dialog shows.396+ // Activate does for a window not shown over the cover, so
397+ // the dialog shows.
396 w.leaveFullscreen()398 w.leaveFullscreen()
397 }399 }
398 }400 }
399 w.FocusID(d)401 w.FocusID(d)
400 }402 }
401 403
402-// coverDialog reports whether f is a dialog of the covering fullscreen404+// overCover reports whether float f is drawn over the covering fullscreen
403-// window cover: the one float drawn over it.405+// window: a dialog of the cover, or of a stashed window while the stash
404-func (f Float) coverDialog(cover WindowID) bool {406+// shows over the cover (a "Save as…" of the app the user picked there),
405- return cover != 0 && f.over == cover && f.ID != cover407+// or a dialog of such a dialog (an "Overwrite?" over the "Save as…").
408+func (w *Workspace) overCover(f Float) bool {
409+ c := w.cover()
410+ if c == 0 || f.ID == c {
411+ return false
412+ }
413+ owner := w.dialogOwner(f, c)
414+ return owner == c || owner != 0 && w.stashOverCover() && w.stashIndex(owner) >= 0
415+}
416+
417+// dialogOwner is the window float f is a dialog of, through dialogs of
418+// dialogs: the first parent that is no float, or stop. 0 when f is no
419+// dialog, its chain ends on a plain float, or it loops.
420+func (w *Workspace) dialogOwner(f Float, stop WindowID) WindowID {
421+ // Each hop goes to another float: at most len(w.Floats) hops.
422+ for range len(w.Floats) {
423+ if f.over == 0 || f.over == f.ID {
424+ return 0
425+ }
426+ if f.over == stop {
427+ return stop
428+ }
429+ i := w.floatIndex(f.over)
430+ if i < 0 {
431+ return f.over
432+ }
433+ f = w.Floats[i]
434+ }
435+ return 0
406 }436 }
407 437
408 // coverDialog is the topmost dialog shown over the covering fullscreen438 // coverDialog is the topmost dialog shown over the covering fullscreen
409-// window when the floats have the focus, else 0. The floats it hides cannot439+// window (overCover) when the floats have the focus, else 0. The floats it
410-// take the focus.440+// hides cannot take the focus.
411 func (w *Workspace) coverDialog() WindowID {441 func (w *Workspace) coverDialog() WindowID {
412 if !w.floatFocus {442 if !w.floatFocus {
413 return 0443 return 0
414 }444 }
415- cover := w.cover()
416 for i := len(w.Floats) - 1; i >= 0; i-- {445 for i := len(w.Floats) - 1; i >= 0; i-- {
417- if f := w.Floats[i]; !f.below && f.coverDialog(cover) {446+ if f := w.Floats[i]; !f.below && w.overCover(f) {
418 return f.ID447 return f.ID
419 }448 }
420 }449 }
@@ -638,9 +667,9 @@ func (w *Workspace) FocusID(id WindowID) bool {
638 f := w.Floats[i]667 f := w.Floats[i]
639 f.below = false668 f.below = false
640 w.Floats = append(slices.Delete(w.Floats, i, i+1), f)669 w.Floats = append(slices.Delete(w.Floats, i, i+1), f)
641- if !f.coverDialog(w.cover()) {670+ if !w.overCover(f) {
642- // A dialog of the cover shows over the stash; leaving it671+ // A dialog shown over the cover keeps the stash over it;
643- // returns there.672+ // leaving it returns there.
644 w.dropStashOver()673 w.dropStashOver()
645 }674 }
646 w.floatFocus = true675 w.floatFocus = true
@@ -679,7 +708,8 @@ func (w *Workspace) FocusColumn(dir int) bool {
679 from, _ := w.Focused()708 from, _ := w.Focused()
680 defer w.focusModal(from)709 defer w.focusModal(from)
681 if w.coverDialogFocused() {710 if w.coverDialogFocused() {
682- // The first move goes back to the fullscreen window under it.711+ // The first move goes back to the window under it: the stash
712+ // over the cover, or the cover.
683 w.floatFocus = false713 w.floatFocus = false
684 return true714 return true
685 }715 }
@@ -1234,8 +1264,39 @@ func (w *Workspace) leaveFullscreen() {
1234 }1264 }
1235 1265
1236 // Activate focuses a window and makes it visible: it leaves another1266 // Activate focuses a window and makes it visible: it leaves another
1237-// window's fullscreen, which hides everything else.1267+// window's fullscreen, which hides everything else. Over a covering
1268+// window, a dialog drawn over it, a stashed window or a dialog of one
1269+// shows over it instead, as ToggleStashVisible does: the cover stays
1270+// fullscreen behind, so a game never sees its fullscreen end. A stashed
1271+// window whose own fullscreen waited while hidden replaces the cover
1272+// (coverStays); a dialog of a stashed window activates that window
1273+// first.
1238 func (w *Workspace) Activate(id WindowID) {1274 func (w *Workspace) Activate(id WindowID) {
1275+ if c := w.cover(); c != 0 && id != c {
1276+ owner := id
1277+ if f := w.floatIndex(id); f >= 0 {
1278+ owner = w.dialogOwner(w.Floats[f], c)
1279+ }
1280+ if owner != id && owner != c && w.stashIndex(owner) >= 0 && !w.coverStays(owner) {
1281+ w.Activate(owner)
1282+ w.FocusID(id)
1283+ w.focusModal(0)
1284+ return
1285+ }
1286+ if i := w.stashIndex(owner); i >= 0 && w.coverStays(owner) {
1287+ if !w.stashOverCover() {
1288+ w.showStashOver()
1289+ }
1290+ w.stashAt, w.stashFocus, w.floatFocus = i, true, false
1291+ }
1292+ if owner == c || w.stashOverCover() && w.stashIndex(owner) >= 0 {
1293+ if owner != id {
1294+ w.FocusID(id)
1295+ }
1296+ w.focusModal(0)
1297+ return
1298+ }
1299+ }
1239 if i := w.stashIndex(id); i >= 0 && w.stashHidden {1300 if i := w.stashIndex(id); i >= 0 && w.stashHidden {
1240 // A hidden stashed window must be seen: show the stash with it,1301 // A hidden stashed window must be seen: show the stash with it,
1241 // selected. Another window's fullscreen leaves first, so its own1302 // selected. Another window's fullscreen leaves first, so its own
@@ -1251,6 +1312,14 @@ func (w *Workspace) Activate(id WindowID) {
1251 w.focusModal(0)1312 w.focusModal(0)
1252 }1313 }
1253 1314
1315+// coverStays reports whether activating stashed window id keeps the
1316+// covering window fullscreen under the stash: a cover outside the stash,
1317+// and no pending fullscreen of id's own that would replace it.
1318+func (w *Workspace) coverStays(id WindowID) bool {
1319+ c := w.cover()
1320+ return c != 0 && w.stashIndex(c) < 0 && !(w.stashHidden && w.hiddenFullscreen == id)
1321+}
1322+
1254 // SetFullscreen applies a client request. It never moves focus: client1323 // SetFullscreen applies a client request. It never moves focus: client
1255 // requests are automatic events (ADR 011 golden rule).1324 // requests are automatic events (ADR 011 golden rule).
1256 func (w *Workspace) SetFullscreen(id WindowID, on bool) {1325 func (w *Workspace) SetFullscreen(id WindowID, on bool) {
@@ -1770,9 +1839,10 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
1770 if order != group {1839 if order != group {
1771 continue1840 continue
1772 }1841 }
1773- // A covering fullscreen window hides every float but its own1842+ // A covering fullscreen window hides every float but the
1774- // dialogs: the others show again when it leaves fullscreen.1843+ // dialogs over it (overCover): the others show again when it
1775- p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover && !f.coverDialog(cover)}1844+ // leaves fullscreen.
1845+ p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover && !w.overCover(f)}
1776 if p.Hidden {1846 if p.Hidden {
1777 p.Rect = Rect{}1847 p.Rect = Rect{}
1778 result = append(result, p)1848 result = append(result, p)
M internal/core/stash.go +9 -4
@@ -174,9 +174,7 @@ func (w *Workspace) ToggleStashVisible() {
174174 w.dropStashOver()
175175 return
176176 }
177- // Not showStash: a pending stashed fullscreen must not replace
178- // the covering window.
179- w.stashOver, w.stashHidden, w.stashFocus, w.floatFocus = c, false, true, false
177+ w.showStashOver()
180178 return
181179 }
182180 if w.stashHidden {
@@ -186,8 +184,15 @@ func (w *Workspace) ToggleStashVisible() {
186184 w.stashHidden, w.stashFocus = true, false
187185 }
188186
187+// showStashOver shows the stash over the covering fullscreen window,
188+// focused. Not showStash: a pending stashed fullscreen must not replace
189+// the covering window.
190+func (w *Workspace) showStashOver() {
191+ w.stashOver, w.stashHidden, w.stashFocus, w.floatFocus = w.cover(), false, true, false
192+}
193+
189194 // stashOverCover reports whether the stash shows over the covering
190-// fullscreen window (ToggleStashVisible).
195+// fullscreen window (ToggleStashVisible, Activate).
191196 func (w *Workspace) stashOverCover() bool {
192197 return w.stashOver != 0 && w.stashOver == w.cover() && !w.stashHidden && len(w.Stash) > 0
193198 }
@@ -174,9 +174,7 @@ func (w *Workspace) ToggleStashVisible() {
174 w.dropStashOver()174 w.dropStashOver()
175 return175 return
176 }176 }
177- // Not showStash: a pending stashed fullscreen must not replace177+ w.showStashOver()
178- // the covering window.
179- w.stashOver, w.stashHidden, w.stashFocus, w.floatFocus = c, false, true, false
180 return178 return
181 }179 }
182 if w.stashHidden {180 if w.stashHidden {
@@ -186,8 +184,15 @@ func (w *Workspace) ToggleStashVisible() {
186 w.stashHidden, w.stashFocus = true, false184 w.stashHidden, w.stashFocus = true, false
187 }185 }
188 186
187+// showStashOver shows the stash over the covering fullscreen window,
188+// focused. Not showStash: a pending stashed fullscreen must not replace
189+// the covering window.
190+func (w *Workspace) showStashOver() {
191+ w.stashOver, w.stashHidden, w.stashFocus, w.floatFocus = w.cover(), false, true, false
192+}
193+
189 // stashOverCover reports whether the stash shows over the covering194 // stashOverCover reports whether the stash shows over the covering
190-// fullscreen window (ToggleStashVisible).195+// fullscreen window (ToggleStashVisible, Activate).
191 func (w *Workspace) stashOverCover() bool {196 func (w *Workspace) stashOverCover() bool {
192 return w.stashOver != 0 && w.stashOver == w.cover() && !w.stashHidden && len(w.Stash) > 0197 return w.stashOver != 0 && w.stashOver == w.cover() && !w.stashHidden && len(w.Stash) > 0
193 }198 }
M internal/core/stash_cover_test.go +247 -0
@@ -1,6 +1,8 @@
11 package core
22
33 import (
4+ "context"
5+ "slices"
46 "testing"
57
68 "github.com/bnema/neferwl/internal/ports"
@@ -158,3 +160,248 @@ func TestStashOverCoverLeaves(t *testing.T) {
158160 t.Fatalf("fullscreen: cover %d left %d", w.cover(), w.left)
159161 }
160162 }
163+
164+// Activating a window over a covering fullscreen window (xdg-activation,
165+// taskbar) never ends that fullscreen when the window can show over it:
166+// a stashed window shows the stash over the cover, and dialogs of the
167+// cover or of stashed windows, and their dialogs, show above. A game
168+// told it left fullscreen (configure 0x0) quits.
169+func TestActivateOverCover(t *testing.T) {
170+ covers := []struct {
171+ name string
172+ overflow Overflow
173+ float bool
174+ }{
175+ {"fixed tile", OverflowFixed, false},
176+ {"scroll tile", OverflowScroll, false},
177+ {"fixed float", OverflowFixed, true},
178+ {"scroll float", OverflowScroll, true},
179+ }
180+ cases := []struct {
181+ name string
182+ // setup prepares windows and returns the window to activate.
183+ setup func(w *Workspace, cover WindowID) WindowID
184+ // focused is the window focused after the activation.
185+ focused WindowID
186+ // over reports whether the stash shows over the cover then.
187+ over bool
188+ }{
189+ {"stashed", func(*Workspace, WindowID) WindowID { return 2 }, 2, true},
190+ {"modal of stashed", func(w *Workspace, _ WindowID) WindowID {
191+ w.AddDialog(9, 2, 20, 10)
192+ w.SetModal(9, true)
193+ return 2
194+ }, 9, true},
195+ {"modal chain of stashed", func(w *Workspace, _ WindowID) WindowID {
196+ w.AddDialog(9, 2, 20, 10)
197+ w.SetModal(9, true)
198+ w.AddDialog(8, 9, 10, 5)
199+ w.SetModal(8, true)
200+ return 2
201+ }, 8, true},
202+ {"dialog of stashed", func(w *Workspace, _ WindowID) WindowID {
203+ w.AddDialog(9, 2, 20, 10)
204+ return 9
205+ }, 9, true},
206+ {"dialog of cover", func(w *Workspace, cover WindowID) WindowID {
207+ w.AddDialog(9, cover, 20, 10)
208+ w.FocusID(cover)
209+ return 9
210+ }, 9, false},
211+ {"modal chain of cover", func(w *Workspace, cover WindowID) WindowID {
212+ w.AddDialog(9, cover, 20, 10)
213+ w.SetModal(9, true)
214+ w.AddDialog(8, 9, 10, 5)
215+ w.SetModal(8, true)
216+ w.FocusID(cover)
217+ return cover
218+ }, 8, false},
219+ {"stashed from a focused cover dialog", func(w *Workspace, cover WindowID) WindowID {
220+ w.ToggleStashVisible()
221+ w.AddDialog(9, cover, 20, 10)
222+ w.FocusID(9)
223+ return 2
224+ }, 2, true},
225+ }
226+ for _, cv := range covers {
227+ for _, tc := range cases {
228+ t.Run(cv.name+"/"+tc.name, func(t *testing.T) {
229+ _, w, cover := stashCoverCore(t, cv.overflow, cv.float)
230+ id := tc.setup(w, cover)
231+ // A dialog of the hidden stash waits hidden under the cover.
232+ if i := slices.IndexFunc(w.Floats, func(f Float) bool { return f.over == 2 }); i >= 0 && !w.stashOverCover() {
233+ if p := previewOf(t, w.Layout(), w.Floats[i].ID); !p.Hidden {
234+ t.Fatalf("dialog of the hidden stash shown before activation: %+v", p)
235+ }
236+ }
237+ view := w.View
238+ w.Activate(id)
239+ if got, _ := w.Focused(); got != tc.focused || w.cover() != cover || w.left != 0 || w.stashOverCover() != tc.over {
240+ t.Fatalf("focused %d (want %d) cover %d left %d over %v", got, tc.focused, w.cover(), w.left, w.stashOverCover())
241+ }
242+ ps := w.Layout()
243+ if p := previewOf(t, ps, cover); p.Hidden || !p.Fullscreen {
244+ t.Fatalf("cover %+v", p)
245+ }
246+ f := slices.IndexFunc(ps, isID(tc.focused))
247+ if f < slices.IndexFunc(ps, isID(cover)) || ps[f].Hidden {
248+ t.Fatalf("focused window not over the cover: %+v", ps)
249+ }
250+ if tc.over && f < slices.IndexFunc(ps, isID(2)) {
251+ t.Fatalf("focused window under the stash: %+v", ps)
252+ }
253+ if !cv.float && w.View != view {
254+ t.Fatalf("view %d, was %d", w.View, view)
255+ }
256+ })
257+ }
258+ }
259+}
260+
261+// After an activation shows the stash over the cover, hiding the stash
262+// or activating the cover returns to it; a dialog of a stashed window,
263+// modal or mapping meanwhile, shows with the focus and closing it
264+// returns to the stash over the cover.
265+func TestActivateOverCoverReturns(t *testing.T) {
266+ for _, cv := range []struct {
267+ name string
268+ overflow Overflow
269+ float bool
270+ }{
271+ {"fixed tile", OverflowFixed, false},
272+ {"scroll tile", OverflowScroll, false},
273+ {"fixed float", OverflowFixed, true},
274+ {"scroll float", OverflowScroll, true},
275+ } {
276+ t.Run(cv.name, func(t *testing.T) {
277+ _, w, cover := stashCoverCore(t, cv.overflow, cv.float)
278+ w.Activate(2)
279+ w.ToggleStashVisible()
280+ if id, _ := w.Focused(); id != cover || w.stashShown() || w.fullscreen != cover {
281+ t.Fatalf("hide: focused %d shown %v fullscreen %d", id, w.stashShown(), w.fullscreen)
282+ }
283+ w.Activate(2)
284+ w.Activate(cover)
285+ if id, _ := w.Focused(); id != cover || w.stashShown() || w.fullscreen != cover {
286+ t.Fatalf("activate cover: focused %d shown %v fullscreen %d", id, w.stashShown(), w.fullscreen)
287+ }
288+ w.AddDialog(7, 2, 20, 10)
289+ w.SetModal(7, true)
290+ w.FocusID(cover)
291+ w.Activate(2)
292+ w.RemoveWindow(7)
293+ if id, _ := w.Focused(); id != 2 || !w.stashOverCover() {
294+ t.Fatalf("modal closed: focused %d over %v", id, w.stashOverCover())
295+ }
296+ w.AddDialog(9, 2, 20, 10)
297+ w.AddDialog(8, 9, 10, 5)
298+ if id, _ := w.Focused(); id != 8 || previewOf(t, w.Layout(), 8).Hidden {
299+ t.Fatalf("mapped chain: focused %d", id)
300+ }
301+ w.RemoveWindow(8)
302+ w.RemoveWindow(9)
303+ if id, _ := w.Focused(); id != 2 || !w.stashOverCover() {
304+ t.Fatalf("closed: focused %d over %v", id, w.stashOverCover())
305+ }
306+ // The cover closes: the stash stays shown and focused, the
307+ // override ends with it.
308+ w.RemoveWindow(cover)
309+ if id, _ := w.Focused(); id != 2 || w.stashOver != 0 || !w.stashShown() {
310+ t.Fatalf("cover closed: focused %d over %d shown %v", id, w.stashOver, w.stashShown())
311+ }
312+ })
313+ }
314+}
315+
316+// Activating a dialog that cannot show over the cover leaves fullscreen
317+// as any other window does; a dialog of a stashed window whose own
318+// fullscreen waited while hidden brings that window fullscreen, as
319+// activating the window itself does.
320+func TestActivateDialogLeavesCover(t *testing.T) {
321+ t.Run("dialog of a plain float", func(t *testing.T) {
322+ _, w, cover := stashCoverCore(t, OverflowFixed, false)
323+ w.AddFloating(5, 20, 10)
324+ w.AddDialog(9, 5, 10, 5)
325+ w.FocusID(cover)
326+ w.Activate(9)
327+ if id, _ := w.Focused(); id != 9 || w.fullscreen != 0 || w.left != cover {
328+ t.Fatalf("focused %d fullscreen %d left %d", id, w.fullscreen, w.left)
329+ }
330+ })
331+ t.Run("dialog of another tile", func(t *testing.T) {
332+ _, w, cover := stashCoverCore(t, OverflowFixed, true)
333+ w.AddDialog(9, 1, 10, 5)
334+ w.FocusID(cover)
335+ w.Activate(9)
336+ if id, _ := w.Focused(); id != 9 || w.fullscreen != 0 || w.left != cover {
337+ t.Fatalf("focused %d fullscreen %d left %d", id, w.fullscreen, w.left)
338+ }
339+ })
340+ t.Run("dialog of a stashed window with a pending fullscreen", func(t *testing.T) {
341+ _, w, cover := stashCoverCore(t, OverflowFixed, true)
342+ w.SetFullscreen(2, true)
343+ w.AddDialog(9, 2, 10, 5)
344+ w.FocusID(cover)
345+ w.Activate(9)
346+ if id, _ := w.Focused(); id != 9 || w.fullscreen != 2 || w.stashHidden || w.left != cover {
347+ t.Fatalf("focused %d fullscreen %d hidden %v left %d", id, w.fullscreen, w.stashHidden, w.left)
348+ }
349+ if p := previewOf(t, w.Layout(), 9); p.Hidden {
350+ t.Fatalf("dialog hidden: %+v", p)
351+ }
352+ })
353+}
354+
355+// A dialog parent loop under a cover ends: neither dialog shows over it.
356+func TestOverCoverDialogLoop(t *testing.T) {
357+ _, w, _ := stashCoverCore(t, OverflowFixed, true)
358+ w.AddFloating(8, 10, 5)
359+ w.AddFloating(9, 10, 5)
360+ w.SetDialogParent(8, 9)
361+ w.SetDialogParent(9, 8)
362+ ps := w.Layout()
363+ if !previewOf(t, ps, 8).Hidden || !previewOf(t, ps, 9).Hidden {
364+ t.Fatalf("looped dialogs shown: %+v", ps)
365+ }
366+}
367+
368+// Through core: activating a stashed window keeps a fullscreen game's
369+// configure fullscreen at the output size.
370+func TestCoreActivateStashKeepsGameConfigure(t *testing.T) {
371+ cfg := ports.Config{}
372+ cfg.Keyboard.CmdKey = "super"
373+ cfg.Layout.MaxColumns = 2
374+ commands := make(chan ports.ClientCommand, 256)
375+ c, err := New(cfg, Channels{Commands: commands, Scenes: make(chan []ports.Scene, 1)}, Options{})
376+ if err != nil {
377+ t.Fatal(err)
378+ }
379+ c.addScreen(ports.OutputInfo{Name: "A", Width: 100, Height: 80})
380+ m := c.screens[0].mon
381+ m.AddWindow(2)
382+ w := m.Current()
383+ w.ToggleWindowStash()
384+ w.ToggleStashVisible()
385+ m.AddFloating(4, 100, 80)
386+ m.SetFullscreen(4, true)
387+ ctx := context.Background()
388+ if err := c.activate(ctx, 2); err != nil {
389+ t.Fatal(err)
390+ }
391+ if err := c.publish(ctx); err != nil {
392+ t.Fatal(err)
393+ }
394+ var last *ports.ConfigureWindow
395+ for len(commands) > 0 {
396+ if v, ok := (<-commands).(ports.ConfigureWindow); ok && v.ID == 4 {
397+ last = &v
398+ }
399+ }
400+ if last == nil || !last.Fullscreen || last.Width != 100 || last.Height != 80 {
401+ t.Fatalf("game configure %+v", last)
402+ }
403+}
404+
405+func isID(id WindowID) func(Placement) bool {
406+ return func(p Placement) bool { return p.ID == id }
407+}
@@ -1,6 +1,8 @@
1 package core1 package core
2 2
3 import (3 import (
4+ "context"
5+ "slices"
4 "testing"6 "testing"
5 7
6 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
@@ -158,3 +160,248 @@ func TestStashOverCoverLeaves(t *testing.T) {
158 t.Fatalf("fullscreen: cover %d left %d", w.cover(), w.left)160 t.Fatalf("fullscreen: cover %d left %d", w.cover(), w.left)
159 }161 }
160 }162 }
163+
164+// Activating a window over a covering fullscreen window (xdg-activation,
165+// taskbar) never ends that fullscreen when the window can show over it:
166+// a stashed window shows the stash over the cover, and dialogs of the
167+// cover or of stashed windows, and their dialogs, show above. A game
168+// told it left fullscreen (configure 0x0) quits.
169+func TestActivateOverCover(t *testing.T) {
170+ covers := []struct {
171+ name string
172+ overflow Overflow
173+ float bool
174+ }{
175+ {"fixed tile", OverflowFixed, false},
176+ {"scroll tile", OverflowScroll, false},
177+ {"fixed float", OverflowFixed, true},
178+ {"scroll float", OverflowScroll, true},
179+ }
180+ cases := []struct {
181+ name string
182+ // setup prepares windows and returns the window to activate.
183+ setup func(w *Workspace, cover WindowID) WindowID
184+ // focused is the window focused after the activation.
185+ focused WindowID
186+ // over reports whether the stash shows over the cover then.
187+ over bool
188+ }{
189+ {"stashed", func(*Workspace, WindowID) WindowID { return 2 }, 2, true},
190+ {"modal of stashed", func(w *Workspace, _ WindowID) WindowID {
191+ w.AddDialog(9, 2, 20, 10)
192+ w.SetModal(9, true)
193+ return 2
194+ }, 9, true},
195+ {"modal chain of stashed", func(w *Workspace, _ WindowID) WindowID {
196+ w.AddDialog(9, 2, 20, 10)
197+ w.SetModal(9, true)
198+ w.AddDialog(8, 9, 10, 5)
199+ w.SetModal(8, true)
200+ return 2
201+ }, 8, true},
202+ {"dialog of stashed", func(w *Workspace, _ WindowID) WindowID {
203+ w.AddDialog(9, 2, 20, 10)
204+ return 9
205+ }, 9, true},
206+ {"dialog of cover", func(w *Workspace, cover WindowID) WindowID {
207+ w.AddDialog(9, cover, 20, 10)
208+ w.FocusID(cover)
209+ return 9
210+ }, 9, false},
211+ {"modal chain of cover", func(w *Workspace, cover WindowID) WindowID {
212+ w.AddDialog(9, cover, 20, 10)
213+ w.SetModal(9, true)
214+ w.AddDialog(8, 9, 10, 5)
215+ w.SetModal(8, true)
216+ w.FocusID(cover)
217+ return cover
218+ }, 8, false},
219+ {"stashed from a focused cover dialog", func(w *Workspace, cover WindowID) WindowID {
220+ w.ToggleStashVisible()
221+ w.AddDialog(9, cover, 20, 10)
222+ w.FocusID(9)
223+ return 2
224+ }, 2, true},
225+ }
226+ for _, cv := range covers {
227+ for _, tc := range cases {
228+ t.Run(cv.name+"/"+tc.name, func(t *testing.T) {
229+ _, w, cover := stashCoverCore(t, cv.overflow, cv.float)
230+ id := tc.setup(w, cover)
231+ // A dialog of the hidden stash waits hidden under the cover.
232+ if i := slices.IndexFunc(w.Floats, func(f Float) bool { return f.over == 2 }); i >= 0 && !w.stashOverCover() {
233+ if p := previewOf(t, w.Layout(), w.Floats[i].ID); !p.Hidden {
234+ t.Fatalf("dialog of the hidden stash shown before activation: %+v", p)
235+ }
236+ }
237+ view := w.View
238+ w.Activate(id)
239+ if got, _ := w.Focused(); got != tc.focused || w.cover() != cover || w.left != 0 || w.stashOverCover() != tc.over {
240+ t.Fatalf("focused %d (want %d) cover %d left %d over %v", got, tc.focused, w.cover(), w.left, w.stashOverCover())
241+ }
242+ ps := w.Layout()
243+ if p := previewOf(t, ps, cover); p.Hidden || !p.Fullscreen {
244+ t.Fatalf("cover %+v", p)
245+ }
246+ f := slices.IndexFunc(ps, isID(tc.focused))
247+ if f < slices.IndexFunc(ps, isID(cover)) || ps[f].Hidden {
248+ t.Fatalf("focused window not over the cover: %+v", ps)
249+ }
250+ if tc.over && f < slices.IndexFunc(ps, isID(2)) {
251+ t.Fatalf("focused window under the stash: %+v", ps)
252+ }
253+ if !cv.float && w.View != view {
254+ t.Fatalf("view %d, was %d", w.View, view)
255+ }
256+ })
257+ }
258+ }
259+}
260+
261+// After an activation shows the stash over the cover, hiding the stash
262+// or activating the cover returns to it; a dialog of a stashed window,
263+// modal or mapping meanwhile, shows with the focus and closing it
264+// returns to the stash over the cover.
265+func TestActivateOverCoverReturns(t *testing.T) {
266+ for _, cv := range []struct {
267+ name string
268+ overflow Overflow
269+ float bool
270+ }{
271+ {"fixed tile", OverflowFixed, false},
272+ {"scroll tile", OverflowScroll, false},
273+ {"fixed float", OverflowFixed, true},
274+ {"scroll float", OverflowScroll, true},
275+ } {
276+ t.Run(cv.name, func(t *testing.T) {
277+ _, w, cover := stashCoverCore(t, cv.overflow, cv.float)
278+ w.Activate(2)
279+ w.ToggleStashVisible()
280+ if id, _ := w.Focused(); id != cover || w.stashShown() || w.fullscreen != cover {
281+ t.Fatalf("hide: focused %d shown %v fullscreen %d", id, w.stashShown(), w.fullscreen)
282+ }
283+ w.Activate(2)
284+ w.Activate(cover)
285+ if id, _ := w.Focused(); id != cover || w.stashShown() || w.fullscreen != cover {
286+ t.Fatalf("activate cover: focused %d shown %v fullscreen %d", id, w.stashShown(), w.fullscreen)
287+ }
288+ w.AddDialog(7, 2, 20, 10)
289+ w.SetModal(7, true)
290+ w.FocusID(cover)
291+ w.Activate(2)
292+ w.RemoveWindow(7)
293+ if id, _ := w.Focused(); id != 2 || !w.stashOverCover() {
294+ t.Fatalf("modal closed: focused %d over %v", id, w.stashOverCover())
295+ }
296+ w.AddDialog(9, 2, 20, 10)
297+ w.AddDialog(8, 9, 10, 5)
298+ if id, _ := w.Focused(); id != 8 || previewOf(t, w.Layout(), 8).Hidden {
299+ t.Fatalf("mapped chain: focused %d", id)
300+ }
301+ w.RemoveWindow(8)
302+ w.RemoveWindow(9)
303+ if id, _ := w.Focused(); id != 2 || !w.stashOverCover() {
304+ t.Fatalf("closed: focused %d over %v", id, w.stashOverCover())
305+ }
306+ // The cover closes: the stash stays shown and focused, the
307+ // override ends with it.
308+ w.RemoveWindow(cover)
309+ if id, _ := w.Focused(); id != 2 || w.stashOver != 0 || !w.stashShown() {
310+ t.Fatalf("cover closed: focused %d over %d shown %v", id, w.stashOver, w.stashShown())
311+ }
312+ })
313+ }
314+}
315+
316+// Activating a dialog that cannot show over the cover leaves fullscreen
317+// as any other window does; a dialog of a stashed window whose own
318+// fullscreen waited while hidden brings that window fullscreen, as
319+// activating the window itself does.
320+func TestActivateDialogLeavesCover(t *testing.T) {
321+ t.Run("dialog of a plain float", func(t *testing.T) {
322+ _, w, cover := stashCoverCore(t, OverflowFixed, false)
323+ w.AddFloating(5, 20, 10)
324+ w.AddDialog(9, 5, 10, 5)
325+ w.FocusID(cover)
326+ w.Activate(9)
327+ if id, _ := w.Focused(); id != 9 || w.fullscreen != 0 || w.left != cover {
328+ t.Fatalf("focused %d fullscreen %d left %d", id, w.fullscreen, w.left)
329+ }
330+ })
331+ t.Run("dialog of another tile", func(t *testing.T) {
332+ _, w, cover := stashCoverCore(t, OverflowFixed, true)
333+ w.AddDialog(9, 1, 10, 5)
334+ w.FocusID(cover)
335+ w.Activate(9)
336+ if id, _ := w.Focused(); id != 9 || w.fullscreen != 0 || w.left != cover {
337+ t.Fatalf("focused %d fullscreen %d left %d", id, w.fullscreen, w.left)
338+ }
339+ })
340+ t.Run("dialog of a stashed window with a pending fullscreen", func(t *testing.T) {
341+ _, w, cover := stashCoverCore(t, OverflowFixed, true)
342+ w.SetFullscreen(2, true)
343+ w.AddDialog(9, 2, 10, 5)
344+ w.FocusID(cover)
345+ w.Activate(9)
346+ if id, _ := w.Focused(); id != 9 || w.fullscreen != 2 || w.stashHidden || w.left != cover {
347+ t.Fatalf("focused %d fullscreen %d hidden %v left %d", id, w.fullscreen, w.stashHidden, w.left)
348+ }
349+ if p := previewOf(t, w.Layout(), 9); p.Hidden {
350+ t.Fatalf("dialog hidden: %+v", p)
351+ }
352+ })
353+}
354+
355+// A dialog parent loop under a cover ends: neither dialog shows over it.
356+func TestOverCoverDialogLoop(t *testing.T) {
357+ _, w, _ := stashCoverCore(t, OverflowFixed, true)
358+ w.AddFloating(8, 10, 5)
359+ w.AddFloating(9, 10, 5)
360+ w.SetDialogParent(8, 9)
361+ w.SetDialogParent(9, 8)
362+ ps := w.Layout()
363+ if !previewOf(t, ps, 8).Hidden || !previewOf(t, ps, 9).Hidden {
364+ t.Fatalf("looped dialogs shown: %+v", ps)
365+ }
366+}
367+
368+// Through core: activating a stashed window keeps a fullscreen game's
369+// configure fullscreen at the output size.
370+func TestCoreActivateStashKeepsGameConfigure(t *testing.T) {
371+ cfg := ports.Config{}
372+ cfg.Keyboard.CmdKey = "super"
373+ cfg.Layout.MaxColumns = 2
374+ commands := make(chan ports.ClientCommand, 256)
375+ c, err := New(cfg, Channels{Commands: commands, Scenes: make(chan []ports.Scene, 1)}, Options{})
376+ if err != nil {
377+ t.Fatal(err)
378+ }
379+ c.addScreen(ports.OutputInfo{Name: "A", Width: 100, Height: 80})
380+ m := c.screens[0].mon
381+ m.AddWindow(2)
382+ w := m.Current()
383+ w.ToggleWindowStash()
384+ w.ToggleStashVisible()
385+ m.AddFloating(4, 100, 80)
386+ m.SetFullscreen(4, true)
387+ ctx := context.Background()
388+ if err := c.activate(ctx, 2); err != nil {
389+ t.Fatal(err)
390+ }
391+ if err := c.publish(ctx); err != nil {
392+ t.Fatal(err)
393+ }
394+ var last *ports.ConfigureWindow
395+ for len(commands) > 0 {
396+ if v, ok := (<-commands).(ports.ConfigureWindow); ok && v.ID == 4 {
397+ last = &v
398+ }
399+ }
400+ if last == nil || !last.Fullscreen || last.Width != 100 || last.Height != 80 {
401+ t.Fatalf("game configure %+v", last)
402+ }
403+}
404+
405+func isID(id WindowID) func(Placement) bool {
406+ return func(p Placement) bool { return p.ID == id }
407+}
M internal/core/stash_test.go +5 -5
@@ -319,9 +319,9 @@ func TestFullscreenStashEdgeGoesToMonitor(t *testing.T) {
319319 }
320320 }
321321
322-// Activating a hidden stashed window over a scroll fullscreen tile ends
323-// the fullscreen like any exit: the view leaves the fullscreen alignment.
324-func TestActivateHiddenStashEndsScrollFullscreen(t *testing.T) {
322+// Activating a hidden stashed window over a scroll fullscreen tile shows
323+// the stash over it: the tile stays fullscreen and the view stays aligned.
324+func TestActivateHiddenStashOverScrollFullscreen(t *testing.T) {
325325 w := workspace()
326326 w.AddWindow(1)
327327 w.AddWindow(2)
@@ -331,8 +331,8 @@ func TestActivateHiddenStashEndsScrollFullscreen(t *testing.T) {
331331 w.FocusID(2)
332332 w.SetFullscreen(2, true)
333333 w.Activate(3)
334- if id, _ := w.Focused(); id != 3 || w.fullscreen != 0 {
335- t.Fatalf("focused %d fullscreen %d", id, w.fullscreen)
334+ if id, _ := w.Focused(); id != 3 || w.fullscreen != 2 || !w.stashOverCover() {
335+ t.Fatalf("focused %d fullscreen %d over %v", id, w.fullscreen, w.stashOverCover())
336336 }
337337 view := w.View
338338 w.scroll()
@@ -319,9 +319,9 @@ func TestFullscreenStashEdgeGoesToMonitor(t *testing.T) {
319 }319 }
320 }320 }
321 321
322-// Activating a hidden stashed window over a scroll fullscreen tile ends322+// Activating a hidden stashed window over a scroll fullscreen tile shows
323-// the fullscreen like any exit: the view leaves the fullscreen alignment.323+// the stash over it: the tile stays fullscreen and the view stays aligned.
324-func TestActivateHiddenStashEndsScrollFullscreen(t *testing.T) {324+func TestActivateHiddenStashOverScrollFullscreen(t *testing.T) {
325 w := workspace()325 w := workspace()
326 w.AddWindow(1)326 w.AddWindow(1)
327 w.AddWindow(2)327 w.AddWindow(2)
@@ -331,8 +331,8 @@ func TestActivateHiddenStashEndsScrollFullscreen(t *testing.T) {
331 w.FocusID(2)331 w.FocusID(2)
332 w.SetFullscreen(2, true)332 w.SetFullscreen(2, true)
333 w.Activate(3)333 w.Activate(3)
334- if id, _ := w.Focused(); id != 3 || w.fullscreen != 0 {334+ if id, _ := w.Focused(); id != 3 || w.fullscreen != 2 || !w.stashOverCover() {
335- t.Fatalf("focused %d fullscreen %d", id, w.fullscreen)335+ t.Fatalf("focused %d fullscreen %d over %v", id, w.fullscreen, w.stashOverCover())
336 }336 }
337 view := w.View337 view := w.View
338 w.scroll()338 w.scroll()

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