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 Unverified
B5690EEEBB952194The 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 | |||
| 187 | 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 | 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 | 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 | 191 | | `toggle-overview` | Open the [overview](#overview), or close it on the selected window | | |
| 192 | 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 | 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 | | |
| @@ -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 { | |||
| 212 | 212 | // A covering fullscreen window is left for the window on that side; | |
| 213 | 213 | // with none (the workspace or stash edge), the move goes to the | |
| 214 | 214 | // neighbor monitor and fullscreen stays. | |
| 215 | - | if w.pinned() { | |
| 215 | + | if w.pinned() && !w.stashOverCover() { | |
| 216 | 216 | if !w.FocusColumn(dir) { | |
| 217 | 217 | if i := c.neighbor(d); i >= 0 { | |
| 218 | 218 | 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 the | 213 | // 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 = i | 218 | c.focusScreen = i | ||
M internal/core/layout.go +49 -6
| @@ -193,6 +193,10 @@ type Workspace struct { | |||
| 193 | 193 | // hiddenFullscreen holds a stashed window's fullscreen while the stash | |
| 194 | 194 | // is hidden, restored when shown. | |
| 195 | 195 | 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 | 200 | // home is the monitor (key or connector) the workspace belongs to; "" | |
| 197 | 201 | // means the one it is on. On another monitor it is a guest; homePos is | |
| 198 | 202 | // its position there, where it returns. | |
| @@ -395,6 +399,9 @@ func (w *Workspace) Focused() (WindowID, bool) { | |||
| 395 | 399 | if d := w.coverDialog(); d != 0 { | |
| 396 | 400 | return d, true | |
| 397 | 401 | } | |
| 402 | + | if w.stashOverCover() { | |
| 403 | + | return w.Stash[w.stashAt].ID, true | |
| 404 | + | } | |
| 398 | 405 | return full, true | |
| 399 | 406 | } | |
| 400 | 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 | 511 | if w.fullscreen == id { | |
| 505 | 512 | w.fullscreen = 0 | |
| 506 | 513 | } | |
| 514 | + | if w.stashOver == id { | |
| 515 | + | w.stashOver = 0 | |
| 516 | + | } | |
| 507 | 517 | if w.hiddenFullscreen == id { | |
| 508 | 518 | w.hiddenFullscreen = 0 | |
| 509 | 519 | } | |
| @@ -524,7 +534,7 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||
| 524 | 534 | w.stashAt = max(w.stashAt-1, 0) | |
| 525 | 535 | } | |
| 526 | 536 | if len(w.Stash) == 0 { | |
| 527 | - | w.stashFocus, w.stashHidden = false, false | |
| 537 | + | w.stashFocus, w.stashHidden, w.stashOver = false, false, 0 | |
| 528 | 538 | } | |
| 529 | 539 | return | |
| 530 | 540 | } | |
| @@ -566,6 +576,11 @@ func (w *Workspace) FocusID(id WindowID) bool { | |||
| 566 | 576 | f := w.Floats[i] | |
| 567 | 577 | f.below = false | |
| 568 | 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 | 584 | w.floatFocus = true | |
| 570 | 585 | return true | |
| 571 | 586 | } | |
| @@ -579,6 +594,7 @@ func (w *Workspace) FocusID(id WindowID) bool { | |||
| 579 | 594 | for i := range w.Columns { | |
| 580 | 595 | for j, v := range w.Columns[i].Windows { | |
| 581 | 596 | if v == id { | |
| 597 | + | w.dropStashOver() | |
| 582 | 598 | w.floatFocus, w.stashFocus = false, false | |
| 583 | 599 | w.raiseColumns() | |
| 584 | 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 | 619 | w.floatFocus = false | |
| 604 | 620 | return true | |
| 605 | 621 | } | |
| 606 | - | if w.pinned() { | |
| 622 | + | if w.pinned() && !w.stashOverCover() { | |
| 607 | 623 | return w.leaveCover(func() { w.focusColumn(dir) }) | |
| 608 | 624 | } | |
| 609 | 625 | w.focusColumn(dir) | |
| @@ -666,7 +682,7 @@ func (w *Workspace) FocusWindow(dir int) bool { | |||
| 666 | 682 | w.floatFocus = false | |
| 667 | 683 | return true | |
| 668 | 684 | } | |
| 669 | - | if w.pinned() { | |
| 685 | + | if w.pinned() && !w.stashOverCover() { | |
| 670 | 686 | return w.leaveCover(func() { w.focusWindow(dir) }) | |
| 671 | 687 | } | |
| 672 | 688 | return w.focusWindow(dir) | |
| @@ -880,6 +896,9 @@ func (w *Workspace) takeColumn() (Column, bool) { | |||
| 880 | 896 | if slices.Contains(col.Windows, w.fullscreen) { | |
| 881 | 897 | w.fullscreen = 0 | |
| 882 | 898 | } | |
| 899 | + | if slices.Contains(col.Windows, w.stashOver) { | |
| 900 | + | w.stashOver = 0 | |
| 901 | + | } | |
| 883 | 902 | if slices.Contains(col.Windows, w.left) { | |
| 884 | 903 | w.left = 0 | |
| 885 | 904 | } | |
| @@ -996,6 +1015,10 @@ func (w *Workspace) ToggleFullscreen() { | |||
| 996 | 1015 | if !ok { | |
| 997 | 1016 | return | |
| 998 | 1017 | } | |
| 1018 | + | if w.stashOverCover() { | |
| 1019 | + | // A stashed window the user picked over the cover replaces it. | |
| 1020 | + | w.leaveFullscreen() | |
| 1021 | + | } | |
| 999 | 1022 | if w.fullscreen == id { | |
| 1000 | 1023 | // The user was on it: it keeps the focus, in front of floats that | |
| 1001 | 1024 | // mapped meanwhile. Cleared first, so that floats it covers drop | |
| @@ -1012,7 +1035,7 @@ func (w *Workspace) ToggleFullscreen() { | |||
| 1012 | 1035 | // endFullscreen ends the fullscreen: floats it kept demoted are promoted | |
| 1013 | 1036 | // again and the view follows the focus. | |
| 1014 | 1037 | func (w *Workspace) endFullscreen() { | |
| 1015 | - | w.fullscreen = 0 | |
| 1038 | + | w.fullscreen, w.stashOver = 0, 0 | |
| 1016 | 1039 | w.reconcileFloats() | |
| 1017 | 1040 | w.scroll() | |
| 1018 | 1041 | } | |
| @@ -1070,6 +1093,10 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) { | |||
| 1070 | 1093 | } | |
| 1071 | 1094 | return | |
| 1072 | 1095 | } | |
| 1096 | + | if id != w.stashOver { | |
| 1097 | + | // Another window covers: the override was for the old cover. | |
| 1098 | + | w.dropStashOver() | |
| 1099 | + | } | |
| 1073 | 1100 | w.fullscreen = id | |
| 1074 | 1101 | if !w.isFloat(id) { | |
| 1075 | 1102 | w.scroll() | |
| @@ -1214,6 +1241,11 @@ func (w *Workspace) focusCover() { | |||
| 1214 | 1241 | w.FocusID(d) | |
| 1215 | 1242 | 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 | 1249 | if w.isFloat(c) { | |
| 1218 | 1250 | w.FocusID(c) | |
| 1219 | 1251 | return | |
| @@ -1534,8 +1566,15 @@ func (w *Workspace) Layout() []Placement { | |||
| 1534 | 1566 | } | |
| 1535 | 1567 | appendFloats(belowTiles) | |
| 1536 | 1568 | 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 | + | } | |
| 1539 | 1578 | appendFloats(dialogs) | |
| 1540 | 1579 | return result | |
| 1541 | 1580 | } | |
| @@ -1626,6 +1665,10 @@ func (w *Workspace) ToggleWindowStash() { | |||
| 1626 | 1665 | w.RemoveWindow(id) | |
| 1627 | 1666 | w.restore(id, nil) | |
| 1628 | 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 | 1672 | w.unstash(w.stashIndex(id)) | |
| 1630 | 1673 | default: | |
| 1631 | 1674 | w.stashWindow(id) | |
| @@ -193,6 +193,10 @@ type Workspace struct { | |||||
| 193 | // hiddenFullscreen holds a stashed window's fullscreen while the stash | 193 | // hiddenFullscreen holds a stashed window's fullscreen while the stash | ||
| 194 | // is hidden, restored when shown. | 194 | // is hidden, restored when shown. | ||
| 195 | hiddenFullscreen WindowID | 195 | 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 is | 201 | // 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, true | 400 | return d, true | ||
| 397 | } | 401 | } | ||
| 402 | + | if w.stashOverCover() { | |||
| 403 | + | return w.Stash[w.stashAt].ID, true | |||
| 404 | + | } | |||
| 398 | return full, true | 405 | 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 = 0 | 512 | 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 = 0 | 518 | 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, false | 537 | + | w.stashFocus, w.stashHidden, w.stashOver = false, false, 0 |
| 528 | } | 538 | } | ||
| 529 | return | 539 | 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 = false | 577 | 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 = true | 584 | w.floatFocus = true | ||
| 570 | return true | 585 | 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, false | 598 | 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 = false | 619 | w.floatFocus = false | ||
| 604 | return true | 620 | 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 = false | 682 | w.floatFocus = false | ||
| 667 | return true | 683 | 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 = 0 | 897 | 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 = 0 | 903 | w.left = 0 | ||
| 885 | } | 904 | } | ||
| @@ -996,6 +1015,10 @@ func (w *Workspace) ToggleFullscreen() { | |||||
| 996 | if !ok { | 1015 | if !ok { | ||
| 997 | return | 1016 | 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 that | 1023 | // The user was on it: it keeps the focus, in front of floats that | ||
| 1001 | // mapped meanwhile. Cleared first, so that floats it covers drop | 1024 | // 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 promoted | 1035 | // 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 = 0 | 1038 | + | 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 | return | 1094 | 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 = id | 1100 | 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 | return | 1242 | 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 | return | 1251 | 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 result | 1579 | 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 { | |||
| 114 | 114 | // holds its former neighbors, else a column of its own at its former | |
| 115 | 115 | // index. It takes the focus. | |
| 116 | 116 | func (w *Workspace) restore(id WindowID, back *origPlace) { | |
| 117 | + | w.dropStashOver() | |
| 117 | 118 | w.floatFocus, w.stashFocus = false, false | |
| 118 | 119 | w.raiseColumns() | |
| 119 | 120 | if back == nil { | |
| @@ -156,11 +157,25 @@ func (f Float) rehome() Float { | |||
| 156 | 157 | } | |
| 157 | 158 | ||
| 158 | 159 | // 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. | |
| 162 | 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 | 179 | return | |
| 165 | 180 | } | |
| 166 | 181 | if w.stashHidden { | |
| @@ -170,6 +185,21 @@ func (w *Workspace) ToggleStashVisible() { | |||
| 170 | 185 | 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 | 203 | // Click focuses the clicked window. A click on a tile behind the shown | |
| 174 | 204 | // stash hides the stash, as a click outside a menu closes it. | |
| 175 | 205 | func (w *Workspace) Click(id WindowID) { | |
| @@ -204,7 +234,7 @@ func (w *Workspace) stashLayout(focusedID, cover WindowID) []Placement { | |||
| 204 | 234 | switch { | |
| 205 | 235 | case f.ID == cover: | |
| 206 | 236 | 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 | 238 | p.Hidden = true | |
| 209 | 239 | case i == w.stashAt: | |
| 210 | 240 | 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 former | 114 | // 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, false | 118 | 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 the | 159 | // 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 | + | // focus. Native floats stay as they are. Over a covering fullscreen |
| 160 | - | // fullscreen window (a game in scanout): hiding it would drop scanout and | 161 | + | // 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 | return | 179 | 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, false | 185 | 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 shown | 203 | // 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, 0 | 236 | 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 = true | 238 | p.Hidden = true | ||
| 209 | case i == w.stashAt: | 239 | case i == w.stashAt: | ||
| 210 | p.Rect = center | 240 | 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 | + | } | |||