detached e031711

fix(core): keep the cover fullscreen when a dialog over it is activated

Activating a dialog drawn over a fullscreen cover, or a dialog of a stashed window, still ended the cover's fullscreen. Activate now resolves the dialog's owner (dialogOwner) and shows it over the cover. showStashOver shares the stash-over setup with toggle-stash-visible. Activation tests move to a table over every cover kind, with a core test on the game's configure. Docs describe activations and dialogs over a fullscreen window.
commit
e03171101862f87d5865458959174ea26ac6fe48
author
brice · · 2026-10-13 08:25 UTC
parent
92f3c09
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +229 -202
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, except an activation of a window that can show over it: one of its dialogs, or a stashed window, which shows the stash over it as `toggle-stash-visible` does; 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 |
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, except an activation of a window that can show over it: one of its dialogs, or a stashed window, which shows the stash over it as `toggle-stash-visible` does; 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 |
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/core/floating_test.go +2 -158
@@ -490,9 +490,8 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) {
490490 check(0.3)
491491 }
492492
493-// Activating a hidden stashed window shows the stash over the covering
494-// window and focuses it; the cover stays fullscreen behind and returns
495-// when the stash hides.
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.
496495 func TestActivateHiddenFloat(t *testing.T) {
497496 w := workspace()
498497 w.AddWindow(1)
@@ -500,20 +499,6 @@ func TestActivateHiddenFloat(t *testing.T) {
500499 w.ToggleWindowStash()
501500 w.ToggleStashVisible()
502501 w.FocusID(1)
503- w.SetFullscreen(1, true)
504- w.Activate(2)
505- if id, _ := w.Focused(); id != 2 || !w.stashShown() || w.fullscreen != 1 {
506- t.Fatalf("focused %d shown %v fullscreen %d", id, w.stashShown(), w.fullscreen)
507- }
508- w.ToggleStashVisible()
509- if id, _ := w.Focused(); id != 1 || w.stashShown() || w.fullscreen != 1 {
510- t.Fatalf("stash hidden: focused %d shown %v fullscreen %d", id, w.stashShown(), w.fullscreen)
511- }
512- w.SetFullscreen(1, false)
513- w.ToggleStashVisible()
514- // The float's own fullscreen, asked while hidden, comes back with it.
515- w.FocusID(2)
516- w.ToggleStashVisible()
517502 w.SetFullscreen(2, true)
518503 w.SetFullscreen(1, true)
519504 w.Activate(2)
@@ -521,144 +506,3 @@ func TestActivateHiddenFloat(t *testing.T) {
521506 t.Fatalf("own fullscreen: focused %d hidden %v fullscreen %d", id, w.stashHidden, w.fullscreen)
522507 }
523508 }
524-
525-// A fullscreen game float stays fullscreen when a hidden stashed window is
526-// activated (xdg-activation, taskbar): the stash shows over it. Ending its
527-// fullscreen sent the game a 0x0 configure and it quit.
528-func TestActivateStashKeepsFullscreenFloat(t *testing.T) {
529- w := workspace()
530- w.AddWindow(14)
531- w.ToggleWindowStash()
532- w.ToggleStashVisible()
533- w.AddFloating(72, 50, 40)
534- w.SetFullscreen(72, true)
535- w.Activate(14)
536- if id, _ := w.Focused(); id != 14 || !w.stashOverCover() || w.fullscreen != 72 || w.left != 0 {
537- t.Fatalf("focused %d over %v fullscreen %d left %d", id, w.stashOverCover(), w.fullscreen, w.left)
538- }
539- // The game gets the focus back fullscreen.
540- w.Activate(72)
541- if id, _ := w.Focused(); id != 72 || w.stashShown() || w.fullscreen != 72 {
542- t.Fatalf("back: focused %d shown %v fullscreen %d", id, w.stashShown(), w.fullscreen)
543- }
544-}
545-
546-// A modal dialog of the activated stashed window shows over the cover,
547-// focused, and the game stays fullscreen; closing it returns to the stash
548-// over the cover.
549-func TestActivateStashModalKeepsFullscreen(t *testing.T) {
550- w := workspace()
551- w.AddWindow(14)
552- w.ToggleWindowStash()
553- w.ToggleStashVisible()
554- w.AddDialog(9, 14, 20, 10)
555- w.SetModal(9, true)
556- w.AddFloating(72, 50, 40)
557- w.SetFullscreen(72, true)
558- if p := placementOf(w.Layout(), 9); !p.Hidden {
559- t.Fatalf("modal shown over the game before activation: %+v", p)
560- }
561- w.Activate(14)
562- if id, _ := w.Focused(); id != 9 || w.fullscreen != 72 || w.left != 0 || !w.stashOverCover() {
563- t.Fatalf("focused %d fullscreen %d left %d over %v", id, w.fullscreen, w.left, w.stashOverCover())
564- }
565- layout := w.Layout()
566- if p := placementOf(layout, 9); p.Hidden || p.Rect.W == 0 {
567- t.Fatalf("modal hidden: %+v", p)
568- }
569- if p := placementOf(layout, 72); !p.Fullscreen {
570- t.Fatalf("game left fullscreen: %+v", p)
571- }
572- if placementIndex(layout, 9) < placementIndex(layout, 14) || placementIndex(layout, 14) < placementIndex(layout, 72) {
573- t.Fatalf("order: game %d stash %d modal %d", placementIndex(layout, 72), placementIndex(layout, 14), placementIndex(layout, 9))
574- }
575- w.RemoveWindow(9)
576- if id, _ := w.Focused(); id != 14 || w.fullscreen != 72 || !w.stashOverCover() {
577- t.Fatalf("closed: focused %d fullscreen %d over %v", id, w.fullscreen, w.stashOverCover())
578- }
579-}
580-
581-// A dialog of a dialog (an "Overwrite?" over a "Save as…") shows over
582-// the cover like its parent: modal chains of a stashed window or of the
583-// cover keep the game fullscreen, and a new one takes the focus.
584-func TestDialogChainOverCover(t *testing.T) {
585- t.Run("stashed modal chain", func(t *testing.T) {
586- w := workspace()
587- w.AddWindow(14)
588- w.ToggleWindowStash()
589- w.ToggleStashVisible()
590- w.AddDialog(9, 14, 20, 10)
591- w.SetModal(9, true)
592- w.AddDialog(8, 9, 10, 5)
593- w.SetModal(8, true)
594- w.AddFloating(72, 50, 40)
595- w.SetFullscreen(72, true)
596- w.Activate(14)
597- if id, _ := w.Focused(); id != 8 || w.fullscreen != 72 || w.left != 0 {
598- t.Fatalf("focused %d fullscreen %d left %d", id, w.fullscreen, w.left)
599- }
600- if p := placementOf(w.Layout(), 8); p.Hidden {
601- t.Fatalf("confirm hidden: %+v", p)
602- }
603- })
604- t.Run("stashed dialog maps a dialog", func(t *testing.T) {
605- w := workspace()
606- w.AddWindow(14)
607- w.ToggleWindowStash()
608- w.ToggleStashVisible()
609- w.AddFloating(72, 50, 40)
610- w.SetFullscreen(72, true)
611- w.Activate(14)
612- w.AddDialog(9, 14, 20, 10)
613- w.AddDialog(8, 9, 10, 5)
614- if id, _ := w.Focused(); id != 8 || w.fullscreen != 72 {
615- t.Fatalf("focused %d fullscreen %d", id, w.fullscreen)
616- }
617- if p := placementOf(w.Layout(), 8); p.Hidden {
618- t.Fatalf("confirm hidden: %+v", p)
619- }
620- })
621- t.Run("cover modal chain", func(t *testing.T) {
622- w := workspace()
623- w.AddFloating(72, 50, 40)
624- w.AddDialog(9, 72, 20, 10)
625- w.SetModal(9, true)
626- w.AddDialog(8, 9, 10, 5)
627- w.SetModal(8, true)
628- w.SetFullscreen(72, true)
629- w.AddWindow(1)
630- w.Activate(72)
631- if id, _ := w.Focused(); id != 8 || w.fullscreen != 72 || w.left != 0 {
632- t.Fatalf("focused %d fullscreen %d left %d", id, w.fullscreen, w.left)
633- }
634- })
635-}
636-
637-func placementOf(ps []Placement, id WindowID) Placement {
638- if i := placementIndex(ps, id); i >= 0 {
639- return ps[i]
640- }
641- return Placement{}
642-}
643-
644-func placementIndex(ps []Placement, id WindowID) int {
645- return slices.IndexFunc(ps, func(p Placement) bool { return p.ID == id })
646-}
647-
648-// With the stash already over the cover and a dialog of the cover
649-// focused, activating a stashed window focuses it.
650-func TestActivateStashOverCoverFromCoverDialog(t *testing.T) {
651- w := workspace()
652- w.AddWindow(14)
653- w.ToggleWindowStash()
654- w.ToggleStashVisible()
655- w.AddWindow(1)
656- w.SetFullscreen(1, true)
657- w.ToggleStashVisible()
658- w.AddDialog(9, 1, 20, 10)
659- w.FocusID(9)
660- w.Activate(14)
661- if id, _ := w.Focused(); id != 14 || !w.stashOverCover() || w.fullscreen != 1 {
662- t.Fatalf("focused %d over %v fullscreen %d", id, w.stashOverCover(), w.fullscreen)
663- }
664-}
@@ -490,9 +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 over the covering493+// A stashed float's own fullscreen, asked while the stash was hidden,
494-// window and focuses it; the cover stays fullscreen behind and returns494+// comes back with it when it is activated: it replaces the cover.
495-// when the stash hides.
496 func TestActivateHiddenFloat(t *testing.T) {495 func TestActivateHiddenFloat(t *testing.T) {
497 w := workspace()496 w := workspace()
498 w.AddWindow(1)497 w.AddWindow(1)
@@ -500,20 +499,6 @@ func TestActivateHiddenFloat(t *testing.T) {
500 w.ToggleWindowStash()499 w.ToggleWindowStash()
501 w.ToggleStashVisible()500 w.ToggleStashVisible()
502 w.FocusID(1)501 w.FocusID(1)
503- w.SetFullscreen(1, true)
504- w.Activate(2)
505- if id, _ := w.Focused(); id != 2 || !w.stashShown() || w.fullscreen != 1 {
506- t.Fatalf("focused %d shown %v fullscreen %d", id, w.stashShown(), w.fullscreen)
507- }
508- w.ToggleStashVisible()
509- if id, _ := w.Focused(); id != 1 || w.stashShown() || w.fullscreen != 1 {
510- t.Fatalf("stash hidden: focused %d shown %v fullscreen %d", id, w.stashShown(), w.fullscreen)
511- }
512- w.SetFullscreen(1, false)
513- w.ToggleStashVisible()
514- // The float's own fullscreen, asked while hidden, comes back with it.
515- w.FocusID(2)
516- w.ToggleStashVisible()
517 w.SetFullscreen(2, true)502 w.SetFullscreen(2, true)
518 w.SetFullscreen(1, true)503 w.SetFullscreen(1, true)
519 w.Activate(2)504 w.Activate(2)
@@ -521,144 +506,3 @@ func TestActivateHiddenFloat(t *testing.T) {
521 t.Fatalf("own fullscreen: focused %d hidden %v fullscreen %d", id, w.stashHidden, w.fullscreen)506 t.Fatalf("own fullscreen: focused %d hidden %v fullscreen %d", id, w.stashHidden, w.fullscreen)
522 }507 }
523 }508 }
524-
525-// A fullscreen game float stays fullscreen when a hidden stashed window is
526-// activated (xdg-activation, taskbar): the stash shows over it. Ending its
527-// fullscreen sent the game a 0x0 configure and it quit.
528-func TestActivateStashKeepsFullscreenFloat(t *testing.T) {
529- w := workspace()
530- w.AddWindow(14)
531- w.ToggleWindowStash()
532- w.ToggleStashVisible()
533- w.AddFloating(72, 50, 40)
534- w.SetFullscreen(72, true)
535- w.Activate(14)
536- if id, _ := w.Focused(); id != 14 || !w.stashOverCover() || w.fullscreen != 72 || w.left != 0 {
537- t.Fatalf("focused %d over %v fullscreen %d left %d", id, w.stashOverCover(), w.fullscreen, w.left)
538- }
539- // The game gets the focus back fullscreen.
540- w.Activate(72)
541- if id, _ := w.Focused(); id != 72 || w.stashShown() || w.fullscreen != 72 {
542- t.Fatalf("back: focused %d shown %v fullscreen %d", id, w.stashShown(), w.fullscreen)
543- }
544-}
545-
546-// A modal dialog of the activated stashed window shows over the cover,
547-// focused, and the game stays fullscreen; closing it returns to the stash
548-// over the cover.
549-func TestActivateStashModalKeepsFullscreen(t *testing.T) {
550- w := workspace()
551- w.AddWindow(14)
552- w.ToggleWindowStash()
553- w.ToggleStashVisible()
554- w.AddDialog(9, 14, 20, 10)
555- w.SetModal(9, true)
556- w.AddFloating(72, 50, 40)
557- w.SetFullscreen(72, true)
558- if p := placementOf(w.Layout(), 9); !p.Hidden {
559- t.Fatalf("modal shown over the game before activation: %+v", p)
560- }
561- w.Activate(14)
562- if id, _ := w.Focused(); id != 9 || w.fullscreen != 72 || w.left != 0 || !w.stashOverCover() {
563- t.Fatalf("focused %d fullscreen %d left %d over %v", id, w.fullscreen, w.left, w.stashOverCover())
564- }
565- layout := w.Layout()
566- if p := placementOf(layout, 9); p.Hidden || p.Rect.W == 0 {
567- t.Fatalf("modal hidden: %+v", p)
568- }
569- if p := placementOf(layout, 72); !p.Fullscreen {
570- t.Fatalf("game left fullscreen: %+v", p)
571- }
572- if placementIndex(layout, 9) < placementIndex(layout, 14) || placementIndex(layout, 14) < placementIndex(layout, 72) {
573- t.Fatalf("order: game %d stash %d modal %d", placementIndex(layout, 72), placementIndex(layout, 14), placementIndex(layout, 9))
574- }
575- w.RemoveWindow(9)
576- if id, _ := w.Focused(); id != 14 || w.fullscreen != 72 || !w.stashOverCover() {
577- t.Fatalf("closed: focused %d fullscreen %d over %v", id, w.fullscreen, w.stashOverCover())
578- }
579-}
580-
581-// A dialog of a dialog (an "Overwrite?" over a "Save as…") shows over
582-// the cover like its parent: modal chains of a stashed window or of the
583-// cover keep the game fullscreen, and a new one takes the focus.
584-func TestDialogChainOverCover(t *testing.T) {
585- t.Run("stashed modal chain", func(t *testing.T) {
586- w := workspace()
587- w.AddWindow(14)
588- w.ToggleWindowStash()
589- w.ToggleStashVisible()
590- w.AddDialog(9, 14, 20, 10)
591- w.SetModal(9, true)
592- w.AddDialog(8, 9, 10, 5)
593- w.SetModal(8, true)
594- w.AddFloating(72, 50, 40)
595- w.SetFullscreen(72, true)
596- w.Activate(14)
597- if id, _ := w.Focused(); id != 8 || w.fullscreen != 72 || w.left != 0 {
598- t.Fatalf("focused %d fullscreen %d left %d", id, w.fullscreen, w.left)
599- }
600- if p := placementOf(w.Layout(), 8); p.Hidden {
601- t.Fatalf("confirm hidden: %+v", p)
602- }
603- })
604- t.Run("stashed dialog maps a dialog", func(t *testing.T) {
605- w := workspace()
606- w.AddWindow(14)
607- w.ToggleWindowStash()
608- w.ToggleStashVisible()
609- w.AddFloating(72, 50, 40)
610- w.SetFullscreen(72, true)
611- w.Activate(14)
612- w.AddDialog(9, 14, 20, 10)
613- w.AddDialog(8, 9, 10, 5)
614- if id, _ := w.Focused(); id != 8 || w.fullscreen != 72 {
615- t.Fatalf("focused %d fullscreen %d", id, w.fullscreen)
616- }
617- if p := placementOf(w.Layout(), 8); p.Hidden {
618- t.Fatalf("confirm hidden: %+v", p)
619- }
620- })
621- t.Run("cover modal chain", func(t *testing.T) {
622- w := workspace()
623- w.AddFloating(72, 50, 40)
624- w.AddDialog(9, 72, 20, 10)
625- w.SetModal(9, true)
626- w.AddDialog(8, 9, 10, 5)
627- w.SetModal(8, true)
628- w.SetFullscreen(72, true)
629- w.AddWindow(1)
630- w.Activate(72)
631- if id, _ := w.Focused(); id != 8 || w.fullscreen != 72 || w.left != 0 {
632- t.Fatalf("focused %d fullscreen %d left %d", id, w.fullscreen, w.left)
633- }
634- })
635-}
636-
637-func placementOf(ps []Placement, id WindowID) Placement {
638- if i := placementIndex(ps, id); i >= 0 {
639- return ps[i]
640- }
641- return Placement{}
642-}
643-
644-func placementIndex(ps []Placement, id WindowID) int {
645- return slices.IndexFunc(ps, func(p Placement) bool { return p.ID == id })
646-}
647-
648-// With the stash already over the cover and a dialog of the cover
649-// focused, activating a stashed window focuses it.
650-func TestActivateStashOverCoverFromCoverDialog(t *testing.T) {
651- w := workspace()
652- w.AddWindow(14)
653- w.ToggleWindowStash()
654- w.ToggleStashVisible()
655- w.AddWindow(1)
656- w.SetFullscreen(1, true)
657- w.ToggleStashVisible()
658- w.AddDialog(9, 1, 20, 10)
659- w.FocusID(9)
660- w.Activate(14)
661- if id, _ := w.Focused(); id != 14 || !w.stashOverCover() || w.fullscreen != 1 {
662- t.Fatalf("focused %d over %v fullscreen %d", id, w.stashOverCover(), w.fullscreen)
663- }
664-}
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 over a fixed fullscreen tile shows
116-// the stash over it: the tile stays fullscreen and nothing is latched.
117-func TestActivateHiddenStashOverFixedCover(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 != 1 || w.left != 0 || !w.stashOverCover() {
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 over a fixed fullscreen tile shows
116-// the stash over it: the tile stays fullscreen and nothing is latched.
117-func TestActivateHiddenStashOverFixedCover(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 != 1 || w.left != 0 || !w.stashOverCover() {
131- t.Fatalf("focused %d, fullscreen %d, left %d", f, w.fullscreen, w.left)
132- }
133-}
M internal/core/layout.go +37 -17
@@ -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; ""
@@ -392,7 +393,7 @@ func (w *Workspace) focusModal(from WindowID) {
392393 if c := w.cover(); c != 0 && c != d {
393394 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 tile, so the dialog shows.
396397 w.leaveFullscreen()
397398 }
398399 }
@@ -408,22 +409,29 @@ func (w *Workspace) overCover(f Float) bool {
408409 if c == 0 || f.ID == c {
409410 return false
410411 }
411- over := w.stashOverCover()
412+ owner := w.dialogOwner(f, c)
413+ return owner == c || owner != 0 && w.stashOverCover() && w.stashIndex(owner) >= 0
414+}
415+
416+// dialogOwner is the window float f is a dialog of, through dialogs of
417+// dialogs: the first parent that is no float, or stop. 0 when f is no
418+// dialog, its chain ends on a plain float, or it loops.
419+func (w *Workspace) dialogOwner(f Float, stop WindowID) WindowID {
412420 // Each hop goes to another float: at most len(w.Floats) hops.
413421 for range len(w.Floats) {
414422 if f.over == 0 || f.over == f.ID {
415- return false
423+ return 0
416424 }
417- if f.over == c || over && w.stashIndex(f.over) >= 0 {
418- return true
425+ if f.over == stop {
426+ return stop
419427 }
420428 i := w.floatIndex(f.over)
421429 if i < 0 {
422- return false
430+ return f.over
423431 }
424432 f = w.Floats[i]
425433 }
426- return false
434+ return 0
427435 }
428436
429437 // coverDialog is the topmost dialog shown over the covering fullscreen
@@ -1255,17 +1263,29 @@ func (w *Workspace) leaveFullscreen() {
12551263 }
12561264
12571265 // Activate focuses a window and makes it visible: it leaves another
1258-// window's fullscreen, which hides everything else. A stashed window
1259-// shows over a covering window instead, as ToggleStashVisible does: the
1260-// cover stays fullscreen behind, so a game never sees its fullscreen end.
1266+// window's fullscreen, which hides everything else. Over a covering
1267+// window, a dialog drawn over it, a stashed window or a dialog of one
1268+// shows over it instead, as ToggleStashVisible does: the cover stays
1269+// fullscreen behind, so a game never sees its fullscreen end.
12611270 func (w *Workspace) Activate(id WindowID) {
1262- if i := w.stashIndex(id); i >= 0 && w.coverStays(id) {
1263- if !w.stashOverCover() {
1264- w.stashOver, w.stashHidden = w.cover(), false
1271+ if c := w.cover(); c != 0 && id != c {
1272+ owner := id
1273+ if f := w.floatIndex(id); f >= 0 {
1274+ owner = w.dialogOwner(w.Floats[f], c)
1275+ }
1276+ if i := w.stashIndex(owner); i >= 0 && w.coverStays(owner) {
1277+ if !w.stashOverCover() {
1278+ w.showStashOver()
1279+ }
1280+ w.stashAt, w.stashFocus, w.floatFocus = i, true, false
1281+ }
1282+ if owner == c || w.stashOverCover() && w.stashIndex(owner) >= 0 {
1283+ if owner != id {
1284+ w.FocusID(id)
1285+ }
1286+ w.focusModal(0)
1287+ return
12651288 }
1266- w.stashAt, w.stashFocus, w.floatFocus = i, true, false
1267- w.focusModal(0)
1268- return
12691289 }
12701290 if i := w.stashIndex(id); i >= 0 && w.stashHidden {
12711291 // A hidden stashed window must be seen: show the stash with it,
@@ -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; ""
@@ -392,7 +393,7 @@ func (w *Workspace) focusModal(from WindowID) {
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.overCover(w.Floats[i]) {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 tile, so the dialog shows.
396 w.leaveFullscreen()397 w.leaveFullscreen()
397 }398 }
398 }399 }
@@ -408,22 +409,29 @@ func (w *Workspace) overCover(f Float) bool {
408 if c == 0 || f.ID == c {409 if c == 0 || f.ID == c {
409 return false410 return false
410 }411 }
411- over := w.stashOverCover()412+ owner := w.dialogOwner(f, c)
413+ return owner == c || owner != 0 && w.stashOverCover() && w.stashIndex(owner) >= 0
414+}
415+
416+// dialogOwner is the window float f is a dialog of, through dialogs of
417+// dialogs: the first parent that is no float, or stop. 0 when f is no
418+// dialog, its chain ends on a plain float, or it loops.
419+func (w *Workspace) dialogOwner(f Float, stop WindowID) WindowID {
412 // Each hop goes to another float: at most len(w.Floats) hops.420 // Each hop goes to another float: at most len(w.Floats) hops.
413 for range len(w.Floats) {421 for range len(w.Floats) {
414 if f.over == 0 || f.over == f.ID {422 if f.over == 0 || f.over == f.ID {
415- return false423+ return 0
416 }424 }
417- if f.over == c || over && w.stashIndex(f.over) >= 0 {425+ if f.over == stop {
418- return true426+ return stop
419 }427 }
420 i := w.floatIndex(f.over)428 i := w.floatIndex(f.over)
421 if i < 0 {429 if i < 0 {
422- return false430+ return f.over
423 }431 }
424 f = w.Floats[i]432 f = w.Floats[i]
425 }433 }
426- return false434+ return 0
427 }435 }
428 436
429 // coverDialog is the topmost dialog shown over the covering fullscreen437 // coverDialog is the topmost dialog shown over the covering fullscreen
@@ -1255,17 +1263,29 @@ func (w *Workspace) leaveFullscreen() {
1255 }1263 }
1256 1264
1257 // Activate focuses a window and makes it visible: it leaves another1265 // Activate focuses a window and makes it visible: it leaves another
1258-// window's fullscreen, which hides everything else. A stashed window1266+// window's fullscreen, which hides everything else. Over a covering
1259-// shows over a covering window instead, as ToggleStashVisible does: the1267+// window, a dialog drawn over it, a stashed window or a dialog of one
1260-// cover stays fullscreen behind, so a game never sees its fullscreen end.1268+// shows over it instead, as ToggleStashVisible does: the cover stays
1269+// fullscreen behind, so a game never sees its fullscreen end.
1261 func (w *Workspace) Activate(id WindowID) {1270 func (w *Workspace) Activate(id WindowID) {
1262- if i := w.stashIndex(id); i >= 0 && w.coverStays(id) {1271+ if c := w.cover(); c != 0 && id != c {
1263- if !w.stashOverCover() {1272+ owner := id
1264- w.stashOver, w.stashHidden = w.cover(), false1273+ if f := w.floatIndex(id); f >= 0 {
1274+ owner = w.dialogOwner(w.Floats[f], c)
1275+ }
1276+ if i := w.stashIndex(owner); i >= 0 && w.coverStays(owner) {
1277+ if !w.stashOverCover() {
1278+ w.showStashOver()
1279+ }
1280+ w.stashAt, w.stashFocus, w.floatFocus = i, true, false
1281+ }
1282+ if owner == c || w.stashOverCover() && w.stashIndex(owner) >= 0 {
1283+ if owner != id {
1284+ w.FocusID(id)
1285+ }
1286+ w.focusModal(0)
1287+ return
1265 }1288 }
1266- w.stashAt, w.stashFocus, w.floatFocus = i, true, false
1267- w.focusModal(0)
1268- return
1269 }1289 }
1270 if i := w.stashIndex(id); i >= 0 && w.stashHidden {1290 if i := w.stashIndex(id); i >= 0 && w.stashHidden {
1271 // A hidden stashed window must be seen: show the stash with it,1291 // A hidden stashed window must be seen: show the stash with it,
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 +178 -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,179 @@ 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+ view := w.View
232+ w.Activate(id)
233+ if got, _ := w.Focused(); got != tc.focused || w.cover() != cover || w.left != 0 || w.stashOverCover() != tc.over {
234+ t.Fatalf("focused %d (want %d) cover %d left %d over %v", got, tc.focused, w.cover(), w.left, w.stashOverCover())
235+ }
236+ ps := w.Layout()
237+ if p := previewOf(t, ps, cover); p.Hidden || !p.Fullscreen {
238+ t.Fatalf("cover %+v", p)
239+ }
240+ if f, c := slices.IndexFunc(ps, isID(tc.focused)), slices.IndexFunc(ps, isID(cover)); f < c || ps[f].Hidden {
241+ t.Fatalf("focused window not over the cover: %+v", ps)
242+ }
243+ if !cv.float && w.View != view {
244+ t.Fatalf("view %d, was %d", w.View, view)
245+ }
246+ })
247+ }
248+ }
249+}
250+
251+// After an activation shows the stash over the cover, hiding the stash
252+// or activating the cover returns to it, and a dialog of a stashed
253+// window mapping meanwhile shows with the focus.
254+func TestActivateOverCoverReturns(t *testing.T) {
255+ _, w, cover := stashCoverCore(t, OverflowFixed, true)
256+ w.Activate(2)
257+ w.ToggleStashVisible()
258+ if id, _ := w.Focused(); id != cover || w.stashShown() {
259+ t.Fatalf("hide: focused %d shown %v", id, w.stashShown())
260+ }
261+ w.Activate(2)
262+ w.Activate(cover)
263+ if id, _ := w.Focused(); id != cover || w.stashShown() || w.fullscreen != cover {
264+ t.Fatalf("activate cover: focused %d shown %v fullscreen %d", id, w.stashShown(), w.fullscreen)
265+ }
266+ w.Activate(2)
267+ w.AddDialog(9, 2, 20, 10)
268+ w.AddDialog(8, 9, 10, 5)
269+ if id, _ := w.Focused(); id != 8 || previewOf(t, w.Layout(), 8).Hidden {
270+ t.Fatalf("mapped chain: focused %d", id)
271+ }
272+ // Closing the dialogs returns to the stash over the cover.
273+ w.RemoveWindow(8)
274+ w.RemoveWindow(9)
275+ if id, _ := w.Focused(); id != 2 || !w.stashOverCover() {
276+ t.Fatalf("closed: focused %d over %v", id, w.stashOverCover())
277+ }
278+ // The cover closes: the stash stays shown and focused, the override
279+ // ends with it.
280+ w.RemoveWindow(cover)
281+ if id, _ := w.Focused(); id != 2 || w.stashOver != 0 || !w.stashShown() {
282+ t.Fatalf("cover closed: focused %d over %d shown %v", id, w.stashOver, w.stashShown())
283+ }
284+}
285+
286+// A dialog parent loop under a cover ends: neither dialog shows over it.
287+func TestOverCoverDialogLoop(t *testing.T) {
288+ _, w, _ := stashCoverCore(t, OverflowFixed, true)
289+ w.AddFloating(8, 10, 5)
290+ w.AddFloating(9, 10, 5)
291+ w.SetDialogParent(8, 9)
292+ w.SetDialogParent(9, 8)
293+ ps := w.Layout()
294+ if !previewOf(t, ps, 8).Hidden || !previewOf(t, ps, 9).Hidden {
295+ t.Fatalf("looped dialogs shown: %+v", ps)
296+ }
297+}
298+
299+// Through core: activating a stashed window keeps a fullscreen game's
300+// configure fullscreen at the output size.
301+func TestCoreActivateStashKeepsGameConfigure(t *testing.T) {
302+ cfg := ports.Config{}
303+ cfg.Keyboard.CmdKey = "super"
304+ cfg.Layout.MaxColumns = 2
305+ commands := make(chan ports.ClientCommand, 256)
306+ c, err := New(cfg, Channels{Commands: commands, Scenes: make(chan []ports.Scene, 1)}, Options{})
307+ if err != nil {
308+ t.Fatal(err)
309+ }
310+ c.addScreen(ports.OutputInfo{Name: "A", Width: 100, Height: 80})
311+ m := c.screens[0].mon
312+ m.AddWindow(2)
313+ w := m.Current()
314+ w.ToggleWindowStash()
315+ w.ToggleStashVisible()
316+ m.AddFloating(4, 100, 80)
317+ m.SetFullscreen(4, true)
318+ ctx := context.Background()
319+ if err := c.activate(ctx, 2); err != nil {
320+ t.Fatal(err)
321+ }
322+ if err := c.publish(ctx); err != nil {
323+ t.Fatal(err)
324+ }
325+ var last *ports.ConfigureWindow
326+ for len(commands) > 0 {
327+ if v, ok := (<-commands).(ports.ConfigureWindow); ok && v.ID == 4 {
328+ last = &v
329+ }
330+ }
331+ if last == nil || !last.Fullscreen || last.Width != 100 || last.Height != 80 {
332+ t.Fatalf("game configure %+v", last)
333+ }
334+}
335+
336+func isID(id WindowID) func(Placement) bool {
337+ return func(p Placement) bool { return p.ID == id }
338+}
@@ -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,179 @@ 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+ view := w.View
232+ w.Activate(id)
233+ if got, _ := w.Focused(); got != tc.focused || w.cover() != cover || w.left != 0 || w.stashOverCover() != tc.over {
234+ t.Fatalf("focused %d (want %d) cover %d left %d over %v", got, tc.focused, w.cover(), w.left, w.stashOverCover())
235+ }
236+ ps := w.Layout()
237+ if p := previewOf(t, ps, cover); p.Hidden || !p.Fullscreen {
238+ t.Fatalf("cover %+v", p)
239+ }
240+ if f, c := slices.IndexFunc(ps, isID(tc.focused)), slices.IndexFunc(ps, isID(cover)); f < c || ps[f].Hidden {
241+ t.Fatalf("focused window not over the cover: %+v", ps)
242+ }
243+ if !cv.float && w.View != view {
244+ t.Fatalf("view %d, was %d", w.View, view)
245+ }
246+ })
247+ }
248+ }
249+}
250+
251+// After an activation shows the stash over the cover, hiding the stash
252+// or activating the cover returns to it, and a dialog of a stashed
253+// window mapping meanwhile shows with the focus.
254+func TestActivateOverCoverReturns(t *testing.T) {
255+ _, w, cover := stashCoverCore(t, OverflowFixed, true)
256+ w.Activate(2)
257+ w.ToggleStashVisible()
258+ if id, _ := w.Focused(); id != cover || w.stashShown() {
259+ t.Fatalf("hide: focused %d shown %v", id, w.stashShown())
260+ }
261+ w.Activate(2)
262+ w.Activate(cover)
263+ if id, _ := w.Focused(); id != cover || w.stashShown() || w.fullscreen != cover {
264+ t.Fatalf("activate cover: focused %d shown %v fullscreen %d", id, w.stashShown(), w.fullscreen)
265+ }
266+ w.Activate(2)
267+ w.AddDialog(9, 2, 20, 10)
268+ w.AddDialog(8, 9, 10, 5)
269+ if id, _ := w.Focused(); id != 8 || previewOf(t, w.Layout(), 8).Hidden {
270+ t.Fatalf("mapped chain: focused %d", id)
271+ }
272+ // Closing the dialogs returns to the stash over the cover.
273+ w.RemoveWindow(8)
274+ w.RemoveWindow(9)
275+ if id, _ := w.Focused(); id != 2 || !w.stashOverCover() {
276+ t.Fatalf("closed: focused %d over %v", id, w.stashOverCover())
277+ }
278+ // The cover closes: the stash stays shown and focused, the override
279+ // ends with it.
280+ w.RemoveWindow(cover)
281+ if id, _ := w.Focused(); id != 2 || w.stashOver != 0 || !w.stashShown() {
282+ t.Fatalf("cover closed: focused %d over %d shown %v", id, w.stashOver, w.stashShown())
283+ }
284+}
285+
286+// A dialog parent loop under a cover ends: neither dialog shows over it.
287+func TestOverCoverDialogLoop(t *testing.T) {
288+ _, w, _ := stashCoverCore(t, OverflowFixed, true)
289+ w.AddFloating(8, 10, 5)
290+ w.AddFloating(9, 10, 5)
291+ w.SetDialogParent(8, 9)
292+ w.SetDialogParent(9, 8)
293+ ps := w.Layout()
294+ if !previewOf(t, ps, 8).Hidden || !previewOf(t, ps, 9).Hidden {
295+ t.Fatalf("looped dialogs shown: %+v", ps)
296+ }
297+}
298+
299+// Through core: activating a stashed window keeps a fullscreen game's
300+// configure fullscreen at the output size.
301+func TestCoreActivateStashKeepsGameConfigure(t *testing.T) {
302+ cfg := ports.Config{}
303+ cfg.Keyboard.CmdKey = "super"
304+ cfg.Layout.MaxColumns = 2
305+ commands := make(chan ports.ClientCommand, 256)
306+ c, err := New(cfg, Channels{Commands: commands, Scenes: make(chan []ports.Scene, 1)}, Options{})
307+ if err != nil {
308+ t.Fatal(err)
309+ }
310+ c.addScreen(ports.OutputInfo{Name: "A", Width: 100, Height: 80})
311+ m := c.screens[0].mon
312+ m.AddWindow(2)
313+ w := m.Current()
314+ w.ToggleWindowStash()
315+ w.ToggleStashVisible()
316+ m.AddFloating(4, 100, 80)
317+ m.SetFullscreen(4, true)
318+ ctx := context.Background()
319+ if err := c.activate(ctx, 2); err != nil {
320+ t.Fatal(err)
321+ }
322+ if err := c.publish(ctx); err != nil {
323+ t.Fatal(err)
324+ }
325+ var last *ports.ConfigureWindow
326+ for len(commands) > 0 {
327+ if v, ok := (<-commands).(ports.ConfigureWindow); ok && v.ID == 4 {
328+ last = &v
329+ }
330+ }
331+ if last == nil || !last.Fullscreen || last.Width != 100 || last.Height != 80 {
332+ t.Fatalf("game configure %+v", last)
333+ }
334+}
335+
336+func isID(id WindowID) func(Placement) bool {
337+ return func(p Placement) bool { return p.ID == id }
338+}

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close