detached ff7108c

feat(stash): show the stash over a fullscreen window (#111)

* feat(stash): show the stash over a fullscreen window on toggle-stash-visible

* fix(stash): keep the stash override through binds, dialogs and cover changes

* fix(stash): keep the override through cover dialogs and end it on another cover
commit
ff7108c02c5557023cd7c63bcf49c42c1737973c
author
bnema · · 2026-10-03 18:05 UTC
committer
GitHub · · 2026-10-03 18:05 UTC
parent
db3364d
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
5 files · +246 -13
M docs/config.md +1 -1
@@ -187,7 +187,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
187187 | `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 |
188188 | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first |
189189 | `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 |
190-| `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window |
190+| `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 |
191191 | `toggle-overview` | Open the [overview](#overview), or close it on the selected window |
192192 | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column |
193193 | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time |
@@ -187,7 +187,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
187 | `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 |187 | `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 |
188 | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first |188 | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first |
189 | `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 |189 | `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 |
190-| `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window |190+| `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 |
191 | `toggle-overview` | Open the [overview](#overview), or close it on the selected window |191 | `toggle-overview` | Open the [overview](#overview), or close it on the selected window |
192 | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column |192 | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column |
193 | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time |193 | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time |
M internal/core/action.go +1 -1
@@ -212,7 +212,7 @@ func (c *Core) applyAction(a Action) Effect {
212212 // A covering fullscreen window is left for the window on that side;
213213 // with none (the workspace or stash edge), the move goes to the
214214 // neighbor monitor and fullscreen stays.
215- if w.pinned() {
215+ if w.pinned() && !w.stashOverCover() {
216216 if !w.FocusColumn(dir) {
217217 if i := c.neighbor(d); i >= 0 {
218218 c.focusScreen = i
@@ -212,7 +212,7 @@ func (c *Core) applyAction(a Action) Effect {
212 // A covering fullscreen window is left for the window on that side;212 // A covering fullscreen window is left for the window on that side;
213 // with none (the workspace or stash edge), the move goes to the213 // with none (the workspace or stash edge), the move goes to the
214 // neighbor monitor and fullscreen stays.214 // neighbor monitor and fullscreen stays.
215- if w.pinned() {215+ if w.pinned() && !w.stashOverCover() {
216 if !w.FocusColumn(dir) {216 if !w.FocusColumn(dir) {
217 if i := c.neighbor(d); i >= 0 {217 if i := c.neighbor(d); i >= 0 {
218 c.focusScreen = i218 c.focusScreen = i
M internal/core/layout.go +49 -6
@@ -193,6 +193,10 @@ type Workspace struct {
193193 // hiddenFullscreen holds a stashed window's fullscreen while the stash
194194 // is hidden, restored when shown.
195195 hiddenFullscreen WindowID
196+ // stashOver is the covering fullscreen window the stash shows over, a
197+ // user override (ToggleStashVisible); 0 without. Bound to that window,
198+ // it ends with its cover. Focusing another window ends it too.
199+ stashOver WindowID
196200 // home is the monitor (key or connector) the workspace belongs to; ""
197201 // means the one it is on. On another monitor it is a guest; homePos is
198202 // its position there, where it returns.
@@ -395,6 +399,9 @@ func (w *Workspace) Focused() (WindowID, bool) {
395399 if d := w.coverDialog(); d != 0 {
396400 return d, true
397401 }
402+ if w.stashOverCover() {
403+ return w.Stash[w.stashAt].ID, true
404+ }
398405 return full, true
399406 }
400407 if w.floatFocus && len(w.Floats) > 0 && !w.Floats[len(w.Floats)-1].below {
@@ -504,6 +511,9 @@ func (w *Workspace) RemoveWindow(id WindowID) {
504511 if w.fullscreen == id {
505512 w.fullscreen = 0
506513 }
514+ if w.stashOver == id {
515+ w.stashOver = 0
516+ }
507517 if w.hiddenFullscreen == id {
508518 w.hiddenFullscreen = 0
509519 }
@@ -524,7 +534,7 @@ func (w *Workspace) RemoveWindow(id WindowID) {
524534 w.stashAt = max(w.stashAt-1, 0)
525535 }
526536 if len(w.Stash) == 0 {
527- w.stashFocus, w.stashHidden = false, false
537+ w.stashFocus, w.stashHidden, w.stashOver = false, false, 0
528538 }
529539 return
530540 }
@@ -566,6 +576,11 @@ func (w *Workspace) FocusID(id WindowID) bool {
566576 f := w.Floats[i]
567577 f.below = false
568578 w.Floats = append(slices.Delete(w.Floats, i, i+1), f)
579+ if !f.coverDialog(w.cover()) {
580+ // A dialog of the cover shows over the stash; leaving it
581+ // returns there.
582+ w.dropStashOver()
583+ }
569584 w.floatFocus = true
570585 return true
571586 }
@@ -579,6 +594,7 @@ func (w *Workspace) FocusID(id WindowID) bool {
579594 for i := range w.Columns {
580595 for j, v := range w.Columns[i].Windows {
581596 if v == id {
597+ w.dropStashOver()
582598 w.floatFocus, w.stashFocus = false, false
583599 w.raiseColumns()
584600 if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) && !w.Columns[i].FullWidth {
@@ -603,7 +619,7 @@ func (w *Workspace) FocusColumn(dir int) bool {
603619 w.floatFocus = false
604620 return true
605621 }
606- if w.pinned() {
622+ if w.pinned() && !w.stashOverCover() {
607623 return w.leaveCover(func() { w.focusColumn(dir) })
608624 }
609625 w.focusColumn(dir)
@@ -666,7 +682,7 @@ func (w *Workspace) FocusWindow(dir int) bool {
666682 w.floatFocus = false
667683 return true
668684 }
669- if w.pinned() {
685+ if w.pinned() && !w.stashOverCover() {
670686 return w.leaveCover(func() { w.focusWindow(dir) })
671687 }
672688 return w.focusWindow(dir)
@@ -880,6 +896,9 @@ func (w *Workspace) takeColumn() (Column, bool) {
880896 if slices.Contains(col.Windows, w.fullscreen) {
881897 w.fullscreen = 0
882898 }
899+ if slices.Contains(col.Windows, w.stashOver) {
900+ w.stashOver = 0
901+ }
883902 if slices.Contains(col.Windows, w.left) {
884903 w.left = 0
885904 }
@@ -996,6 +1015,10 @@ func (w *Workspace) ToggleFullscreen() {
9961015 if !ok {
9971016 return
9981017 }
1018+ if w.stashOverCover() {
1019+ // A stashed window the user picked over the cover replaces it.
1020+ w.leaveFullscreen()
1021+ }
9991022 if w.fullscreen == id {
10001023 // The user was on it: it keeps the focus, in front of floats that
10011024 // mapped meanwhile. Cleared first, so that floats it covers drop
@@ -1012,7 +1035,7 @@ func (w *Workspace) ToggleFullscreen() {
10121035 // endFullscreen ends the fullscreen: floats it kept demoted are promoted
10131036 // again and the view follows the focus.
10141037 func (w *Workspace) endFullscreen() {
1015- w.fullscreen = 0
1038+ w.fullscreen, w.stashOver = 0, 0
10161039 w.reconcileFloats()
10171040 w.scroll()
10181041 }
@@ -1070,6 +1093,10 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) {
10701093 }
10711094 return
10721095 }
1096+ if id != w.stashOver {
1097+ // Another window covers: the override was for the old cover.
1098+ w.dropStashOver()
1099+ }
10731100 w.fullscreen = id
10741101 if !w.isFloat(id) {
10751102 w.scroll()
@@ -1214,6 +1241,11 @@ func (w *Workspace) focusCover() {
12141241 w.FocusID(d)
12151242 return
12161243 }
1244+ if w.stashOverCover() {
1245+ // The stash shown over it has the focus.
1246+ w.stashFocus, w.floatFocus = true, false
1247+ return
1248+ }
12171249 if w.isFloat(c) {
12181250 w.FocusID(c)
12191251 return
@@ -1534,8 +1566,15 @@ func (w *Workspace) Layout() []Placement {
15341566 }
15351567 appendFloats(belowTiles)
15361568 result = append(result, tiles...)
1537- result = append(result, w.stashLayout(focusedID, cover)...)
1538- appendFloats(coveringFloats)
1569+ if w.stashOverCover() {
1570+ // The stash the user showed over a covering window tops it, under
1571+ // the dialogs it may open meanwhile.
1572+ appendFloats(coveringFloats)
1573+ result = append(result, w.stashLayout(focusedID, cover)...)
1574+ } else {
1575+ result = append(result, w.stashLayout(focusedID, cover)...)
1576+ appendFloats(coveringFloats)
1577+ }
15391578 appendFloats(dialogs)
15401579 return result
15411580 }
@@ -1626,6 +1665,10 @@ func (w *Workspace) ToggleWindowStash() {
16261665 w.RemoveWindow(id)
16271666 w.restore(id, nil)
16281667 case w.stashIndex(id) >= 0:
1668+ if w.stashOverCover() {
1669+ // Its column would be hidden under the cover: leave it.
1670+ w.leaveFullscreen()
1671+ }
16291672 w.unstash(w.stashIndex(id))
16301673 default:
16311674 w.stashWindow(id)
@@ -193,6 +193,10 @@ type Workspace struct {
193 // hiddenFullscreen holds a stashed window's fullscreen while the stash193 // hiddenFullscreen holds a stashed window's fullscreen while the stash
194 // is hidden, restored when shown.194 // is hidden, restored when shown.
195 hiddenFullscreen WindowID195 hiddenFullscreen WindowID
196+ // stashOver is the covering fullscreen window the stash shows over, a
197+ // user override (ToggleStashVisible); 0 without. Bound to that window,
198+ // it ends with its cover. Focusing another window ends it too.
199+ stashOver WindowID
196 // home is the monitor (key or connector) the workspace belongs to; ""200 // home is the monitor (key or connector) the workspace belongs to; ""
197 // means the one it is on. On another monitor it is a guest; homePos is201 // means the one it is on. On another monitor it is a guest; homePos is
198 // its position there, where it returns.202 // its position there, where it returns.
@@ -395,6 +399,9 @@ func (w *Workspace) Focused() (WindowID, bool) {
395 if d := w.coverDialog(); d != 0 {399 if d := w.coverDialog(); d != 0 {
396 return d, true400 return d, true
397 }401 }
402+ if w.stashOverCover() {
403+ return w.Stash[w.stashAt].ID, true
404+ }
398 return full, true405 return full, true
399 }406 }
400 if w.floatFocus && len(w.Floats) > 0 && !w.Floats[len(w.Floats)-1].below {407 if w.floatFocus && len(w.Floats) > 0 && !w.Floats[len(w.Floats)-1].below {
@@ -504,6 +511,9 @@ func (w *Workspace) RemoveWindow(id WindowID) {
504 if w.fullscreen == id {511 if w.fullscreen == id {
505 w.fullscreen = 0512 w.fullscreen = 0
506 }513 }
514+ if w.stashOver == id {
515+ w.stashOver = 0
516+ }
507 if w.hiddenFullscreen == id {517 if w.hiddenFullscreen == id {
508 w.hiddenFullscreen = 0518 w.hiddenFullscreen = 0
509 }519 }
@@ -524,7 +534,7 @@ func (w *Workspace) RemoveWindow(id WindowID) {
524 w.stashAt = max(w.stashAt-1, 0)534 w.stashAt = max(w.stashAt-1, 0)
525 }535 }
526 if len(w.Stash) == 0 {536 if len(w.Stash) == 0 {
527- w.stashFocus, w.stashHidden = false, false537+ w.stashFocus, w.stashHidden, w.stashOver = false, false, 0
528 }538 }
529 return539 return
530 }540 }
@@ -566,6 +576,11 @@ func (w *Workspace) FocusID(id WindowID) bool {
566 f := w.Floats[i]576 f := w.Floats[i]
567 f.below = false577 f.below = false
568 w.Floats = append(slices.Delete(w.Floats, i, i+1), f)578 w.Floats = append(slices.Delete(w.Floats, i, i+1), f)
579+ if !f.coverDialog(w.cover()) {
580+ // A dialog of the cover shows over the stash; leaving it
581+ // returns there.
582+ w.dropStashOver()
583+ }
569 w.floatFocus = true584 w.floatFocus = true
570 return true585 return true
571 }586 }
@@ -579,6 +594,7 @@ func (w *Workspace) FocusID(id WindowID) bool {
579 for i := range w.Columns {594 for i := range w.Columns {
580 for j, v := range w.Columns[i].Windows {595 for j, v := range w.Columns[i].Windows {
581 if v == id {596 if v == id {
597+ w.dropStashOver()
582 w.floatFocus, w.stashFocus = false, false598 w.floatFocus, w.stashFocus = false, false
583 w.raiseColumns()599 w.raiseColumns()
584 if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) && !w.Columns[i].FullWidth {600 if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) && !w.Columns[i].FullWidth {
@@ -603,7 +619,7 @@ func (w *Workspace) FocusColumn(dir int) bool {
603 w.floatFocus = false619 w.floatFocus = false
604 return true620 return true
605 }621 }
606- if w.pinned() {622+ if w.pinned() && !w.stashOverCover() {
607 return w.leaveCover(func() { w.focusColumn(dir) })623 return w.leaveCover(func() { w.focusColumn(dir) })
608 }624 }
609 w.focusColumn(dir)625 w.focusColumn(dir)
@@ -666,7 +682,7 @@ func (w *Workspace) FocusWindow(dir int) bool {
666 w.floatFocus = false682 w.floatFocus = false
667 return true683 return true
668 }684 }
669- if w.pinned() {685+ if w.pinned() && !w.stashOverCover() {
670 return w.leaveCover(func() { w.focusWindow(dir) })686 return w.leaveCover(func() { w.focusWindow(dir) })
671 }687 }
672 return w.focusWindow(dir)688 return w.focusWindow(dir)
@@ -880,6 +896,9 @@ func (w *Workspace) takeColumn() (Column, bool) {
880 if slices.Contains(col.Windows, w.fullscreen) {896 if slices.Contains(col.Windows, w.fullscreen) {
881 w.fullscreen = 0897 w.fullscreen = 0
882 }898 }
899+ if slices.Contains(col.Windows, w.stashOver) {
900+ w.stashOver = 0
901+ }
883 if slices.Contains(col.Windows, w.left) {902 if slices.Contains(col.Windows, w.left) {
884 w.left = 0903 w.left = 0
885 }904 }
@@ -996,6 +1015,10 @@ func (w *Workspace) ToggleFullscreen() {
996 if !ok {1015 if !ok {
997 return1016 return
998 }1017 }
1018+ if w.stashOverCover() {
1019+ // A stashed window the user picked over the cover replaces it.
1020+ w.leaveFullscreen()
1021+ }
999 if w.fullscreen == id {1022 if w.fullscreen == id {
1000 // The user was on it: it keeps the focus, in front of floats that1023 // The user was on it: it keeps the focus, in front of floats that
1001 // mapped meanwhile. Cleared first, so that floats it covers drop1024 // mapped meanwhile. Cleared first, so that floats it covers drop
@@ -1012,7 +1035,7 @@ func (w *Workspace) ToggleFullscreen() {
1012 // endFullscreen ends the fullscreen: floats it kept demoted are promoted1035 // endFullscreen ends the fullscreen: floats it kept demoted are promoted
1013 // again and the view follows the focus.1036 // again and the view follows the focus.
1014 func (w *Workspace) endFullscreen() {1037 func (w *Workspace) endFullscreen() {
1015- w.fullscreen = 01038+ w.fullscreen, w.stashOver = 0, 0
1016 w.reconcileFloats()1039 w.reconcileFloats()
1017 w.scroll()1040 w.scroll()
1018 }1041 }
@@ -1070,6 +1093,10 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) {
1070 }1093 }
1071 return1094 return
1072 }1095 }
1096+ if id != w.stashOver {
1097+ // Another window covers: the override was for the old cover.
1098+ w.dropStashOver()
1099+ }
1073 w.fullscreen = id1100 w.fullscreen = id
1074 if !w.isFloat(id) {1101 if !w.isFloat(id) {
1075 w.scroll()1102 w.scroll()
@@ -1214,6 +1241,11 @@ func (w *Workspace) focusCover() {
1214 w.FocusID(d)1241 w.FocusID(d)
1215 return1242 return
1216 }1243 }
1244+ if w.stashOverCover() {
1245+ // The stash shown over it has the focus.
1246+ w.stashFocus, w.floatFocus = true, false
1247+ return
1248+ }
1217 if w.isFloat(c) {1249 if w.isFloat(c) {
1218 w.FocusID(c)1250 w.FocusID(c)
1219 return1251 return
@@ -1534,8 +1566,15 @@ func (w *Workspace) Layout() []Placement {
1534 }1566 }
1535 appendFloats(belowTiles)1567 appendFloats(belowTiles)
1536 result = append(result, tiles...)1568 result = append(result, tiles...)
1537- result = append(result, w.stashLayout(focusedID, cover)...)1569+ if w.stashOverCover() {
1538- appendFloats(coveringFloats)1570+ // The stash the user showed over a covering window tops it, under
1571+ // the dialogs it may open meanwhile.
1572+ appendFloats(coveringFloats)
1573+ result = append(result, w.stashLayout(focusedID, cover)...)
1574+ } else {
1575+ result = append(result, w.stashLayout(focusedID, cover)...)
1576+ appendFloats(coveringFloats)
1577+ }
1539 appendFloats(dialogs)1578 appendFloats(dialogs)
1540 return result1579 return result
1541 }1580 }
@@ -1626,6 +1665,10 @@ func (w *Workspace) ToggleWindowStash() {
1626 w.RemoveWindow(id)1665 w.RemoveWindow(id)
1627 w.restore(id, nil)1666 w.restore(id, nil)
1628 case w.stashIndex(id) >= 0:1667 case w.stashIndex(id) >= 0:
1668+ if w.stashOverCover() {
1669+ // Its column would be hidden under the cover: leave it.
1670+ w.leaveFullscreen()
1671+ }
1629 w.unstash(w.stashIndex(id))1672 w.unstash(w.stashIndex(id))
1630 default:1673 default:
1631 w.stashWindow(id)1674 w.stashWindow(id)
M internal/core/stash.go +35 -5
@@ -114,6 +114,7 @@ func (p origPlace) holdsStack(c Column) bool {
114114 // holds its former neighbors, else a column of its own at its former
115115 // index. It takes the focus.
116116 func (w *Workspace) restore(id WindowID, back *origPlace) {
117+ w.dropStashOver()
117118 w.floatFocus, w.stashFocus = false, false
118119 w.raiseColumns()
119120 if back == nil {
@@ -156,11 +157,25 @@ func (f Float) rehome() Float {
156157 }
157158
158159 // ToggleStashVisible hides the stash, or shows it and gives it the
159-// focus. Native floats stay as they are. It does nothing under a covering
160-// fullscreen window (a game in scanout): hiding it would drop scanout and
161-// VRR.
160+// focus. Native floats stay as they are. Over a covering fullscreen
161+// window it is a user override: the stash shows over it, which stays
162+// fullscreen behind (composed, no scanout) and returns when the stash
163+// hides. A stashed fullscreen window is the stash on screen: no-op.
162164 func (w *Workspace) ToggleStashVisible() {
163- if len(w.Stash) == 0 || w.cover() != 0 {
165+ if len(w.Stash) == 0 {
166+ return
167+ }
168+ if c := w.cover(); c != 0 {
169+ if w.stashIndex(c) >= 0 {
170+ return
171+ }
172+ if w.stashOverCover() {
173+ w.dropStashOver()
174+ return
175+ }
176+ // Not showStash: a pending stashed fullscreen must not replace
177+ // the covering window.
178+ w.stashOver, w.stashHidden, w.stashFocus, w.floatFocus = c, false, true, false
164179 return
165180 }
166181 if w.stashHidden {
@@ -170,6 +185,21 @@ func (w *Workspace) ToggleStashVisible() {
170185 w.stashHidden, w.stashFocus = true, false
171186 }
172187
188+// stashOverCover reports whether the stash shows over the covering
189+// fullscreen window (ToggleStashVisible).
190+func (w *Workspace) stashOverCover() bool {
191+ return w.stashOver != 0 && w.stashOver == w.cover() && !w.stashHidden && len(w.Stash) > 0
192+}
193+
194+// dropStashOver ends the stash override when the focus leaves it: the
195+// stash hides again under the covering window, as it was.
196+func (w *Workspace) dropStashOver() {
197+ if w.stashOverCover() {
198+ w.stashHidden, w.stashFocus = true, false
199+ }
200+ w.stashOver = 0
201+}
202+
173203 // Click focuses the clicked window. A click on a tile behind the shown
174204 // stash hides the stash, as a click outside a menu closes it.
175205 func (w *Workspace) Click(id WindowID) {
@@ -204,7 +234,7 @@ func (w *Workspace) stashLayout(focusedID, cover WindowID) []Placement {
204234 switch {
205235 case f.ID == cover:
206236 p.Rect, p.Fullscreen, p.Inset = w.Output, true, 0
207- case w.stashHidden || cover != 0:
237+ case w.stashHidden || cover != 0 && !w.stashOverCover():
208238 p.Hidden = true
209239 case i == w.stashAt:
210240 p.Rect = center
@@ -114,6 +114,7 @@ func (p origPlace) holdsStack(c Column) bool {
114 // holds its former neighbors, else a column of its own at its former114 // holds its former neighbors, else a column of its own at its former
115 // index. It takes the focus.115 // index. It takes the focus.
116 func (w *Workspace) restore(id WindowID, back *origPlace) {116 func (w *Workspace) restore(id WindowID, back *origPlace) {
117+ w.dropStashOver()
117 w.floatFocus, w.stashFocus = false, false118 w.floatFocus, w.stashFocus = false, false
118 w.raiseColumns()119 w.raiseColumns()
119 if back == nil {120 if back == nil {
@@ -156,11 +157,25 @@ func (f Float) rehome() Float {
156 }157 }
157 158
158 // ToggleStashVisible hides the stash, or shows it and gives it the159 // ToggleStashVisible hides the stash, or shows it and gives it the
159-// focus. Native floats stay as they are. It does nothing under a covering160+// focus. Native floats stay as they are. Over a covering fullscreen
160-// fullscreen window (a game in scanout): hiding it would drop scanout and161+// window it is a user override: the stash shows over it, which stays
161-// VRR.162+// fullscreen behind (composed, no scanout) and returns when the stash
163+// hides. A stashed fullscreen window is the stash on screen: no-op.
162 func (w *Workspace) ToggleStashVisible() {164 func (w *Workspace) ToggleStashVisible() {
163- if len(w.Stash) == 0 || w.cover() != 0 {165+ if len(w.Stash) == 0 {
166+ return
167+ }
168+ if c := w.cover(); c != 0 {
169+ if w.stashIndex(c) >= 0 {
170+ return
171+ }
172+ if w.stashOverCover() {
173+ w.dropStashOver()
174+ return
175+ }
176+ // Not showStash: a pending stashed fullscreen must not replace
177+ // the covering window.
178+ w.stashOver, w.stashHidden, w.stashFocus, w.floatFocus = c, false, true, false
164 return179 return
165 }180 }
166 if w.stashHidden {181 if w.stashHidden {
@@ -170,6 +185,21 @@ func (w *Workspace) ToggleStashVisible() {
170 w.stashHidden, w.stashFocus = true, false185 w.stashHidden, w.stashFocus = true, false
171 }186 }
172 187
188+// stashOverCover reports whether the stash shows over the covering
189+// fullscreen window (ToggleStashVisible).
190+func (w *Workspace) stashOverCover() bool {
191+ return w.stashOver != 0 && w.stashOver == w.cover() && !w.stashHidden && len(w.Stash) > 0
192+}
193+
194+// dropStashOver ends the stash override when the focus leaves it: the
195+// stash hides again under the covering window, as it was.
196+func (w *Workspace) dropStashOver() {
197+ if w.stashOverCover() {
198+ w.stashHidden, w.stashFocus = true, false
199+ }
200+ w.stashOver = 0
201+}
202+
173 // Click focuses the clicked window. A click on a tile behind the shown203 // Click focuses the clicked window. A click on a tile behind the shown
174 // stash hides the stash, as a click outside a menu closes it.204 // stash hides the stash, as a click outside a menu closes it.
175 func (w *Workspace) Click(id WindowID) {205 func (w *Workspace) Click(id WindowID) {
@@ -204,7 +234,7 @@ func (w *Workspace) stashLayout(focusedID, cover WindowID) []Placement {
204 switch {234 switch {
205 case f.ID == cover:235 case f.ID == cover:
206 p.Rect, p.Fullscreen, p.Inset = w.Output, true, 0236 p.Rect, p.Fullscreen, p.Inset = w.Output, true, 0
207- case w.stashHidden || cover != 0:237+ case w.stashHidden || cover != 0 && !w.stashOverCover():
208 p.Hidden = true238 p.Hidden = true
209 case i == w.stashAt:239 case i == w.stashAt:
210 p.Rect = center240 p.Rect = center
A internal/core/stash_cover_test.go +160 -0
@@ -0,0 +1,160 @@
1+package core
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// stashCoverCore has two screens; the first holds tile 1 and the hidden
10+// stash [2 3], 3 selected, under a covering fullscreen window: tile 1, or
11+// float 4 with float set.
12+func stashCoverCore(t *testing.T, overflow Overflow, float bool) (*Core, *Workspace, WindowID) {
13+ t.Helper()
14+ cfg := ports.Config{}
15+ cfg.Keyboard.CmdKey = "super"
16+ cfg.Layout.Overflow = string(overflow)
17+ cfg.Layout.MaxColumns = 2
18+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)})
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ c.addScreen(ports.OutputInfo{Name: "A", Width: 100, Height: 80})
23+ c.addScreen(ports.OutputInfo{Name: "B", Width: 100, Height: 80})
24+ m := c.screens[0].mon
25+ for id := WindowID(1); id <= 3; id++ {
26+ m.AddWindow(id)
27+ }
28+ w := m.Current()
29+ for _, id := range []WindowID{2, 3} {
30+ w.FocusID(id)
31+ w.ToggleWindowStash()
32+ }
33+ c.applyAction(ActionToggleStashVisible) // hidden
34+ cover := WindowID(1)
35+ if float {
36+ cover = 4
37+ m.AddFloating(4, 100, 80)
38+ m.SetFullscreen(4, true)
39+ } else {
40+ c.applyAction(ActionToggleFullscreen)
41+ }
42+ if w.cover() != cover || c.focusScreen != 0 {
43+ t.Fatalf("setup: cover %d", w.cover())
44+ }
45+ return c, w, cover
46+}
47+
48+// toggle-stash-visible is a user override: under any covering fullscreen
49+// window it shows the stash on top, focused, the window fullscreen
50+// behind. Moves stay in the stash, at its edges too. Toggling again
51+// hides it, back on the fullscreen window.
52+func TestStashShowsOverCover(t *testing.T) {
53+ for _, tc := range []struct {
54+ name string
55+ overflow Overflow
56+ float bool
57+ }{
58+ {"fixed tile", OverflowFixed, false},
59+ {"scroll tile", OverflowScroll, false},
60+ {"fixed float", OverflowFixed, true},
61+ {"scroll float", OverflowScroll, true},
62+ } {
63+ t.Run(tc.name, func(t *testing.T) {
64+ c, w, cover := stashCoverCore(t, tc.overflow, tc.float)
65+ c.applyAction(ActionToggleStashVisible)
66+ ps := w.Layout()
67+ if id, _ := w.Focused(); id != 3 || w.cover() != cover {
68+ t.Fatalf("focused %d cover %d", id, w.cover())
69+ }
70+ if p := previewOf(t, ps, cover); p.Hidden || !p.Fullscreen {
71+ t.Fatalf("cover %+v", p)
72+ }
73+ if p := previewOf(t, ps, 3); p.Hidden || ps[len(ps)-1].ID != 3 && ps[len(ps)-2].ID != 3 {
74+ t.Fatalf("stash not on top: %+v", ps)
75+ }
76+ c.applyAction(ActionFocusColumnLeft)
77+ if id, _ := w.Focused(); id != 2 || w.cover() != cover {
78+ t.Fatalf("left: focused %d cover %d", id, w.cover())
79+ }
80+ c.applyAction(ActionFocusColumnRight)
81+ c.applyAction(ActionFocusColumnRight) // stash edge: stays, like a shown stash
82+ if id, _ := w.Focused(); id != 3 || c.focusScreen != 0 {
83+ t.Fatalf("edge: focused %d screen %d", id, c.focusScreen)
84+ }
85+ c.applyAction(ActionToggleStashVisible)
86+ if id, _ := w.Focused(); id != cover || w.cover() != cover || !previewOf(t, w.Layout(), 3).Hidden {
87+ t.Fatalf("hide: focused %d cover %d", id, w.cover())
88+ }
89+ })
90+ }
91+}
92+
93+// Only the user shows the stash over the cover: a window mapping
94+// meanwhile is not captured, a dialog of the cover shows above the
95+// stash, and the override ends with its cover.
96+func TestStashOverCoverEdges(t *testing.T) {
97+ c, w, cover := stashCoverCore(t, OverflowFixed, false)
98+ m := c.screens[0].mon
99+ m.SetStashCapture(true)
100+ c.applyAction(ActionToggleStashVisible)
101+ m.AddWindow(5)
102+ if w.stashIndex(5) >= 0 || !previewOf(t, w.Layout(), 5).Hidden {
103+ t.Fatalf("new window shown: %+v", w.Layout())
104+ }
105+ w.AddDialog(6, cover, 20, 20)
106+ if ps := w.Layout(); ps[len(ps)-1].ID != 6 {
107+ t.Fatalf("dialog under the stash: %+v", ps)
108+ }
109+ if id, _ := w.Focused(); id != 6 {
110+ t.Fatalf("dialog focus %d", id)
111+ }
112+ c.applyAction(ActionFocusColumnLeft) // back to the stash
113+ if id, _ := w.Focused(); id != 3 || !w.stashOverCover() {
114+ t.Fatalf("from dialog: focused %d", id)
115+ }
116+ w.RemoveWindow(6)
117+ // The cover closes: the stash it showed over is a plain shown stash;
118+ // a later fullscreen window is never covered by it.
119+ w.RemoveWindow(cover)
120+ if w.stashOver != 0 {
121+ t.Fatal("override outlived its cover")
122+ }
123+ w.FocusID(5)
124+ w.ToggleFullscreen()
125+ if p := previewOf(t, w.Layout(), 3); !p.Hidden {
126+ t.Fatalf("stash over a new cover: %+v", p)
127+ }
128+}
129+
130+// Another window's fullscreen request ends the override: the old cover
131+// asking again never brings the stash back over it.
132+func TestStashOverCoverOtherRequest(t *testing.T) {
133+ c, w, cover := stashCoverCore(t, OverflowScroll, false)
134+ m := c.screens[0].mon
135+ m.AddWindow(5)
136+ c.applyAction(ActionToggleStashVisible)
137+ m.SetFullscreen(5, true)
138+ m.SetFullscreen(cover, true)
139+ if w.cover() != cover || w.stashOverCover() {
140+ t.Fatalf("cover %d, override revived", w.cover())
141+ }
142+}
143+
144+// Acting on a stashed window over the cover leaves the cover for it:
145+// toggle-window-stash returns it to a visible column, toggle-fullscreen
146+// makes it the fullscreen window.
147+func TestStashOverCoverLeaves(t *testing.T) {
148+ c, w, _ := stashCoverCore(t, OverflowFixed, false)
149+ c.applyAction(ActionToggleStashVisible)
150+ c.applyAction(ActionToggleWindowStash)
151+ if id, _ := w.Focused(); id != 3 || w.fullscreen != 0 || previewOf(t, w.Layout(), 3).Hidden {
152+ t.Fatalf("unstash: focused %d fullscreen %d", id, w.fullscreen)
153+ }
154+ c, w, _ = stashCoverCore(t, OverflowFixed, false)
155+ c.applyAction(ActionToggleStashVisible)
156+ c.applyAction(ActionToggleFullscreen)
157+ if w.cover() != 3 || w.left != 1 {
158+ t.Fatalf("fullscreen: cover %d left %d", w.cover(), w.left)
159+ }
160+}
@@ -0,0 +1,160 @@
1+package core
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// stashCoverCore has two screens; the first holds tile 1 and the hidden
10+// stash [2 3], 3 selected, under a covering fullscreen window: tile 1, or
11+// float 4 with float set.
12+func stashCoverCore(t *testing.T, overflow Overflow, float bool) (*Core, *Workspace, WindowID) {
13+ t.Helper()
14+ cfg := ports.Config{}
15+ cfg.Keyboard.CmdKey = "super"
16+ cfg.Layout.Overflow = string(overflow)
17+ cfg.Layout.MaxColumns = 2
18+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)})
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ c.addScreen(ports.OutputInfo{Name: "A", Width: 100, Height: 80})
23+ c.addScreen(ports.OutputInfo{Name: "B", Width: 100, Height: 80})
24+ m := c.screens[0].mon
25+ for id := WindowID(1); id <= 3; id++ {
26+ m.AddWindow(id)
27+ }
28+ w := m.Current()
29+ for _, id := range []WindowID{2, 3} {
30+ w.FocusID(id)
31+ w.ToggleWindowStash()
32+ }
33+ c.applyAction(ActionToggleStashVisible) // hidden
34+ cover := WindowID(1)
35+ if float {
36+ cover = 4
37+ m.AddFloating(4, 100, 80)
38+ m.SetFullscreen(4, true)
39+ } else {
40+ c.applyAction(ActionToggleFullscreen)
41+ }
42+ if w.cover() != cover || c.focusScreen != 0 {
43+ t.Fatalf("setup: cover %d", w.cover())
44+ }
45+ return c, w, cover
46+}
47+
48+// toggle-stash-visible is a user override: under any covering fullscreen
49+// window it shows the stash on top, focused, the window fullscreen
50+// behind. Moves stay in the stash, at its edges too. Toggling again
51+// hides it, back on the fullscreen window.
52+func TestStashShowsOverCover(t *testing.T) {
53+ for _, tc := range []struct {
54+ name string
55+ overflow Overflow
56+ float bool
57+ }{
58+ {"fixed tile", OverflowFixed, false},
59+ {"scroll tile", OverflowScroll, false},
60+ {"fixed float", OverflowFixed, true},
61+ {"scroll float", OverflowScroll, true},
62+ } {
63+ t.Run(tc.name, func(t *testing.T) {
64+ c, w, cover := stashCoverCore(t, tc.overflow, tc.float)
65+ c.applyAction(ActionToggleStashVisible)
66+ ps := w.Layout()
67+ if id, _ := w.Focused(); id != 3 || w.cover() != cover {
68+ t.Fatalf("focused %d cover %d", id, w.cover())
69+ }
70+ if p := previewOf(t, ps, cover); p.Hidden || !p.Fullscreen {
71+ t.Fatalf("cover %+v", p)
72+ }
73+ if p := previewOf(t, ps, 3); p.Hidden || ps[len(ps)-1].ID != 3 && ps[len(ps)-2].ID != 3 {
74+ t.Fatalf("stash not on top: %+v", ps)
75+ }
76+ c.applyAction(ActionFocusColumnLeft)
77+ if id, _ := w.Focused(); id != 2 || w.cover() != cover {
78+ t.Fatalf("left: focused %d cover %d", id, w.cover())
79+ }
80+ c.applyAction(ActionFocusColumnRight)
81+ c.applyAction(ActionFocusColumnRight) // stash edge: stays, like a shown stash
82+ if id, _ := w.Focused(); id != 3 || c.focusScreen != 0 {
83+ t.Fatalf("edge: focused %d screen %d", id, c.focusScreen)
84+ }
85+ c.applyAction(ActionToggleStashVisible)
86+ if id, _ := w.Focused(); id != cover || w.cover() != cover || !previewOf(t, w.Layout(), 3).Hidden {
87+ t.Fatalf("hide: focused %d cover %d", id, w.cover())
88+ }
89+ })
90+ }
91+}
92+
93+// Only the user shows the stash over the cover: a window mapping
94+// meanwhile is not captured, a dialog of the cover shows above the
95+// stash, and the override ends with its cover.
96+func TestStashOverCoverEdges(t *testing.T) {
97+ c, w, cover := stashCoverCore(t, OverflowFixed, false)
98+ m := c.screens[0].mon
99+ m.SetStashCapture(true)
100+ c.applyAction(ActionToggleStashVisible)
101+ m.AddWindow(5)
102+ if w.stashIndex(5) >= 0 || !previewOf(t, w.Layout(), 5).Hidden {
103+ t.Fatalf("new window shown: %+v", w.Layout())
104+ }
105+ w.AddDialog(6, cover, 20, 20)
106+ if ps := w.Layout(); ps[len(ps)-1].ID != 6 {
107+ t.Fatalf("dialog under the stash: %+v", ps)
108+ }
109+ if id, _ := w.Focused(); id != 6 {
110+ t.Fatalf("dialog focus %d", id)
111+ }
112+ c.applyAction(ActionFocusColumnLeft) // back to the stash
113+ if id, _ := w.Focused(); id != 3 || !w.stashOverCover() {
114+ t.Fatalf("from dialog: focused %d", id)
115+ }
116+ w.RemoveWindow(6)
117+ // The cover closes: the stash it showed over is a plain shown stash;
118+ // a later fullscreen window is never covered by it.
119+ w.RemoveWindow(cover)
120+ if w.stashOver != 0 {
121+ t.Fatal("override outlived its cover")
122+ }
123+ w.FocusID(5)
124+ w.ToggleFullscreen()
125+ if p := previewOf(t, w.Layout(), 3); !p.Hidden {
126+ t.Fatalf("stash over a new cover: %+v", p)
127+ }
128+}
129+
130+// Another window's fullscreen request ends the override: the old cover
131+// asking again never brings the stash back over it.
132+func TestStashOverCoverOtherRequest(t *testing.T) {
133+ c, w, cover := stashCoverCore(t, OverflowScroll, false)
134+ m := c.screens[0].mon
135+ m.AddWindow(5)
136+ c.applyAction(ActionToggleStashVisible)
137+ m.SetFullscreen(5, true)
138+ m.SetFullscreen(cover, true)
139+ if w.cover() != cover || w.stashOverCover() {
140+ t.Fatalf("cover %d, override revived", w.cover())
141+ }
142+}
143+
144+// Acting on a stashed window over the cover leaves the cover for it:
145+// toggle-window-stash returns it to a visible column, toggle-fullscreen
146+// makes it the fullscreen window.
147+func TestStashOverCoverLeaves(t *testing.T) {
148+ c, w, _ := stashCoverCore(t, OverflowFixed, false)
149+ c.applyAction(ActionToggleStashVisible)
150+ c.applyAction(ActionToggleWindowStash)
151+ if id, _ := w.Focused(); id != 3 || w.fullscreen != 0 || previewOf(t, w.Layout(), 3).Hidden {
152+ t.Fatalf("unstash: focused %d fullscreen %d", id, w.fullscreen)
153+ }
154+ c, w, _ = stashCoverCore(t, OverflowFixed, false)
155+ c.applyAction(ActionToggleStashVisible)
156+ c.applyAction(ActionToggleFullscreen)
157+ if w.cover() != 3 || w.left != 1 {
158+ t.Fatalf("fullscreen: cover %d left %d", w.cover(), w.left)
159+ }
160+}

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