Merge pull request 'feat(overview): present cascade bands as dimmed lanes' (#13) from feat/overview-cascade into main
9 files · +1512 -136
M docs/config.md +9 -5
| @@ -196,7 +196,11 @@ Left/right focus follows column order across band boundaries. Up/down visits sta | |||
| 196 | 196 | ||
| 197 | 197 | Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour. | |
| 198 | 198 | ||
| 199 | - | The overview presents bands side by side in one workspace card. Selecting a window reveals its band. Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag. | |
| 199 | + | In the [overview](#overview), the selected band is centred at the usual overview zoom, whatever the band count. The other bands of the workspace sit above and below it, dimmed, and may run off screen. Neighbor workspaces sit left and right, fully dimmed, with their selected row level with the centred band. The stash pile stays left of the band, inside the workspace's slot. | |
| 200 | + | ||
| 201 | + | In the overview, up/down move to the previous or next band and stop at the first and last; with a stash card selected they browse the pile. Left/right move along the band; past its first column they enter the stash, or go to the previous workspace when there is none; past its last column they go to the next workspace (with the overview closed, focus crosses into the next band instead). Covering floats fan left and right of the band: left/right traverse them before entering the stash or changing workspace, and up/down do nothing on a float. `h`/`j`/`k`/`l` and the focus binds follow the arrows; `focus-workspace-prev/next` always change workspace, except over a selected stash card, where they browse the pile. Three-finger swipes follow the fingers: vertical moves one band (or one stash card) and horizontal one workspace per gesture, even over a stash card. A vertical wheel or continuous scroll changes workspace, except over a selected stash card, where it browses the pile; a horizontal one moves along the band. | |
| 202 | + | ||
| 203 | + | Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag. | |
| 200 | 204 | ||
| 201 | 205 | ### Actions | |
| 202 | 206 | ||
| @@ -257,7 +261,7 @@ The drop can be on another monitor; focus follows the window. There, the middle | |||
| 257 | 261 | ||
| 258 | 262 | ### Overview | |
| 259 | 263 | ||
| 260 | - | `toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows, over a background darkened by `floating.dim`. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Each monitor has one vertical overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with horizontal rules separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear above and below; rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal previous/next workspace navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen. | |
| 264 | + | `toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows, over a background darkened by `floating.dim`. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, and in a vertical overview up to two cards behind peek above it and passed cards peek below (a Cascade overview fans them left and right). Neighbor workspaces show dimmed stacks. Each monitor has one overview, laid out along the navigation axis of the current workspace's layout: neighbors appear above and below a `scroll` or `fixed` workspace and left and right of a `cascade` one (see [Cascade](#cascade)); stepping to a workspace with another layout turns the overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with rules across the axis (horizontal in a vertical overview, vertical in a side-by-side one) separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear beside it along that axis; in a vertical overview, rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal previous/next workspace navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen. | |
| 261 | 265 | ||
| 262 | 266 | | Key | Action | | |
| 263 | 267 | | --- | --- | | |
| @@ -266,11 +270,11 @@ The drop can be on another monitor; focus follows the window. There, the middle | |||
| 266 | 270 | | `return` | Show the selected front card; picking a hidden column moves the maximization to it. Other rows shown keep their focus, maximization and float order | | |
| 267 | 271 | | `escape` | Return to the original workspace; every row shown keeps its focus, maximization and float order | | |
| 268 | 272 | ||
| 269 | - | The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows` by default) move the selection like these keys. In the overview, `focus-workspace-prev/next` also traverse cards before crossing workspace rows, including named ones, except inside a selected stash pile. | |
| 273 | + | The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows` by default) move the selection like these keys. In the overview, `focus-workspace-prev/next` also traverse cards before crossing workspace rows, including named ones, except inside a selected stash pile. The keys above describe vertical overviews; in a [Cascade](#cascade) overview they move as described there. | |
| 270 | 274 | ||
| 271 | - | Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row, which stays centred unless it would overlap the pile: its selected window in front, up to three others behind it, dimmed. In the pile, `k` / `j` or up/down browse stash cards and stop at either end without changing workspace. Right (`l`) leaves the pile immediately; left (`h`) stays in the pile. On a stash-only workspace, right deselects the pile so up/down can change workspace. Browsing brings the selected card to the front without reordering the stash. `return` or a click on a card closes the overview with the stash shown on that window. | |
| 275 | + | Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row, which stays centred unless it would overlap the pile (in a Cascade overview the pile extends its workspace's slot to the left): its selected window in front, up to three others behind it, dimmed. In the pile, `k` / `j` or up/down browse stash cards and stop at either end without changing workspace. In a vertical overview, right (`l`) leaves the pile immediately, left (`h`) stays in the pile, and on a stash-only workspace right deselects the pile so up/down can change workspace. In a Cascade overview, left from the pile goes to the previous workspace, right returns to the band's first column (or to the next workspace on a stash-only workspace), and up/down browse the pile. Browsing brings the selected card to the front without reordering the stash. `return` or a click on a card closes the overview with the stash shown on that window. | |
| 272 | 276 | ||
| 273 | - | A four-finger swipe up opens the overview, or hides the [stash](#stash) when it is shown, and a swipe down closes the overview on the selection (with the overview closed, a swipe down shows or hides the stash), whatever `touchpad.natural-scroll` says. Two-finger scrolling belongs to applications and never navigates the overview; previews do not accept application input. Three fingers move the selection during the gesture, following `touchpad.natural-scroll`: horizontally, a short distance selects the next app and further movement repeats at a longer distance; vertically, one step per gesture selects a stack card or changes workspace. Inside a selected stash pile, vertical movement browses its cards and horizontal movement enters or leaves the pile. Lifting resets the gesture without an extra step; cancellation keeps selections already made. The mouse wheel moves one step per notch, following `mouse.natural-scroll`. Three-finger navigation, the wheel and keyboard navigation share the same pile boundaries. | |
| 277 | + | A four-finger swipe up opens the overview, or hides the [stash](#stash) when it is shown, and a swipe down closes the overview on the selection (with the overview closed, a swipe down shows or hides the stash), whatever `touchpad.natural-scroll` says. Two-finger scrolling belongs to applications and never navigates the overview; previews do not accept application input. In a vertical overview, three fingers move the selection during the gesture, following `touchpad.natural-scroll`: horizontally, a short distance selects the next app and further movement repeats at a longer distance; vertically, one step per gesture selects a stack card or changes workspace. Inside a selected stash pile, vertical movement browses its cards and horizontal movement enters or leaves the pile. Lifting resets the gesture without an extra step; cancellation keeps selections already made. A Cascade overview follows the fingers instead (see [Cascade](#cascade)). The mouse wheel moves one step per notch, following `mouse.natural-scroll`. Three-finger navigation, the wheel and keyboard navigation share the same pile boundaries. | |
| 274 | 278 | ||
| 275 | 279 | Return or a click on a front tile or peeking card commits it; card changes remain provisional until then. `close-window` targets the selected preview. Move-to-workspace binds (`move-column-to-workspace*`, `move-window-to-workspace*`) move the selected preview at once and keep the overview open; with `focus.follow-move`, the selection follows it. Escape does not undo a move, and the row that received the window keeps it focused. A selected stash card, or a row with no preview, does not move. Other window mutation binds (moving within the row, resizing, maximizing, fullscreen and stash toggles) and moving a workspace to another monitor are disabled while the overview is open. Workspace and monitor navigation, launch and quit binds remain active. | |
| 276 | 280 | ||
| @@ -196,7 +196,11 @@ Left/right focus follows column order across band boundaries. Up/down visits sta | |||||
| 196 | 196 | ||||
| 197 | Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour. | 197 | Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour. | ||
| 198 | 198 | ||||
| 199 | - | The overview presents bands side by side in one workspace card. Selecting a window reveals its band. Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag. | 199 | + | In the [overview](#overview), the selected band is centred at the usual overview zoom, whatever the band count. The other bands of the workspace sit above and below it, dimmed, and may run off screen. Neighbor workspaces sit left and right, fully dimmed, with their selected row level with the centred band. The stash pile stays left of the band, inside the workspace's slot. |
| 200 | + | ||||
| 201 | + | In the overview, up/down move to the previous or next band and stop at the first and last; with a stash card selected they browse the pile. Left/right move along the band; past its first column they enter the stash, or go to the previous workspace when there is none; past its last column they go to the next workspace (with the overview closed, focus crosses into the next band instead). Covering floats fan left and right of the band: left/right traverse them before entering the stash or changing workspace, and up/down do nothing on a float. `h`/`j`/`k`/`l` and the focus binds follow the arrows; `focus-workspace-prev/next` always change workspace, except over a selected stash card, where they browse the pile. Three-finger swipes follow the fingers: vertical moves one band (or one stash card) and horizontal one workspace per gesture, even over a stash card. A vertical wheel or continuous scroll changes workspace, except over a selected stash card, where it browses the pile; a horizontal one moves along the band. | |||
| 202 | + | ||||
| 203 | + | Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag. | |||
| 200 | 204 | ||||
| 201 | ### Actions | 205 | ### Actions | ||
| 202 | 206 | ||||
| @@ -257,7 +261,7 @@ The drop can be on another monitor; focus follows the window. There, the middle | |||||
| 257 | 261 | ||||
| 258 | ### Overview | 262 | ### Overview | ||
| 259 | 263 | ||||
| 260 | - | `toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows, over a background darkened by `floating.dim`. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Each monitor has one vertical overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with horizontal rules separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear above and below; rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal previous/next workspace navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen. | 264 | + | `toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows, over a background darkened by `floating.dim`. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, and in a vertical overview up to two cards behind peek above it and passed cards peek below (a Cascade overview fans them left and right). Neighbor workspaces show dimmed stacks. Each monitor has one overview, laid out along the navigation axis of the current workspace's layout: neighbors appear above and below a `scroll` or `fixed` workspace and left and right of a `cascade` one (see [Cascade](#cascade)); stepping to a workspace with another layout turns the overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with rules across the axis (horizontal in a vertical overview, vertical in a side-by-side one) separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear beside it along that axis; in a vertical overview, rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal previous/next workspace navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen. |
| 261 | 265 | ||||
| 262 | | Key | Action | | 266 | | Key | Action | | ||
| 263 | | --- | --- | | 267 | | --- | --- | | ||
| @@ -266,11 +270,11 @@ The drop can be on another monitor; focus follows the window. There, the middle | |||||
| 266 | | `return` | Show the selected front card; picking a hidden column moves the maximization to it. Other rows shown keep their focus, maximization and float order | | 270 | | `return` | Show the selected front card; picking a hidden column moves the maximization to it. Other rows shown keep their focus, maximization and float order | | ||
| 267 | | `escape` | Return to the original workspace; every row shown keeps its focus, maximization and float order | | 271 | | `escape` | Return to the original workspace; every row shown keeps its focus, maximization and float order | | ||
| 268 | 272 | ||||
| 269 | - | The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows` by default) move the selection like these keys. In the overview, `focus-workspace-prev/next` also traverse cards before crossing workspace rows, including named ones, except inside a selected stash pile. | 273 | + | The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows` by default) move the selection like these keys. In the overview, `focus-workspace-prev/next` also traverse cards before crossing workspace rows, including named ones, except inside a selected stash pile. The keys above describe vertical overviews; in a [Cascade](#cascade) overview they move as described there. |
| 270 | 274 | ||||
| 271 | - | Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row, which stays centred unless it would overlap the pile: its selected window in front, up to three others behind it, dimmed. In the pile, `k` / `j` or up/down browse stash cards and stop at either end without changing workspace. Right (`l`) leaves the pile immediately; left (`h`) stays in the pile. On a stash-only workspace, right deselects the pile so up/down can change workspace. Browsing brings the selected card to the front without reordering the stash. `return` or a click on a card closes the overview with the stash shown on that window. | 275 | + | Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row, which stays centred unless it would overlap the pile (in a Cascade overview the pile extends its workspace's slot to the left): its selected window in front, up to three others behind it, dimmed. In the pile, `k` / `j` or up/down browse stash cards and stop at either end without changing workspace. In a vertical overview, right (`l`) leaves the pile immediately, left (`h`) stays in the pile, and on a stash-only workspace right deselects the pile so up/down can change workspace. In a Cascade overview, left from the pile goes to the previous workspace, right returns to the band's first column (or to the next workspace on a stash-only workspace), and up/down browse the pile. Browsing brings the selected card to the front without reordering the stash. `return` or a click on a card closes the overview with the stash shown on that window. |
| 272 | 276 | ||||
| 273 | - | A four-finger swipe up opens the overview, or hides the [stash](#stash) when it is shown, and a swipe down closes the overview on the selection (with the overview closed, a swipe down shows or hides the stash), whatever `touchpad.natural-scroll` says. Two-finger scrolling belongs to applications and never navigates the overview; previews do not accept application input. Three fingers move the selection during the gesture, following `touchpad.natural-scroll`: horizontally, a short distance selects the next app and further movement repeats at a longer distance; vertically, one step per gesture selects a stack card or changes workspace. Inside a selected stash pile, vertical movement browses its cards and horizontal movement enters or leaves the pile. Lifting resets the gesture without an extra step; cancellation keeps selections already made. The mouse wheel moves one step per notch, following `mouse.natural-scroll`. Three-finger navigation, the wheel and keyboard navigation share the same pile boundaries. | 277 | + | A four-finger swipe up opens the overview, or hides the [stash](#stash) when it is shown, and a swipe down closes the overview on the selection (with the overview closed, a swipe down shows or hides the stash), whatever `touchpad.natural-scroll` says. Two-finger scrolling belongs to applications and never navigates the overview; previews do not accept application input. In a vertical overview, three fingers move the selection during the gesture, following `touchpad.natural-scroll`: horizontally, a short distance selects the next app and further movement repeats at a longer distance; vertically, one step per gesture selects a stack card or changes workspace. Inside a selected stash pile, vertical movement browses its cards and horizontal movement enters or leaves the pile. Lifting resets the gesture without an extra step; cancellation keeps selections already made. A Cascade overview follows the fingers instead (see [Cascade](#cascade)). The mouse wheel moves one step per notch, following `mouse.natural-scroll`. Three-finger navigation, the wheel and keyboard navigation share the same pile boundaries. |
| 274 | 278 | ||||
| 275 | Return or a click on a front tile or peeking card commits it; card changes remain provisional until then. `close-window` targets the selected preview. Move-to-workspace binds (`move-column-to-workspace*`, `move-window-to-workspace*`) move the selected preview at once and keep the overview open; with `focus.follow-move`, the selection follows it. Escape does not undo a move, and the row that received the window keeps it focused. A selected stash card, or a row with no preview, does not move. Other window mutation binds (moving within the row, resizing, maximizing, fullscreen and stash toggles) and moving a workspace to another monitor are disabled while the overview is open. Workspace and monitor navigation, launch and quit binds remain active. | 279 | Return or a click on a front tile or peeking card commits it; card changes remain provisional until then. `close-window` targets the selected preview. Move-to-workspace binds (`move-column-to-workspace*`, `move-window-to-workspace*`) move the selected preview at once and keep the overview open; with `focus.follow-move`, the selection follows it. Escape does not undo a move, and the row that received the window keeps it focused. A selected stash card, or a row with no preview, does not move. Other window mutation binds (moving within the row, resizing, maximizing, fullscreen and stash toggles) and moving a workspace to another monitor are disabled while the overview is open. Workspace and monitor navigation, launch and quit binds remain active. | ||
| 276 | 280 | ||||
M internal/core/cascade.go +36 -10
| @@ -22,7 +22,7 @@ func (w *Workspace) cellInBand(b int) int { | |||
| 22 | 22 | return min(b*k+w.Focus%k, len(w.Columns)-1) | |
| 23 | 23 | } | |
| 24 | 24 | func (w *Workspace) focusBand(dir int) bool { | |
| 25 | - | i := w.bandNeighbor(dir) | |
| 25 | + | i := w.laneNeighbor(dir) | |
| 26 | 26 | if i < 0 { | |
| 27 | 27 | return false | |
| 28 | 28 | } | |
| @@ -36,15 +36,6 @@ func (w *Workspace) focusBand(dir int) bool { | |||
| 36 | 36 | w.scroll() | |
| 37 | 37 | return true | |
| 38 | 38 | } | |
| 39 | - | func (w *Workspace) bandNeighbor(dir int) int { | |
| 40 | - | k := max(w.MaxColumns, 1) | |
| 41 | - | b := w.band(w.Focus) + dir | |
| 42 | - | if b < 0 || b*k >= len(w.Columns) { | |
| 43 | - | return -1 | |
| 44 | - | } | |
| 45 | - | return w.cellInBand(b) | |
| 46 | - | } | |
| 47 | - | ||
| 48 | 39 | func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |
| 49 | 40 | g := w.gap() | |
| 50 | 41 | width := w.cellWidth() | |
| @@ -77,3 +68,38 @@ func (w *Workspace) cascadeFocus(view int) int { | |||
| 77 | 68 | b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1)) | |
| 78 | 69 | return w.cellInBand(b) | |
| 79 | 70 | } | |
| 71 | + | ||
| 72 | + | // laneOf is the lane of column i in the overview: its band, or the only | |
| 73 | + | // lane of a layout that does not wrap. | |
| 74 | + | func (w *Workspace) laneOf(i int) int { | |
| 75 | + | if w.policy().wraps { | |
| 76 | + | return w.band(i) | |
| 77 | + | } | |
| 78 | + | return 0 | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // laneBounds is the range of columns of a lane, first > last when the lane | |
| 82 | + | // does not exist. A layout that does not wrap has the whole row as lane 0. | |
| 83 | + | func (w *Workspace) laneBounds(lane int) (first, last int) { | |
| 84 | + | k := len(w.Columns) | |
| 85 | + | if w.policy().wraps { | |
| 86 | + | k = max(w.MaxColumns, 1) | |
| 87 | + | } | |
| 88 | + | first = lane * k | |
| 89 | + | if lane < 0 || first >= len(w.Columns) { | |
| 90 | + | return 0, -1 | |
| 91 | + | } | |
| 92 | + | return first, min(first+k, len(w.Columns)) - 1 | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // laneNeighbor is the closest column to the focus in the lane d lanes away, | |
| 96 | + | // the focus's offset in its lane kept; -1 past the first or last lane. | |
| 97 | + | func (w *Workspace) laneNeighbor(d int) int { | |
| 98 | + | lane := w.laneOf(w.Focus) | |
| 99 | + | first, _ := w.laneBounds(lane) | |
| 100 | + | nfirst, nlast := w.laneBounds(lane + d) | |
| 101 | + | if nfirst > nlast { | |
| 102 | + | return -1 | |
| 103 | + | } | |
| 104 | + | return min(nfirst+w.Focus-first, nlast) | |
| 105 | + | } | |
| @@ -22,7 +22,7 @@ func (w *Workspace) cellInBand(b int) int { | |||||
| 22 | return min(b*k+w.Focus%k, len(w.Columns)-1) | 22 | return min(b*k+w.Focus%k, len(w.Columns)-1) | ||
| 23 | } | 23 | } | ||
| 24 | func (w *Workspace) focusBand(dir int) bool { | 24 | func (w *Workspace) focusBand(dir int) bool { | ||
| 25 | - | i := w.bandNeighbor(dir) | 25 | + | i := w.laneNeighbor(dir) |
| 26 | if i < 0 { | 26 | if i < 0 { | ||
| 27 | return false | 27 | return false | ||
| 28 | } | 28 | } | ||
| @@ -36,15 +36,6 @@ func (w *Workspace) focusBand(dir int) bool { | |||||
| 36 | w.scroll() | 36 | w.scroll() | ||
| 37 | return true | 37 | return true | ||
| 38 | } | 38 | } | ||
| 39 | - | func (w *Workspace) bandNeighbor(dir int) int { | |||
| 40 | - | k := max(w.MaxColumns, 1) | |||
| 41 | - | b := w.band(w.Focus) + dir | |||
| 42 | - | if b < 0 || b*k >= len(w.Columns) { | |||
| 43 | - | return -1 | |||
| 44 | - | } | |||
| 45 | - | return w.cellInBand(b) | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | 39 | func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | ||
| 49 | g := w.gap() | 40 | g := w.gap() | ||
| 50 | width := w.cellWidth() | 41 | width := w.cellWidth() | ||
| @@ -77,3 +68,38 @@ func (w *Workspace) cascadeFocus(view int) int { | |||||
| 77 | b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1)) | 68 | b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1)) | ||
| 78 | return w.cellInBand(b) | 69 | return w.cellInBand(b) | ||
| 79 | } | 70 | } | ||
| 71 | + | ||||
| 72 | + | // laneOf is the lane of column i in the overview: its band, or the only | |||
| 73 | + | // lane of a layout that does not wrap. | |||
| 74 | + | func (w *Workspace) laneOf(i int) int { | |||
| 75 | + | if w.policy().wraps { | |||
| 76 | + | return w.band(i) | |||
| 77 | + | } | |||
| 78 | + | return 0 | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // laneBounds is the range of columns of a lane, first > last when the lane | |||
| 82 | + | // does not exist. A layout that does not wrap has the whole row as lane 0. | |||
| 83 | + | func (w *Workspace) laneBounds(lane int) (first, last int) { | |||
| 84 | + | k := len(w.Columns) | |||
| 85 | + | if w.policy().wraps { | |||
| 86 | + | k = max(w.MaxColumns, 1) | |||
| 87 | + | } | |||
| 88 | + | first = lane * k | |||
| 89 | + | if lane < 0 || first >= len(w.Columns) { | |||
| 90 | + | return 0, -1 | |||
| 91 | + | } | |||
| 92 | + | return first, min(first+k, len(w.Columns)) - 1 | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // laneNeighbor is the closest column to the focus in the lane d lanes away, | |||
| 96 | + | // the focus's offset in its lane kept; -1 past the first or last lane. | |||
| 97 | + | func (w *Workspace) laneNeighbor(d int) int { | |||
| 98 | + | lane := w.laneOf(w.Focus) | |||
| 99 | + | first, _ := w.laneBounds(lane) | |||
| 100 | + | nfirst, nlast := w.laneBounds(lane + d) | |||
| 101 | + | if nfirst > nlast { | |||
| 102 | + | return -1 | |||
| 103 | + | } | |||
| 104 | + | return min(nfirst+w.Focus-first, nlast) | |||
| 105 | + | } | |||
M internal/core/cascade_test.go +56 -0
| @@ -166,6 +166,62 @@ func TestCascadeGeometryReusesBuffer(t *testing.T) { | |||
| 166 | 166 | if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 { | |
| 167 | 167 | t.Fatalf("allocations=%v", n) | |
| 168 | 168 | } | |
| 169 | + | // The overview's lane-encoded tiles reuse their buffer too. | |
| 170 | + | tiles, _, _ := w.previewTiles() | |
| 171 | + | if n := testing.AllocsPerRun(100, func() { tiles, _, _ = w.previewTilesInto(tiles[:0]) }); n != 0 { | |
| 172 | + | t.Fatalf("preview tile allocations=%v", n) | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // Lanes are the bands of a cascade workspace and the whole row of any other. | |
| 177 | + | func TestLaneGeometry(t *testing.T) { | |
| 178 | + | w := cascadeWorkspace(7) // bands {0,1,2} {3,4,5} {6} | |
| 179 | + | for _, c := range []struct{ i, lane int }{{0, 0}, {2, 0}, {3, 1}, {6, 2}} { | |
| 180 | + | if got := w.laneOf(c.i); got != c.lane { | |
| 181 | + | t.Fatalf("cascade laneOf(%d)=%d, want %d", c.i, got, c.lane) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | for lane, want := range [][2]int{{0, 2}, {3, 5}, {6, 6}} { | |
| 185 | + | if first, last := w.laneBounds(lane); first != want[0] || last != want[1] { | |
| 186 | + | t.Fatalf("cascade laneBounds(%d)=%d,%d, want %v", lane, first, last, want) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | for _, lane := range []int{-1, 3} { | |
| 190 | + | if first, last := w.laneBounds(lane); first <= last { | |
| 191 | + | t.Fatalf("cascade laneBounds(%d)=%d,%d exists", lane, first, last) | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | // The closest column of the adjacent lane keeps its offset, clamped. | |
| 195 | + | for _, c := range []struct{ focus, d, want int }{{1, 1, 4}, {5, 1, 6}, {4, -1, 1}, {2, -1, -1}, {6, 1, -1}, {6, -1, 3}} { | |
| 196 | + | w.Focus = c.focus | |
| 197 | + | if got := w.laneNeighbor(c.d); got != c.want { | |
| 198 | + | t.Fatalf("cascade laneNeighbor from %d by %d = %d, want %d", c.focus, c.d, got, c.want) | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | s := &Workspace{Overflow: OverflowScroll, MaxColumns: 3} | |
| 203 | + | s.SetOutput(900, 600) | |
| 204 | + | for i := 1; i <= 5; i++ { | |
| 205 | + | s.AddWindow(WindowID(i)) | |
| 206 | + | } | |
| 207 | + | for i := range s.Columns { | |
| 208 | + | if s.laneOf(i) != 0 { | |
| 209 | + | t.Fatalf("scroll laneOf(%d)=%d", i, s.laneOf(i)) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | if first, last := s.laneBounds(0); first != 0 || last != 4 { | |
| 213 | + | t.Fatalf("scroll laneBounds(0)=%d,%d", first, last) | |
| 214 | + | } | |
| 215 | + | for _, lane := range []int{-1, 1} { | |
| 216 | + | if first, last := s.laneBounds(lane); first <= last { | |
| 217 | + | t.Fatalf("scroll laneBounds(%d)=%d,%d exists", lane, first, last) | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | for _, d := range []int{-1, 1} { | |
| 221 | + | if got := s.laneNeighbor(d); got != -1 { | |
| 222 | + | t.Fatalf("scroll laneNeighbor(%d)=%d", d, got) | |
| 223 | + | } | |
| 224 | + | } | |
| 169 | 225 | } | |
| 170 | 226 | ||
| 171 | 227 | func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) { | |
| @@ -166,6 +166,62 @@ func TestCascadeGeometryReusesBuffer(t *testing.T) { | |||||
| 166 | if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 { | 166 | if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 { | ||
| 167 | t.Fatalf("allocations=%v", n) | 167 | t.Fatalf("allocations=%v", n) | ||
| 168 | } | 168 | } | ||
| 169 | + | // The overview's lane-encoded tiles reuse their buffer too. | |||
| 170 | + | tiles, _, _ := w.previewTiles() | |||
| 171 | + | if n := testing.AllocsPerRun(100, func() { tiles, _, _ = w.previewTilesInto(tiles[:0]) }); n != 0 { | |||
| 172 | + | t.Fatalf("preview tile allocations=%v", n) | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // Lanes are the bands of a cascade workspace and the whole row of any other. | |||
| 177 | + | func TestLaneGeometry(t *testing.T) { | |||
| 178 | + | w := cascadeWorkspace(7) // bands {0,1,2} {3,4,5} {6} | |||
| 179 | + | for _, c := range []struct{ i, lane int }{{0, 0}, {2, 0}, {3, 1}, {6, 2}} { | |||
| 180 | + | if got := w.laneOf(c.i); got != c.lane { | |||
| 181 | + | t.Fatalf("cascade laneOf(%d)=%d, want %d", c.i, got, c.lane) | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | for lane, want := range [][2]int{{0, 2}, {3, 5}, {6, 6}} { | |||
| 185 | + | if first, last := w.laneBounds(lane); first != want[0] || last != want[1] { | |||
| 186 | + | t.Fatalf("cascade laneBounds(%d)=%d,%d, want %v", lane, first, last, want) | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | for _, lane := range []int{-1, 3} { | |||
| 190 | + | if first, last := w.laneBounds(lane); first <= last { | |||
| 191 | + | t.Fatalf("cascade laneBounds(%d)=%d,%d exists", lane, first, last) | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | // The closest column of the adjacent lane keeps its offset, clamped. | |||
| 195 | + | for _, c := range []struct{ focus, d, want int }{{1, 1, 4}, {5, 1, 6}, {4, -1, 1}, {2, -1, -1}, {6, 1, -1}, {6, -1, 3}} { | |||
| 196 | + | w.Focus = c.focus | |||
| 197 | + | if got := w.laneNeighbor(c.d); got != c.want { | |||
| 198 | + | t.Fatalf("cascade laneNeighbor from %d by %d = %d, want %d", c.focus, c.d, got, c.want) | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | s := &Workspace{Overflow: OverflowScroll, MaxColumns: 3} | |||
| 203 | + | s.SetOutput(900, 600) | |||
| 204 | + | for i := 1; i <= 5; i++ { | |||
| 205 | + | s.AddWindow(WindowID(i)) | |||
| 206 | + | } | |||
| 207 | + | for i := range s.Columns { | |||
| 208 | + | if s.laneOf(i) != 0 { | |||
| 209 | + | t.Fatalf("scroll laneOf(%d)=%d", i, s.laneOf(i)) | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | if first, last := s.laneBounds(0); first != 0 || last != 4 { | |||
| 213 | + | t.Fatalf("scroll laneBounds(0)=%d,%d", first, last) | |||
| 214 | + | } | |||
| 215 | + | for _, lane := range []int{-1, 1} { | |||
| 216 | + | if first, last := s.laneBounds(lane); first <= last { | |||
| 217 | + | t.Fatalf("scroll laneBounds(%d)=%d,%d exists", lane, first, last) | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | for _, d := range []int{-1, 1} { | |||
| 221 | + | if got := s.laneNeighbor(d); got != -1 { | |||
| 222 | + | t.Fatalf("scroll laneNeighbor(%d)=%d", d, got) | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 169 | } | 225 | } | ||
| 170 | 226 | ||||
| 171 | func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) { | 227 | func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) { | ||
M internal/core/core.go +11 -0
| @@ -287,6 +287,11 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 287 | 287 | if err != nil { | |
| 288 | 288 | return err | |
| 289 | 289 | } | |
| 290 | + | // Reload is cold-path work; unrelated changes keep a scroll or gesture. | |
| 291 | + | axes := make(map[*Monitor]layoutPolicy, len(c.screens)) | |
| 292 | + | for _, s := range c.screens { | |
| 293 | + | axes[s.mon] = s.mon.Current().policy() | |
| 294 | + | } | |
| 290 | 295 | c.cfg = cfg | |
| 291 | 296 | c.binds = binds | |
| 292 | 297 | c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey] | |
| @@ -297,6 +302,12 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 297 | 302 | c.settings(s.mon) | |
| 298 | 303 | } | |
| 299 | 304 | c.applyOutputConfig() | |
| 305 | + | for _, s := range c.screens { | |
| 306 | + | old, now := axes[s.mon], s.mon.Current().policy() | |
| 307 | + | if old.content != now.content || old.workspace != now.workspace { | |
| 308 | + | s.mon.ov.dropScroll() | |
| 309 | + | } | |
| 310 | + | } | |
| 300 | 311 | return nil | |
| 301 | 312 | } | |
| 302 | 313 | ||
| @@ -287,6 +287,11 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 287 | if err != nil { | 287 | if err != nil { | ||
| 288 | return err | 288 | return err | ||
| 289 | } | 289 | } | ||
| 290 | + | // Reload is cold-path work; unrelated changes keep a scroll or gesture. | |||
| 291 | + | axes := make(map[*Monitor]layoutPolicy, len(c.screens)) | |||
| 292 | + | for _, s := range c.screens { | |||
| 293 | + | axes[s.mon] = s.mon.Current().policy() | |||
| 294 | + | } | |||
| 290 | c.cfg = cfg | 295 | c.cfg = cfg | ||
| 291 | c.binds = binds | 296 | c.binds = binds | ||
| 292 | c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey] | 297 | c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey] | ||
| @@ -297,6 +302,12 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 297 | c.settings(s.mon) | 302 | c.settings(s.mon) | ||
| 298 | } | 303 | } | ||
| 299 | c.applyOutputConfig() | 304 | c.applyOutputConfig() | ||
| 305 | + | for _, s := range c.screens { | |||
| 306 | + | old, now := axes[s.mon], s.mon.Current().policy() | |||
| 307 | + | if old.content != now.content || old.workspace != now.workspace { | |||
| 308 | + | s.mon.ov.dropScroll() | |||
| 309 | + | } | |||
| 310 | + | } | |||
| 300 | return nil | 311 | return nil | ||
| 301 | } | 312 | } | ||
| 302 | 313 | ||||
M internal/core/gesture.go +1 -1
| @@ -418,7 +418,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 418 | 418 | } | |
| 419 | 419 | } else if p.wraps { | |
| 420 | 420 | // A content swipe cannot escape the last band to another workspace. | |
| 421 | - | if w.onFloat() || w.fullscreen != 0 || w.bandNeighbor(step) < 0 { | |
| 421 | + | if w.onFloat() || w.fullscreen != 0 || w.laneNeighbor(step) < 0 { | |
| 422 | 422 | return false | |
| 423 | 423 | } | |
| 424 | 424 | } | |
| @@ -418,7 +418,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 418 | } | 418 | } | ||
| 419 | } else if p.wraps { | 419 | } else if p.wraps { | ||
| 420 | // A content swipe cannot escape the last band to another workspace. | 420 | // A content swipe cannot escape the last band to another workspace. | ||
| 421 | - | if w.onFloat() || w.fullscreen != 0 || w.bandNeighbor(step) < 0 { | 421 | + | if w.onFloat() || w.fullscreen != 0 || w.laneNeighbor(step) < 0 { |
| 422 | return false | 422 | return false | ||
| 423 | } | 423 | } | ||
| 424 | } | 424 | } | ||
M internal/core/overview.go +381 -73
| @@ -31,6 +31,11 @@ type overviewState struct { | |||
| 31 | 31 | scrolled, sideways bool | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | + | // dropScroll forgets the scroll an overview axis accumulated, which belongs | |
| 35 | + | // to the old axis once it turns. The finger latch (scrolled) stays: it waits | |
| 36 | + | // for the fingers to lift. | |
| 37 | + | func (o *overviewState) dropScroll() { o.scrollX, o.scrollY, o.sideways = 0, 0, false } | |
| 38 | + | ||
| 34 | 39 | // rowSnapshot is a row as the overview first showed it: browsing moves its | |
| 35 | 40 | // focus and scroll, closing puts them back. | |
| 36 | 41 | type rowSnapshot struct { | |
| @@ -353,6 +358,9 @@ func (m *Monitor) overviewWorkspaces() []*Workspace { | |||
| 353 | 358 | // a named group uses its invocation anchor as the toggle return target, not | |
| 354 | 359 | // rows merely browsed in the overview. | |
| 355 | 360 | func (m *Monitor) showOverview(w *Workspace) { | |
| 361 | + | if w.policy().workspace != m.overviewAxis() { | |
| 362 | + | m.ov.dropScroll() | |
| 363 | + | } | |
| 356 | 364 | if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) { | |
| 357 | 365 | m.back = m.Current() | |
| 358 | 366 | if indexOf(m.Workspaces, w.overviewAfter) >= 0 { | |
| @@ -363,10 +371,120 @@ func (m *Monitor) showOverview(w *Workspace) { | |||
| 363 | 371 | m.selectRow() | |
| 364 | 372 | } | |
| 365 | 373 | ||
| 366 | - | // OverviewMove shares navigation rules across keys, scrolls and swipes. | |
| 367 | - | // Vertical moves stay in a selected stash pile; left stays, and right | |
| 368 | - | // deselects it, even when the row has no main card. | |
| 374 | + | // OverviewMove shares navigation rules across keys, scrolls and swipes. It | |
| 375 | + | // is the physical entry point: it maps dx and dy to the overview's moves. | |
| 369 | 376 | func (m *Monitor) OverviewMove(dx, dy int) { | |
| 377 | + | if m.overviewAxis() == horizontalAxis { | |
| 378 | + | if dy != 0 { | |
| 379 | + | m.overviewLane(dy) | |
| 380 | + | } else if dx != 0 { | |
| 381 | + | m.overviewAcross(dx) | |
| 382 | + | } | |
| 383 | + | return | |
| 384 | + | } | |
| 385 | + | m.overviewMoveVertical(dx, dy) | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | // overviewWorkspaceStep is a focus-workspace bind. A vertical overview | |
| 389 | + | // walks the stack cards before changing workspace (overviewVertical). A | |
| 390 | + | // side-by-side one always changes workspace, since its stack fans left and | |
| 391 | + | // right. A selected stash card cycles the pile first, in every layout. | |
| 392 | + | func (m *Monitor) overviewWorkspaceStep(d int) { | |
| 393 | + | if m.overviewAxis() == verticalAxis { | |
| 394 | + | m.overviewVertical(d) | |
| 395 | + | return | |
| 396 | + | } | |
| 397 | + | if !m.cycleCard(m.Current(), d) { | |
| 398 | + | m.overviewStep(d) | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | // cycleCard brings the stash card d entries from the selected one of w to | |
| 403 | + | // the front, stopping at either end. It reports whether a card was selected. | |
| 404 | + | func (m *Monitor) cycleCard(w *Workspace, d int) bool { | |
| 405 | + | at := m.cardAt(w) | |
| 406 | + | if at < 0 { | |
| 407 | + | return false | |
| 408 | + | } | |
| 409 | + | m.selectCard(w, min(max(at+d, 0), len(w.Stash)-1)) | |
| 410 | + | return true | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | // overviewLane is the vertical move of an overview whose workspaces sit side | |
| 414 | + | // by side (cascade): a selected stash card cycles the pile; else the | |
| 415 | + | // selection goes to the previous or next lane (band), at the closest column, | |
| 416 | + | // stopping at the first and last. Covering floats stack along the horizontal | |
| 417 | + | // axis, not this one. | |
| 418 | + | func (m *Monitor) overviewLane(dy int) { | |
| 419 | + | w := m.Current() | |
| 420 | + | if m.cycleCard(w, dy) || m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 { | |
| 421 | + | return | |
| 422 | + | } | |
| 423 | + | if i := w.laneNeighbor(dy); i >= 0 { | |
| 424 | + | m.selectLaneColumn(w, i) | |
| 425 | + | } | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | // selectLaneColumn selects column i of w. | |
| 429 | + | func (m *Monitor) selectLaneColumn(w *Workspace, i int) { | |
| 430 | + | w.selectOverviewColumn(i) | |
| 431 | + | m.ov.selected = w.Columns[i].Windows[w.Columns[i].Focus] | |
| 432 | + | m.ov.selectedAt = i | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | // overviewAcross is the horizontal move of an overview whose workspaces sit | |
| 436 | + | // side by side (cascade): a column inside the selected lane. Past the | |
| 437 | + | // lane's first column it goes to the stash, else to the previous workspace; | |
| 438 | + | // past the last column, to the next workspace. Covering floats fan along | |
| 439 | + | // this axis and are traversed before the stash or a workspace: the card on | |
| 440 | + | // the left is the one behind. | |
| 441 | + | func (m *Monitor) overviewAcross(dx int) { | |
| 442 | + | w := m.Current() | |
| 443 | + | if m.cardAt(w) >= 0 { | |
| 444 | + | if dx < 0 || len(w.stack()) == 0 { | |
| 445 | + | // Left leaves the pile; a stash-only workspace has nothing | |
| 446 | + | // to its right to select. | |
| 447 | + | m.overviewStep(dx) | |
| 448 | + | return | |
| 449 | + | } | |
| 450 | + | m.ov.card, m.ov.cardOf = 0, nil | |
| 451 | + | m.ov.selected = 0 | |
| 452 | + | if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 { | |
| 453 | + | first, _ := w.laneBounds(w.laneOf(w.Focus)) | |
| 454 | + | m.selectLaneColumn(w, first) | |
| 455 | + | } | |
| 456 | + | return | |
| 457 | + | } | |
| 458 | + | if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 { | |
| 459 | + | m.laneEdge(w, dx) | |
| 460 | + | return | |
| 461 | + | } | |
| 462 | + | first, last := w.laneBounds(w.laneOf(w.Focus)) | |
| 463 | + | if next := w.Focus + dx; next >= first && next <= last { | |
| 464 | + | m.selectLaneColumn(w, next) | |
| 465 | + | return | |
| 466 | + | } | |
| 467 | + | m.laneEdge(w, dx) | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | // laneEdge is a horizontal move off the edge of the selected lane, or off a | |
| 471 | + | // card that is not the columns: the stack first, then the stash on the left | |
| 472 | + | // or a workspace. | |
| 473 | + | func (m *Monitor) laneEdge(w *Workspace, dx int) { | |
| 474 | + | if m.moveStack(-dx) { | |
| 475 | + | return | |
| 476 | + | } | |
| 477 | + | if dx < 0 && len(w.Stash) > 0 { | |
| 478 | + | m.selectCard(w, w.stashAt) | |
| 479 | + | return | |
| 480 | + | } | |
| 481 | + | m.overviewStep(dx) | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | // overviewMoveVertical is the move of an overview whose workspaces stack | |
| 485 | + | // vertically. Vertical moves stay in a selected stash pile; left stays, and | |
| 486 | + | // right deselects it, even when the row has no main card. | |
| 487 | + | func (m *Monitor) overviewMoveVertical(dx, dy int) { | |
| 370 | 488 | if dy != 0 { | |
| 371 | 489 | m.overviewVertical(dy) | |
| 372 | 490 | } else if dx != 0 { | |
| @@ -375,21 +493,27 @@ func (m *Monitor) OverviewMove(dx, dy int) { | |||
| 375 | 493 | } | |
| 376 | 494 | ||
| 377 | 495 | func (m *Monitor) overviewVertical(dy int) { | |
| 378 | - | w := m.Current() | |
| 379 | - | if at := m.cardAt(w); at >= 0 { | |
| 380 | - | m.selectCard(w, min(max(at+dy, 0), len(w.Stash)-1)) | |
| 496 | + | if m.cycleCard(m.Current(), dy) { | |
| 381 | 497 | return | |
| 382 | 498 | } | |
| 383 | 499 | if m.moveStack(-dy) { | |
| 384 | 500 | return | |
| 385 | 501 | } | |
| 502 | + | m.overviewStep(dy) | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | // overviewStep shows the workspace d rows from the current one; it stays | |
| 506 | + | // on the first or last row. | |
| 507 | + | func (m *Monitor) overviewStep(d int) { | |
| 386 | 508 | rows := m.overviewWorkspaces() | |
| 387 | - | i := indexOf(rows, w) + dy | |
| 509 | + | i := indexOf(rows, m.Current()) + d | |
| 388 | 510 | if i >= 0 && i < len(rows) { | |
| 389 | 511 | m.showOverview(rows[i]) | |
| 390 | 512 | } | |
| 391 | 513 | } | |
| 392 | 514 | ||
| 515 | + | // overviewHorizontal is the horizontal move of an overview whose workspaces | |
| 516 | + | // stack vertically: it never changes workspace. | |
| 393 | 517 | func (m *Monitor) overviewHorizontal(dx int) { | |
| 394 | 518 | w := m.Current() | |
| 395 | 519 | if m.cardAt(w) >= 0 { | |
| @@ -487,16 +611,37 @@ func (m *Monitor) OverviewPick(id WindowID) { | |||
| 487 | 611 | m.closeOverview() | |
| 488 | 612 | } | |
| 489 | 613 | ||
| 490 | - | // overviewRows centers the selected row with its neighbors above and below. | |
| 491 | - | // The numbered/named boundary gets extra spacing and an inactive horizontal | |
| 492 | - | // rule. Row widths, stash piles and preview scales remain unchanged. | |
| 493 | - | func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) { | |
| 614 | + | // overviewAxis is the axis the overview lays workspaces out along, and the | |
| 615 | + | // stacked cards of a row fan along: the navigation axis of the current | |
| 616 | + | // workspace's layout. | |
| 617 | + | func (m *Monitor) overviewAxis() layoutAxis { return m.Current().policy().workspace } | |
| 618 | + | ||
| 619 | + | // overviewRows places the current workspace and its overview neighbors in | |
| 620 | + | // slots, in output-local pixels, along the overview axis; each workspace | |
| 621 | + | // places its previews in its slot. The numbered/named boundary gets extra | |
| 622 | + | // spacing and an inactive rule across the axis. | |
| 623 | + | // | |
| 624 | + | // Vertical axis: full-width slots, the current one centred; the rule is | |
| 625 | + | // horizontal. Horizontal axis: full-height slots as wide as their content, | |
| 626 | + | // stash pile and stack peeks included, the current one centred; the rule is | |
| 627 | + | // vertical, in the spacing between two slots. | |
| 628 | + | func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) { | |
| 494 | 629 | cur := m.Current() | |
| 495 | 630 | u := cur.overviewArea() | |
| 496 | 631 | gap := max(1, u.H*3/100) | |
| 497 | - | hc := cur.rowHeight() | |
| 498 | - | y := u.Y + (u.H-hc)/2 | |
| 499 | - | rows := map[*Workspace]int{cur: y} | |
| 632 | + | axis := m.overviewAxis() | |
| 633 | + | var cs Rect | |
| 634 | + | if axis == horizontalAxis { | |
| 635 | + | // The row stays centred on the area; the pile extends the slot to | |
| 636 | + | // the left, pushing the previous workspace, unless it would leave | |
| 637 | + | // the area. | |
| 638 | + | pile, row := cur.pileWidth(), cur.rowWidth() | |
| 639 | + | cs = Rect{X: max(u.X+(u.W-row)/2-pile, u.X), Y: u.Y, W: pile + row, H: u.H} | |
| 640 | + | } else { | |
| 641 | + | hc := cur.rowHeight() | |
| 642 | + | cs = Rect{X: u.X, Y: u.Y + (u.H-hc)/2, W: u.W, H: hc} | |
| 643 | + | } | |
| 644 | + | rows := map[*Workspace]Rect{cur: cs} | |
| 500 | 645 | var dividers []ports.Separator | |
| 501 | 646 | list := m.overviewWorkspaces() | |
| 502 | 647 | i := indexOf(list, cur) | |
| @@ -510,33 +655,66 @@ func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) { | |||
| 510 | 655 | if boundary { | |
| 511 | 656 | spacing *= 2 | |
| 512 | 657 | } | |
| 513 | - | top := y + hc | |
| 514 | - | if next < i { | |
| 515 | - | rows[w] = y - spacing - w.rowHeight() | |
| 516 | - | top = y - spacing | |
| 517 | - | } else { | |
| 518 | - | rows[w] = y + hc + spacing | |
| 519 | - | } | |
| 658 | + | slot, gapAt := neighborSlot(axis, u, cs, w, next < i, spacing) | |
| 659 | + | rows[w] = slot | |
| 520 | 660 | if boundary { | |
| 521 | - | margin := u.W * 4 / 100 | |
| 522 | - | dividers = append(dividers, ports.Separator{Rect: Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1}}) | |
| 661 | + | dividers = append(dividers, overviewDivider(axis, u, gapAt+spacing/2)) | |
| 523 | 662 | } | |
| 524 | 663 | } | |
| 525 | 664 | return rows, dividers | |
| 526 | 665 | } | |
| 527 | 666 | ||
| 667 | + | // neighborSlot is the slot of w, the neighbor before or after the current | |
| 668 | + | // slot cs along axis, spacing away; gapAt is where the spacing begins. | |
| 669 | + | func neighborSlot(axis layoutAxis, u, cs Rect, w *Workspace, before bool, spacing int) (slot Rect, gapAt int) { | |
| 670 | + | if axis == horizontalAxis { | |
| 671 | + | slot = Rect{Y: u.Y, W: w.slotWidth(), H: u.H} | |
| 672 | + | if before { | |
| 673 | + | gapAt = cs.X - spacing | |
| 674 | + | slot.X = gapAt - slot.W | |
| 675 | + | } else { | |
| 676 | + | gapAt = cs.X + cs.W | |
| 677 | + | slot.X = gapAt + spacing | |
| 678 | + | } | |
| 679 | + | return slot, gapAt | |
| 680 | + | } | |
| 681 | + | h := w.rowHeight() | |
| 682 | + | slot = Rect{X: u.X, W: u.W, H: h} | |
| 683 | + | if before { | |
| 684 | + | gapAt = cs.Y - spacing | |
| 685 | + | slot.Y = gapAt - h | |
| 686 | + | } else { | |
| 687 | + | gapAt = cs.Y + cs.H | |
| 688 | + | slot.Y = gapAt + spacing | |
| 689 | + | } | |
| 690 | + | return slot, gapAt | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | // overviewDivider is the inactive rule across axis at pos, inset from the | |
| 694 | + | // edges of u. | |
| 695 | + | func overviewDivider(axis layoutAxis, u Rect, pos int) ports.Separator { | |
| 696 | + | if axis == horizontalAxis { | |
| 697 | + | return ports.Separator{Rect: Rect{X: pos, Y: u.Y + u.H*4/100, W: 1, H: u.H * 92 / 100}} | |
| 698 | + | } | |
| 699 | + | margin := u.W * 4 / 100 | |
| 700 | + | return ports.Separator{Rect: Rect{X: u.X + margin, Y: pos, W: u.W - 2*margin, H: 1}} | |
| 701 | + | } | |
| 702 | + | ||
| 528 | 703 | // overviewLayout places the current workspace and its overview neighbors, | |
| 529 | - | // each with its full-width stash pile and covering-float stack. Other rows | |
| 704 | + | // each with its stash pile and covering-float stack. Other rows | |
| 530 | 705 | // stay hidden; numbered and named workspaces share the same preview geometry. | |
| 531 | 706 | func (m *Monitor) overviewLayout(result []Placement) []Placement { | |
| 532 | 707 | cur := m.Current() | |
| 533 | 708 | rows, _ := m.overviewRows() | |
| 709 | + | axis := m.overviewAxis() | |
| 534 | 710 | for w := range m.all() { | |
| 535 | - | ry, shown := rows[w] | |
| 711 | + | slot, shown := rows[w] | |
| 536 | 712 | if shown { | |
| 537 | 713 | at := m.cardAt(w) | |
| 538 | - | result = append(result, m.stackRow(w, ry, w != cur, at < 0)...) | |
| 539 | - | result = append(result, w.pile(ry, w != cur, at)...) | |
| 714 | + | result = append(result, m.stackRow(w, axis, slot, w != cur, at < 0)...) | |
| 715 | + | if len(w.Stash) > 0 { | |
| 716 | + | result = append(result, w.pile(w.pileBand(axis, slot), w != cur, at)...) | |
| 717 | + | } | |
| 540 | 718 | } | |
| 541 | 719 | for _, f := range w.Floats { | |
| 542 | 720 | if _, drawn := w.itemOf(f.ID); !shown || !drawn { | |
| @@ -637,11 +815,11 @@ func (w *Workspace) cardStep() int { | |||
| 637 | 815 | return min(overviewCardStep, a.W/100, a.H/50) | |
| 638 | 816 | } | |
| 639 | 817 | ||
| 640 | - | // pile places the stash of w as cards on the left of the row whose top is | |
| 641 | - | // at y: entry front (the stash selection when -1) in front, the next ones | |
| 642 | - | // behind it, dimmed, the rest hidden. Only a front card of the current row | |
| 643 | - | // (dim false) with front set is selected. | |
| 644 | - | func (w *Workspace) pile(y int, dim bool, front int) []Placement { | |
| 818 | + | // pile places the stash of w as cards on the left of band (see pileBand): | |
| 819 | + | // entry front (the stash selection when -1) in | |
| 820 | + | // front, the next ones behind it, dimmed, the rest hidden. Only a front card | |
| 821 | + | // of the current row (dim false) with front set is selected. | |
| 822 | + | func (w *Workspace) pile(band Rect, dim bool, front int) []Placement { | |
| 645 | 823 | n := len(w.Stash) | |
| 646 | 824 | if n == 0 { | |
| 647 | 825 | return nil | |
| @@ -658,7 +836,7 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement { | |||
| 658 | 836 | rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom())) | |
| 659 | 837 | // Stack rows reserve two peek steps above the front card. Align the | |
| 660 | 838 | // stash with that front band, not with the top of its envelope. | |
| 661 | - | x0, y0 := u.X+u.W/100, w.frontRowY(y)+(rowH-ch)/2 | |
| 839 | + | x0, y0 := band.X+u.W/100, band.Y+(rowH-ch)/2 | |
| 662 | 840 | cards := make([][]Placement, n) | |
| 663 | 841 | for i := range w.Stash { | |
| 664 | 842 | cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: Rect{X: x0, Y: y0, W: cw, H: ch}, Preview: overviewCardZoom, Focused: selected}} | |
| @@ -672,19 +850,48 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement { | |||
| 672 | 850 | // overviewZoom is the scale that fits every column of w in the usable | |
| 673 | 851 | // width, within the overview bounds. | |
| 674 | 852 | func (w *Workspace) overviewZoom() float64 { | |
| 853 | + | z, _ := w.zoomSpan() | |
| 854 | + | return z | |
| 855 | + | } | |
| 856 | + | ||
| 857 | + | // zoomSpan is overviewZoom with the unscaled span of the tiles it fits. A | |
| 858 | + | // cascade workspace spans one screen whatever its band count. | |
| 859 | + | func (w *Workspace) zoomSpan() (float64, int) { | |
| 675 | 860 | // Only the span is read: the tiles go in the zoom scratch, dead on return. | |
| 676 | 861 | var span int | |
| 677 | 862 | w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0]) | |
| 678 | 863 | if span <= 0 { | |
| 679 | - | return overviewMaxZoom | |
| 864 | + | return overviewMaxZoom, span | |
| 680 | 865 | } | |
| 681 | 866 | room := float64(w.overviewArea().W-w.pileWidth()) * 0.96 | |
| 682 | 867 | z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom) | |
| 683 | - | if len(w.stack()) > 1 { | |
| 868 | + | if w.stacked() { | |
| 684 | 869 | // Reserve two clear peeks on either side of the front row. | |
| 685 | 870 | z = min(z, overviewMaxZoom/(1+4*overviewPeekStep)) | |
| 686 | 871 | } | |
| 687 | - | return z | |
| 872 | + | return z, span | |
| 873 | + | } | |
| 874 | + | ||
| 875 | + | // slotWidth is the width of the slot of w in a horizontal overview: its | |
| 876 | + | // stash pile and its row. | |
| 877 | + | func (w *Workspace) slotWidth() int { | |
| 878 | + | return w.pileWidth() + w.rowWidth() | |
| 879 | + | } | |
| 880 | + | ||
| 881 | + | // rowWidth is the width of the row of w in a horizontal overview: what its | |
| 882 | + | // tiles take (at most what the pile leaves of the area), plus the room for | |
| 883 | + | // the covering floats peeking on either side of the front card. | |
| 884 | + | func (w *Workspace) rowWidth() int { | |
| 885 | + | z, span := w.zoomSpan() | |
| 886 | + | if span <= 0 { | |
| 887 | + | // An empty workspace keeps the room of a screen. | |
| 888 | + | span = w.Usable.W | |
| 889 | + | } | |
| 890 | + | width := min(int(math.Round(float64(span)*z)), w.overviewArea().W-w.pileWidth()) | |
| 891 | + | if w.stacked() { | |
| 892 | + | width += 4 * w.peekStep() | |
| 893 | + | } | |
| 894 | + | return width | |
| 688 | 895 | } | |
| 689 | 896 | ||
| 690 | 897 | // peekStep is the shared step for a stack row and its front-card inset. | |
| @@ -692,20 +899,38 @@ func (w *Workspace) peekStep() int { | |||
| 692 | 899 | return max(1, int(math.Round(float64(w.Usable.H)*w.overviewZoom()*overviewPeekStep))) | |
| 693 | 900 | } | |
| 694 | 901 | ||
| 695 | - | // frontRowY skips the space reserved for two cards peeking above the front. | |
| 696 | - | // A single-card row starts at y, as before. | |
| 697 | - | func (w *Workspace) frontRowY(y int) int { | |
| 698 | - | if len(w.stack()) > 1 { | |
| 699 | - | return y + 2*w.peekStep() | |
| 902 | + | // frontSlot is the part of slot the front card of w takes: slot shifted | |
| 903 | + | // along axis past the room reserved for two cards peeking before it, and | |
| 904 | + | // (along a horizontal axis, where the slot is as wide as its cards) without | |
| 905 | + | // the room of the two after it. A single-card row keeps slot. | |
| 906 | + | func (w *Workspace) frontSlot(axis layoutAxis, slot Rect) Rect { | |
| 907 | + | if w.stacked() { | |
| 908 | + | p := w.peekStep() | |
| 909 | + | axis.offset(&slot, 2*p) | |
| 910 | + | if axis == horizontalAxis { | |
| 911 | + | slot.W -= 4 * p | |
| 912 | + | } | |
| 700 | 913 | } | |
| 701 | - | return y | |
| 914 | + | return slot | |
| 915 | + | } | |
| 916 | + | ||
| 917 | + | // pileBand is the band the stash pile of w aligns with in its slot. A | |
| 918 | + | // vertical overview aligns it with the front card (see frontSlot). A | |
| 919 | + | // horizontal one puts it level with the selected lane, left of the whole | |
| 920 | + | // stack of cards. | |
| 921 | + | func (w *Workspace) pileBand(axis layoutAxis, slot Rect) Rect { | |
| 922 | + | if axis == verticalAxis { | |
| 923 | + | return w.frontSlot(axis, slot) | |
| 924 | + | } | |
| 925 | + | slot.Y += (slot.H - int(math.Round(float64(w.Usable.H)*w.overviewZoom()))) / 2 | |
| 926 | + | return slot | |
| 702 | 927 | } | |
| 703 | 928 | ||
| 704 | 929 | // rowHeight includes the largest possible stack envelope (two peeks above | |
| 705 | 930 | // and two below). Neighbor rows use the same measure as the current row. | |
| 706 | 931 | func (w *Workspace) rowHeight() int { | |
| 707 | 932 | h := int(math.Round(float64(w.Usable.H) * w.overviewZoom())) | |
| 708 | - | if len(w.stack()) > 1 { | |
| 933 | + | if w.stacked() { | |
| 709 | 934 | h += 4 * w.peekStep() | |
| 710 | 935 | } | |
| 711 | 936 | return h | |
| @@ -745,13 +970,18 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||
| 745 | 970 | rects = w.colBuf | |
| 746 | 971 | span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile. | |
| 747 | 972 | } | |
| 748 | - | maximized := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth | |
| 973 | + | maximized := w.policy().equalCells && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth | |
| 749 | 974 | for i, c := range w.Columns { | |
| 750 | 975 | r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h} | |
| 751 | 976 | if w.policy().wraps { | |
| 752 | - | width := w.cellWidth() | |
| 753 | - | r.X = w.band(i)*w.Usable.W + w.cellX(i) | |
| 754 | - | r.W = width | |
| 977 | + | // The band is the lane: it is encoded in Y, one screen apart. | |
| 978 | + | // A maximized column fills its band as on screen. | |
| 979 | + | r.X = w.cellX(i) | |
| 980 | + | r.Y = g + w.band(i)*w.Usable.H | |
| 981 | + | r.W = w.cellWidth() | |
| 982 | + | if c.FullWidth { | |
| 983 | + | r.X, r.W = g, max(w.Usable.W-2*g, 0) | |
| 984 | + | } | |
| 755 | 985 | } else if w.Overflow == OverflowFixed { | |
| 756 | 986 | r = rects[i] | |
| 757 | 987 | r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y | |
| @@ -759,10 +989,10 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||
| 759 | 989 | // A fullscreen column shows at the width it had in the row. | |
| 760 | 990 | r.W = max(w.Usable.W-2*g, 0) | |
| 761 | 991 | } | |
| 762 | - | if maximized && i != w.Focus { | |
| 763 | - | // FullWidth hides other columns on screen. Their old buffers | |
| 764 | - | // cannot fit their current spiral rects; show only the maximized | |
| 765 | - | // column until navigation restores the normal layout. | |
| 992 | + | if maximized && i != w.Focus && w.sameBand(i, w.Focus) { | |
| 993 | + | // FullWidth hides the other columns of its band on screen. | |
| 994 | + | // Their old buffers cannot fit their current rects; show only | |
| 995 | + | // the maximized column until navigation restores the layout. | |
| 766 | 996 | for _, id := range c.Windows { | |
| 767 | 997 | tiles = append(tiles, Placement{ID: id, Hidden: true}) | |
| 768 | 998 | } | |
| @@ -772,52 +1002,92 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||
| 772 | 1002 | for j, t := range w.rowBuf { | |
| 773 | 1003 | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus}) | |
| 774 | 1004 | } | |
| 775 | - | if w.Overflow != OverflowFixed { | |
| 1005 | + | if w.Overflow != OverflowFixed && !w.policy().wraps { | |
| 776 | 1006 | span = max(span, r.X+r.W+g) | |
| 777 | 1007 | } | |
| 778 | 1008 | if i == w.Focus { | |
| 779 | 1009 | sel = r | |
| 780 | 1010 | } | |
| 781 | 1011 | } | |
| 1012 | + | if w.policy().wraps && len(w.Columns) > 0 { | |
| 1013 | + | span = w.Usable.W | |
| 1014 | + | } | |
| 782 | 1015 | return tiles, span, sel | |
| 783 | 1016 | } | |
| 784 | 1017 | ||
| 785 | - | // previewRow scales the tiles of w into a row whose top is at y, centred | |
| 786 | - | // in the usable width, or scrolled so the focused column shows when the | |
| 1018 | + | // previewRow scales the tiles of w into a row placed in slot, centred | |
| 1019 | + | // in the slot's width, or scrolled so the focused column shows when the | |
| 787 | 1020 | // row is wider, right of the stash pile. Dimmed rows (neighbor | |
| 788 | 1021 | // workspaces) have no focus; neither has a row whose pile has it (lit | |
| 789 | 1022 | // false). | |
| 790 | - | func (w *Workspace) previewRow(y int, dim, lit bool) []Placement { | |
| 1023 | + | func (w *Workspace) previewRow(axis layoutAxis, slot Rect, dim, lit bool) []Placement { | |
| 791 | 1024 | tiles, span, sel := w.previewTiles() | |
| 792 | - | return w.previewRowTiles(y, dim, lit, tiles, span, sel) | |
| 1025 | + | return w.previewRowTiles(axis, slot, dim, lit, w.policy().wraps, tiles, span, sel) | |
| 793 | 1026 | } | |
| 794 | 1027 | ||
| 795 | - | func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement { | |
| 796 | - | u := w.overviewArea() | |
| 1028 | + | // previewRowTiles scales tiles, laid out unscrolled over span with sel the | |
| 1029 | + | // focused one, into slot of an overview along axis. Placement stays inside | |
| 1030 | + | // slot. | |
| 1031 | + | // | |
| 1032 | + | // With lanes set (the tiles of a cascade workspace), the tiles carry their | |
| 1033 | + | // band in Y, one Usable.H apart. The band of sel is the selected lane, | |
| 1034 | + | // centred across the axis; the other bands sit a lane away from it, dimmed, | |
| 1035 | + | // and are hidden when they leave the area. A workspace whose lanes run along | |
| 1036 | + | // the overview axis shows only its selected lane. In a horizontal overview, | |
| 1037 | + | // tiles entirely outside the area are hidden. | |
| 1038 | + | func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes bool, tiles []Placement, span int, sel Rect) []Placement { | |
| 797 | 1039 | z := w.overviewZoom() | |
| 798 | 1040 | scale := func(v int) int { return int(math.Round(float64(v) * z)) } | |
| 799 | 1041 | width := scale(span) | |
| 1042 | + | u := w.overviewArea() | |
| 1043 | + | top := slot.Y | |
| 1044 | + | if axis == horizontalAxis { | |
| 1045 | + | top = slot.Y + (slot.H-scale(w.Usable.H))/2 | |
| 1046 | + | } | |
| 800 | 1047 | // Rows stay centred on the output like rows without a stash; the pile | |
| 801 | 1048 | // only pushes a row right when they would overlap. | |
| 802 | - | x := max(u.X+(u.W-width)/2, u.X+w.pileWidth()) | |
| 803 | 1049 | left := w.pileWidth() | |
| 804 | - | u.X, u.W = u.X+left, u.W-left | |
| 805 | - | if width > u.W { | |
| 806 | - | x = u.X + u.W/2 - scale(sel.X+sel.W/2) | |
| 807 | - | x = min(max(x, u.X+u.W-width), u.X) | |
| 1050 | + | x := max(slot.X+(slot.W-width)/2, slot.X+left) | |
| 1051 | + | ux, uw := slot.X+left, slot.W-left | |
| 1052 | + | if width > uw { | |
| 1053 | + | x = ux + uw/2 - scale(sel.X+sel.W/2) | |
| 1054 | + | x = min(max(x, ux+uw-width), ux) | |
| 1055 | + | } | |
| 1056 | + | stride := w.policy().content.span(w.Usable) | |
| 1057 | + | lanes = lanes && stride > 0 | |
| 1058 | + | // Lanes along the overview axis would overlap the neighbor workspaces. | |
| 1059 | + | otherLanes := w.policy().content != axis | |
| 1060 | + | laneStep := scale(stride) + max(1, u.H*3/100) | |
| 1061 | + | selLane := 0 | |
| 1062 | + | if lanes { | |
| 1063 | + | selLane = max(sel.Y, 0) / stride | |
| 808 | 1064 | } | |
| 809 | 1065 | for i := range tiles { | |
| 810 | 1066 | if tiles[i].Hidden { | |
| 811 | 1067 | continue | |
| 812 | 1068 | } | |
| 813 | 1069 | r := tiles[i].Rect | |
| 814 | - | x0, y0 := scale(r.X), scale(r.Y) | |
| 815 | - | tiles[i].Rect = Rect{X: x + x0, Y: y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0} | |
| 1070 | + | lane, ly := 0, r.Y | |
| 1071 | + | if lanes { | |
| 1072 | + | l := r.Y / stride | |
| 1073 | + | lane, ly = l-selLane, r.Y-l*stride | |
| 1074 | + | } | |
| 1075 | + | x0, y0 := scale(r.X), scale(ly) | |
| 1076 | + | tiles[i].Rect = Rect{X: x + x0, Y: top + y0 + lane*laneStep, W: scale(r.X+r.W) - x0, H: scale(ly+r.H) - y0} | |
| 816 | 1077 | tiles[i].Preview = z | |
| 817 | - | if dim { | |
| 1078 | + | if dim || lane != 0 { | |
| 818 | 1079 | tiles[i].peeking(1) | |
| 819 | 1080 | } | |
| 820 | - | tiles[i].Focused = tiles[i].Focused && !dim && lit | |
| 1081 | + | tiles[i].Focused = tiles[i].Focused && !dim && lit && lane == 0 | |
| 1082 | + | out := false | |
| 1083 | + | if axis == horizontalAxis { | |
| 1084 | + | // A row wider than its slot scrolls: what leaves the slot | |
| 1085 | + | // would sit on the neighbor workspaces. | |
| 1086 | + | out = tiles[i].Rect.X < ux || tiles[i].Rect.X+tiles[i].Rect.W > ux+uw | |
| 1087 | + | } | |
| 1088 | + | if lane != 0 && !otherLanes || out || (lanes || axis == horizontalAxis) && !tiles[i].Rect.Overlaps(u) { | |
| 1089 | + | tiles[i].Hidden, tiles[i].Preview, tiles[i].Focused = true, 0, false | |
| 1090 | + | } | |
| 821 | 1091 | } | |
| 822 | 1092 | return tiles | |
| 823 | 1093 | } | |
| @@ -921,10 +1191,14 @@ func (m *Monitor) overviewFocus(a Action) bool { | |||
| 921 | 1191 | m.OverviewMove(-1, 0) | |
| 922 | 1192 | case ActionFocusColumnRight: | |
| 923 | 1193 | m.OverviewMove(1, 0) | |
| 924 | - | case ActionFocusWindowUp, ActionFocusWorkspacePrev: | |
| 1194 | + | case ActionFocusWindowUp: | |
| 925 | 1195 | m.OverviewMove(0, -1) | |
| 926 | - | case ActionFocusWindowDown, ActionFocusWorkspaceNext: | |
| 1196 | + | case ActionFocusWindowDown: | |
| 927 | 1197 | m.OverviewMove(0, 1) | |
| 1198 | + | case ActionFocusWorkspacePrev: | |
| 1199 | + | m.overviewWorkspaceStep(-1) | |
| 1200 | + | case ActionFocusWorkspaceNext: | |
| 1201 | + | m.overviewWorkspaceStep(1) | |
| 928 | 1202 | default: | |
| 929 | 1203 | return false | |
| 930 | 1204 | } | |
| @@ -984,20 +1258,43 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |||
| 984 | 1258 | add(&m.ov.scrollY, a.Vertical) | |
| 985 | 1259 | } | |
| 986 | 1260 | add(&m.ov.scrollX, a.Horizontal) | |
| 1261 | + | // A step that turns the overview returns, so the axis holds for the loop. | |
| 1262 | + | axis := m.overviewAxis() | |
| 1263 | + | cascade := axis == horizontalAxis | |
| 987 | 1264 | for { | |
| 988 | 1265 | switch { | |
| 989 | 1266 | case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX): | |
| 990 | - | m.OverviewMove(0, sign(m.ov.scrollY)) | |
| 991 | - | m.ov.scrollY -= float64(sign(m.ov.scrollY)) * overviewScrollStep | |
| 1267 | + | d := sign(m.ov.scrollY) | |
| 1268 | + | // The step leaves before the move: a move that turns the | |
| 1269 | + | // overview drops the scroll left (showOverview). | |
| 1270 | + | m.ov.scrollY -= float64(d) * overviewScrollStep | |
| 992 | 1271 | m.ov.scrollX = 0 | |
| 1272 | + | if cascade && !finger && m.cardAt(m.Current()) < 0 { | |
| 1273 | + | m.overviewStep(d) | |
| 1274 | + | } else { | |
| 1275 | + | m.OverviewMove(0, d) | |
| 1276 | + | } | |
| 1277 | + | if m.overviewTurned(axis, finger) { | |
| 1278 | + | return true | |
| 1279 | + | } | |
| 993 | 1280 | if finger { | |
| 994 | 1281 | m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true | |
| 995 | 1282 | return true | |
| 996 | 1283 | } | |
| 997 | 1284 | case math.Abs(m.ov.scrollX) >= stepX: | |
| 998 | - | m.OverviewMove(sign(m.ov.scrollX), 0) | |
| 999 | - | m.ov.scrollX -= float64(sign(m.ov.scrollX)) * stepX | |
| 1285 | + | d := sign(m.ov.scrollX) | |
| 1286 | + | if cascade && finger { | |
| 1287 | + | // One workspace per gesture, even over a stash card. | |
| 1288 | + | m.overviewStep(d) | |
| 1289 | + | m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true | |
| 1290 | + | return true | |
| 1291 | + | } | |
| 1292 | + | m.ov.scrollX -= float64(d) * stepX | |
| 1000 | 1293 | m.ov.scrollY = 0 | |
| 1294 | + | m.OverviewMove(d, 0) | |
| 1295 | + | if m.overviewTurned(axis, finger) { | |
| 1296 | + | return true | |
| 1297 | + | } | |
| 1001 | 1298 | if finger { | |
| 1002 | 1299 | m.ov.sideways, stepX = true, overviewScrollRepeat | |
| 1003 | 1300 | } | |
| @@ -1008,6 +1305,17 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |||
| 1008 | 1305 | } | |
| 1009 | 1306 | } | |
| 1010 | 1307 | ||
| 1308 | + | // overviewTurned reports whether a scroll step turned the overview from the | |
| 1309 | + | // axis before. A finger scroll then waits for the fingers to lift, so it | |
| 1310 | + | // cannot bounce back. | |
| 1311 | + | func (m *Monitor) overviewTurned(before layoutAxis, finger bool) bool { | |
| 1312 | + | if m.overviewAxis() == before { | |
| 1313 | + | return false | |
| 1314 | + | } | |
| 1315 | + | m.ov.scrolled = m.ov.scrolled || finger | |
| 1316 | + | return true | |
| 1317 | + | } | |
| 1318 | + | ||
| 1011 | 1319 | // scrollStop ends a scroll on every monitor: the fingers lifted, maybe | |
| 1012 | 1320 | // after the pointer took the focus to another output, so the next scroll | |
| 1013 | 1321 | // starts from zero wherever it goes. | |
| @@ -31,6 +31,11 @@ type overviewState struct { | |||||
| 31 | scrolled, sideways bool | 31 | scrolled, sideways bool | ||
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
| 34 | + | // dropScroll forgets the scroll an overview axis accumulated, which belongs | |||
| 35 | + | // to the old axis once it turns. The finger latch (scrolled) stays: it waits | |||
| 36 | + | // for the fingers to lift. | |||
| 37 | + | func (o *overviewState) dropScroll() { o.scrollX, o.scrollY, o.sideways = 0, 0, false } | |||
| 38 | + | ||||
| 34 | // rowSnapshot is a row as the overview first showed it: browsing moves its | 39 | // rowSnapshot is a row as the overview first showed it: browsing moves its | ||
| 35 | // focus and scroll, closing puts them back. | 40 | // focus and scroll, closing puts them back. | ||
| 36 | type rowSnapshot struct { | 41 | type rowSnapshot struct { | ||
| @@ -353,6 +358,9 @@ func (m *Monitor) overviewWorkspaces() []*Workspace { | |||||
| 353 | // a named group uses its invocation anchor as the toggle return target, not | 358 | // a named group uses its invocation anchor as the toggle return target, not | ||
| 354 | // rows merely browsed in the overview. | 359 | // rows merely browsed in the overview. | ||
| 355 | func (m *Monitor) showOverview(w *Workspace) { | 360 | func (m *Monitor) showOverview(w *Workspace) { | ||
| 361 | + | if w.policy().workspace != m.overviewAxis() { | |||
| 362 | + | m.ov.dropScroll() | |||
| 363 | + | } | |||
| 356 | if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) { | 364 | if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) { | ||
| 357 | m.back = m.Current() | 365 | m.back = m.Current() | ||
| 358 | if indexOf(m.Workspaces, w.overviewAfter) >= 0 { | 366 | if indexOf(m.Workspaces, w.overviewAfter) >= 0 { | ||
| @@ -363,10 +371,120 @@ func (m *Monitor) showOverview(w *Workspace) { | |||||
| 363 | m.selectRow() | 371 | m.selectRow() | ||
| 364 | } | 372 | } | ||
| 365 | 373 | ||||
| 366 | - | // OverviewMove shares navigation rules across keys, scrolls and swipes. | 374 | + | // OverviewMove shares navigation rules across keys, scrolls and swipes. It |
| 367 | - | // Vertical moves stay in a selected stash pile; left stays, and right | 375 | + | // is the physical entry point: it maps dx and dy to the overview's moves. |
| 368 | - | // deselects it, even when the row has no main card. | |||
| 369 | func (m *Monitor) OverviewMove(dx, dy int) { | 376 | func (m *Monitor) OverviewMove(dx, dy int) { | ||
| 377 | + | if m.overviewAxis() == horizontalAxis { | |||
| 378 | + | if dy != 0 { | |||
| 379 | + | m.overviewLane(dy) | |||
| 380 | + | } else if dx != 0 { | |||
| 381 | + | m.overviewAcross(dx) | |||
| 382 | + | } | |||
| 383 | + | return | |||
| 384 | + | } | |||
| 385 | + | m.overviewMoveVertical(dx, dy) | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | // overviewWorkspaceStep is a focus-workspace bind. A vertical overview | |||
| 389 | + | // walks the stack cards before changing workspace (overviewVertical). A | |||
| 390 | + | // side-by-side one always changes workspace, since its stack fans left and | |||
| 391 | + | // right. A selected stash card cycles the pile first, in every layout. | |||
| 392 | + | func (m *Monitor) overviewWorkspaceStep(d int) { | |||
| 393 | + | if m.overviewAxis() == verticalAxis { | |||
| 394 | + | m.overviewVertical(d) | |||
| 395 | + | return | |||
| 396 | + | } | |||
| 397 | + | if !m.cycleCard(m.Current(), d) { | |||
| 398 | + | m.overviewStep(d) | |||
| 399 | + | } | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | // cycleCard brings the stash card d entries from the selected one of w to | |||
| 403 | + | // the front, stopping at either end. It reports whether a card was selected. | |||
| 404 | + | func (m *Monitor) cycleCard(w *Workspace, d int) bool { | |||
| 405 | + | at := m.cardAt(w) | |||
| 406 | + | if at < 0 { | |||
| 407 | + | return false | |||
| 408 | + | } | |||
| 409 | + | m.selectCard(w, min(max(at+d, 0), len(w.Stash)-1)) | |||
| 410 | + | return true | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | // overviewLane is the vertical move of an overview whose workspaces sit side | |||
| 414 | + | // by side (cascade): a selected stash card cycles the pile; else the | |||
| 415 | + | // selection goes to the previous or next lane (band), at the closest column, | |||
| 416 | + | // stopping at the first and last. Covering floats stack along the horizontal | |||
| 417 | + | // axis, not this one. | |||
| 418 | + | func (m *Monitor) overviewLane(dy int) { | |||
| 419 | + | w := m.Current() | |||
| 420 | + | if m.cycleCard(w, dy) || m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 { | |||
| 421 | + | return | |||
| 422 | + | } | |||
| 423 | + | if i := w.laneNeighbor(dy); i >= 0 { | |||
| 424 | + | m.selectLaneColumn(w, i) | |||
| 425 | + | } | |||
| 426 | + | } | |||
| 427 | + | ||||
| 428 | + | // selectLaneColumn selects column i of w. | |||
| 429 | + | func (m *Monitor) selectLaneColumn(w *Workspace, i int) { | |||
| 430 | + | w.selectOverviewColumn(i) | |||
| 431 | + | m.ov.selected = w.Columns[i].Windows[w.Columns[i].Focus] | |||
| 432 | + | m.ov.selectedAt = i | |||
| 433 | + | } | |||
| 434 | + | ||||
| 435 | + | // overviewAcross is the horizontal move of an overview whose workspaces sit | |||
| 436 | + | // side by side (cascade): a column inside the selected lane. Past the | |||
| 437 | + | // lane's first column it goes to the stash, else to the previous workspace; | |||
| 438 | + | // past the last column, to the next workspace. Covering floats fan along | |||
| 439 | + | // this axis and are traversed before the stash or a workspace: the card on | |||
| 440 | + | // the left is the one behind. | |||
| 441 | + | func (m *Monitor) overviewAcross(dx int) { | |||
| 442 | + | w := m.Current() | |||
| 443 | + | if m.cardAt(w) >= 0 { | |||
| 444 | + | if dx < 0 || len(w.stack()) == 0 { | |||
| 445 | + | // Left leaves the pile; a stash-only workspace has nothing | |||
| 446 | + | // to its right to select. | |||
| 447 | + | m.overviewStep(dx) | |||
| 448 | + | return | |||
| 449 | + | } | |||
| 450 | + | m.ov.card, m.ov.cardOf = 0, nil | |||
| 451 | + | m.ov.selected = 0 | |||
| 452 | + | if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 { | |||
| 453 | + | first, _ := w.laneBounds(w.laneOf(w.Focus)) | |||
| 454 | + | m.selectLaneColumn(w, first) | |||
| 455 | + | } | |||
| 456 | + | return | |||
| 457 | + | } | |||
| 458 | + | if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 { | |||
| 459 | + | m.laneEdge(w, dx) | |||
| 460 | + | return | |||
| 461 | + | } | |||
| 462 | + | first, last := w.laneBounds(w.laneOf(w.Focus)) | |||
| 463 | + | if next := w.Focus + dx; next >= first && next <= last { | |||
| 464 | + | m.selectLaneColumn(w, next) | |||
| 465 | + | return | |||
| 466 | + | } | |||
| 467 | + | m.laneEdge(w, dx) | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | // laneEdge is a horizontal move off the edge of the selected lane, or off a | |||
| 471 | + | // card that is not the columns: the stack first, then the stash on the left | |||
| 472 | + | // or a workspace. | |||
| 473 | + | func (m *Monitor) laneEdge(w *Workspace, dx int) { | |||
| 474 | + | if m.moveStack(-dx) { | |||
| 475 | + | return | |||
| 476 | + | } | |||
| 477 | + | if dx < 0 && len(w.Stash) > 0 { | |||
| 478 | + | m.selectCard(w, w.stashAt) | |||
| 479 | + | return | |||
| 480 | + | } | |||
| 481 | + | m.overviewStep(dx) | |||
| 482 | + | } | |||
| 483 | + | ||||
| 484 | + | // overviewMoveVertical is the move of an overview whose workspaces stack | |||
| 485 | + | // vertically. Vertical moves stay in a selected stash pile; left stays, and | |||
| 486 | + | // right deselects it, even when the row has no main card. | |||
| 487 | + | func (m *Monitor) overviewMoveVertical(dx, dy int) { | |||
| 370 | if dy != 0 { | 488 | if dy != 0 { | ||
| 371 | m.overviewVertical(dy) | 489 | m.overviewVertical(dy) | ||
| 372 | } else if dx != 0 { | 490 | } else if dx != 0 { | ||
| @@ -375,21 +493,27 @@ func (m *Monitor) OverviewMove(dx, dy int) { | |||||
| 375 | } | 493 | } | ||
| 376 | 494 | ||||
| 377 | func (m *Monitor) overviewVertical(dy int) { | 495 | func (m *Monitor) overviewVertical(dy int) { | ||
| 378 | - | w := m.Current() | 496 | + | if m.cycleCard(m.Current(), dy) { |
| 379 | - | if at := m.cardAt(w); at >= 0 { | |||
| 380 | - | m.selectCard(w, min(max(at+dy, 0), len(w.Stash)-1)) | |||
| 381 | return | 497 | return | ||
| 382 | } | 498 | } | ||
| 383 | if m.moveStack(-dy) { | 499 | if m.moveStack(-dy) { | ||
| 384 | return | 500 | return | ||
| 385 | } | 501 | } | ||
| 502 | + | m.overviewStep(dy) | |||
| 503 | + | } | |||
| 504 | + | ||||
| 505 | + | // overviewStep shows the workspace d rows from the current one; it stays | |||
| 506 | + | // on the first or last row. | |||
| 507 | + | func (m *Monitor) overviewStep(d int) { | |||
| 386 | rows := m.overviewWorkspaces() | 508 | rows := m.overviewWorkspaces() | ||
| 387 | - | i := indexOf(rows, w) + dy | 509 | + | i := indexOf(rows, m.Current()) + d |
| 388 | if i >= 0 && i < len(rows) { | 510 | if i >= 0 && i < len(rows) { | ||
| 389 | m.showOverview(rows[i]) | 511 | m.showOverview(rows[i]) | ||
| 390 | } | 512 | } | ||
| 391 | } | 513 | } | ||
| 392 | 514 | ||||
| 515 | + | // overviewHorizontal is the horizontal move of an overview whose workspaces | |||
| 516 | + | // stack vertically: it never changes workspace. | |||
| 393 | func (m *Monitor) overviewHorizontal(dx int) { | 517 | func (m *Monitor) overviewHorizontal(dx int) { | ||
| 394 | w := m.Current() | 518 | w := m.Current() | ||
| 395 | if m.cardAt(w) >= 0 { | 519 | if m.cardAt(w) >= 0 { | ||
| @@ -487,16 +611,37 @@ func (m *Monitor) OverviewPick(id WindowID) { | |||||
| 487 | m.closeOverview() | 611 | m.closeOverview() | ||
| 488 | } | 612 | } | ||
| 489 | 613 | ||||
| 490 | - | // overviewRows centers the selected row with its neighbors above and below. | 614 | + | // overviewAxis is the axis the overview lays workspaces out along, and the |
| 491 | - | // The numbered/named boundary gets extra spacing and an inactive horizontal | 615 | + | // stacked cards of a row fan along: the navigation axis of the current |
| 492 | - | // rule. Row widths, stash piles and preview scales remain unchanged. | 616 | + | // workspace's layout. |
| 493 | - | func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) { | 617 | + | func (m *Monitor) overviewAxis() layoutAxis { return m.Current().policy().workspace } |
| 618 | + | ||||
| 619 | + | // overviewRows places the current workspace and its overview neighbors in | |||
| 620 | + | // slots, in output-local pixels, along the overview axis; each workspace | |||
| 621 | + | // places its previews in its slot. The numbered/named boundary gets extra | |||
| 622 | + | // spacing and an inactive rule across the axis. | |||
| 623 | + | // | |||
| 624 | + | // Vertical axis: full-width slots, the current one centred; the rule is | |||
| 625 | + | // horizontal. Horizontal axis: full-height slots as wide as their content, | |||
| 626 | + | // stash pile and stack peeks included, the current one centred; the rule is | |||
| 627 | + | // vertical, in the spacing between two slots. | |||
| 628 | + | func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) { | |||
| 494 | cur := m.Current() | 629 | cur := m.Current() | ||
| 495 | u := cur.overviewArea() | 630 | u := cur.overviewArea() | ||
| 496 | gap := max(1, u.H*3/100) | 631 | gap := max(1, u.H*3/100) | ||
| 497 | - | hc := cur.rowHeight() | 632 | + | axis := m.overviewAxis() |
| 498 | - | y := u.Y + (u.H-hc)/2 | 633 | + | var cs Rect |
| 499 | - | rows := map[*Workspace]int{cur: y} | 634 | + | if axis == horizontalAxis { |
| 635 | + | // The row stays centred on the area; the pile extends the slot to | |||
| 636 | + | // the left, pushing the previous workspace, unless it would leave | |||
| 637 | + | // the area. | |||
| 638 | + | pile, row := cur.pileWidth(), cur.rowWidth() | |||
| 639 | + | cs = Rect{X: max(u.X+(u.W-row)/2-pile, u.X), Y: u.Y, W: pile + row, H: u.H} | |||
| 640 | + | } else { | |||
| 641 | + | hc := cur.rowHeight() | |||
| 642 | + | cs = Rect{X: u.X, Y: u.Y + (u.H-hc)/2, W: u.W, H: hc} | |||
| 643 | + | } | |||
| 644 | + | rows := map[*Workspace]Rect{cur: cs} | |||
| 500 | var dividers []ports.Separator | 645 | var dividers []ports.Separator | ||
| 501 | list := m.overviewWorkspaces() | 646 | list := m.overviewWorkspaces() | ||
| 502 | i := indexOf(list, cur) | 647 | i := indexOf(list, cur) | ||
| @@ -510,33 +655,66 @@ func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) { | |||||
| 510 | if boundary { | 655 | if boundary { | ||
| 511 | spacing *= 2 | 656 | spacing *= 2 | ||
| 512 | } | 657 | } | ||
| 513 | - | top := y + hc | 658 | + | slot, gapAt := neighborSlot(axis, u, cs, w, next < i, spacing) |
| 514 | - | if next < i { | 659 | + | rows[w] = slot |
| 515 | - | rows[w] = y - spacing - w.rowHeight() | |||
| 516 | - | top = y - spacing | |||
| 517 | - | } else { | |||
| 518 | - | rows[w] = y + hc + spacing | |||
| 519 | - | } | |||
| 520 | if boundary { | 660 | if boundary { | ||
| 521 | - | margin := u.W * 4 / 100 | 661 | + | dividers = append(dividers, overviewDivider(axis, u, gapAt+spacing/2)) |
| 522 | - | dividers = append(dividers, ports.Separator{Rect: Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1}}) | |||
| 523 | } | 662 | } | ||
| 524 | } | 663 | } | ||
| 525 | return rows, dividers | 664 | return rows, dividers | ||
| 526 | } | 665 | } | ||
| 527 | 666 | ||||
| 667 | + | // neighborSlot is the slot of w, the neighbor before or after the current | |||
| 668 | + | // slot cs along axis, spacing away; gapAt is where the spacing begins. | |||
| 669 | + | func neighborSlot(axis layoutAxis, u, cs Rect, w *Workspace, before bool, spacing int) (slot Rect, gapAt int) { | |||
| 670 | + | if axis == horizontalAxis { | |||
| 671 | + | slot = Rect{Y: u.Y, W: w.slotWidth(), H: u.H} | |||
| 672 | + | if before { | |||
| 673 | + | gapAt = cs.X - spacing | |||
| 674 | + | slot.X = gapAt - slot.W | |||
| 675 | + | } else { | |||
| 676 | + | gapAt = cs.X + cs.W | |||
| 677 | + | slot.X = gapAt + spacing | |||
| 678 | + | } | |||
| 679 | + | return slot, gapAt | |||
| 680 | + | } | |||
| 681 | + | h := w.rowHeight() | |||
| 682 | + | slot = Rect{X: u.X, W: u.W, H: h} | |||
| 683 | + | if before { | |||
| 684 | + | gapAt = cs.Y - spacing | |||
| 685 | + | slot.Y = gapAt - h | |||
| 686 | + | } else { | |||
| 687 | + | gapAt = cs.Y + cs.H | |||
| 688 | + | slot.Y = gapAt + spacing | |||
| 689 | + | } | |||
| 690 | + | return slot, gapAt | |||
| 691 | + | } | |||
| 692 | + | ||||
| 693 | + | // overviewDivider is the inactive rule across axis at pos, inset from the | |||
| 694 | + | // edges of u. | |||
| 695 | + | func overviewDivider(axis layoutAxis, u Rect, pos int) ports.Separator { | |||
| 696 | + | if axis == horizontalAxis { | |||
| 697 | + | return ports.Separator{Rect: Rect{X: pos, Y: u.Y + u.H*4/100, W: 1, H: u.H * 92 / 100}} | |||
| 698 | + | } | |||
| 699 | + | margin := u.W * 4 / 100 | |||
| 700 | + | return ports.Separator{Rect: Rect{X: u.X + margin, Y: pos, W: u.W - 2*margin, H: 1}} | |||
| 701 | + | } | |||
| 702 | + | ||||
| 528 | // overviewLayout places the current workspace and its overview neighbors, | 703 | // overviewLayout places the current workspace and its overview neighbors, | ||
| 529 | - | // each with its full-width stash pile and covering-float stack. Other rows | 704 | + | // each with its stash pile and covering-float stack. Other rows |
| 530 | // stay hidden; numbered and named workspaces share the same preview geometry. | 705 | // stay hidden; numbered and named workspaces share the same preview geometry. | ||
| 531 | func (m *Monitor) overviewLayout(result []Placement) []Placement { | 706 | func (m *Monitor) overviewLayout(result []Placement) []Placement { | ||
| 532 | cur := m.Current() | 707 | cur := m.Current() | ||
| 533 | rows, _ := m.overviewRows() | 708 | rows, _ := m.overviewRows() | ||
| 709 | + | axis := m.overviewAxis() | |||
| 534 | for w := range m.all() { | 710 | for w := range m.all() { | ||
| 535 | - | ry, shown := rows[w] | 711 | + | slot, shown := rows[w] |
| 536 | if shown { | 712 | if shown { | ||
| 537 | at := m.cardAt(w) | 713 | at := m.cardAt(w) | ||
| 538 | - | result = append(result, m.stackRow(w, ry, w != cur, at < 0)...) | 714 | + | result = append(result, m.stackRow(w, axis, slot, w != cur, at < 0)...) |
| 539 | - | result = append(result, w.pile(ry, w != cur, at)...) | 715 | + | if len(w.Stash) > 0 { |
| 716 | + | result = append(result, w.pile(w.pileBand(axis, slot), w != cur, at)...) | |||
| 717 | + | } | |||
| 540 | } | 718 | } | ||
| 541 | for _, f := range w.Floats { | 719 | for _, f := range w.Floats { | ||
| 542 | if _, drawn := w.itemOf(f.ID); !shown || !drawn { | 720 | if _, drawn := w.itemOf(f.ID); !shown || !drawn { | ||
| @@ -637,11 +815,11 @@ func (w *Workspace) cardStep() int { | |||||
| 637 | return min(overviewCardStep, a.W/100, a.H/50) | 815 | return min(overviewCardStep, a.W/100, a.H/50) | ||
| 638 | } | 816 | } | ||
| 639 | 817 | ||||
| 640 | - | // pile places the stash of w as cards on the left of the row whose top is | 818 | + | // pile places the stash of w as cards on the left of band (see pileBand): |
| 641 | - | // at y: entry front (the stash selection when -1) in front, the next ones | 819 | + | // entry front (the stash selection when -1) in |
| 642 | - | // behind it, dimmed, the rest hidden. Only a front card of the current row | 820 | + | // front, the next ones behind it, dimmed, the rest hidden. Only a front card |
| 643 | - | // (dim false) with front set is selected. | 821 | + | // of the current row (dim false) with front set is selected. |
| 644 | - | func (w *Workspace) pile(y int, dim bool, front int) []Placement { | 822 | + | func (w *Workspace) pile(band Rect, dim bool, front int) []Placement { |
| 645 | n := len(w.Stash) | 823 | n := len(w.Stash) | ||
| 646 | if n == 0 { | 824 | if n == 0 { | ||
| 647 | return nil | 825 | return nil | ||
| @@ -658,7 +836,7 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement { | |||||
| 658 | rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom())) | 836 | rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom())) | ||
| 659 | // Stack rows reserve two peek steps above the front card. Align the | 837 | // Stack rows reserve two peek steps above the front card. Align the | ||
| 660 | // stash with that front band, not with the top of its envelope. | 838 | // stash with that front band, not with the top of its envelope. | ||
| 661 | - | x0, y0 := u.X+u.W/100, w.frontRowY(y)+(rowH-ch)/2 | 839 | + | x0, y0 := band.X+u.W/100, band.Y+(rowH-ch)/2 |
| 662 | cards := make([][]Placement, n) | 840 | cards := make([][]Placement, n) | ||
| 663 | for i := range w.Stash { | 841 | for i := range w.Stash { | ||
| 664 | cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: Rect{X: x0, Y: y0, W: cw, H: ch}, Preview: overviewCardZoom, Focused: selected}} | 842 | cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: Rect{X: x0, Y: y0, W: cw, H: ch}, Preview: overviewCardZoom, Focused: selected}} | ||
| @@ -672,19 +850,48 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement { | |||||
| 672 | // overviewZoom is the scale that fits every column of w in the usable | 850 | // overviewZoom is the scale that fits every column of w in the usable | ||
| 673 | // width, within the overview bounds. | 851 | // width, within the overview bounds. | ||
| 674 | func (w *Workspace) overviewZoom() float64 { | 852 | func (w *Workspace) overviewZoom() float64 { | ||
| 853 | + | z, _ := w.zoomSpan() | |||
| 854 | + | return z | |||
| 855 | + | } | |||
| 856 | + | ||||
| 857 | + | // zoomSpan is overviewZoom with the unscaled span of the tiles it fits. A | |||
| 858 | + | // cascade workspace spans one screen whatever its band count. | |||
| 859 | + | func (w *Workspace) zoomSpan() (float64, int) { | |||
| 675 | // Only the span is read: the tiles go in the zoom scratch, dead on return. | 860 | // Only the span is read: the tiles go in the zoom scratch, dead on return. | ||
| 676 | var span int | 861 | var span int | ||
| 677 | w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0]) | 862 | w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0]) | ||
| 678 | if span <= 0 { | 863 | if span <= 0 { | ||
| 679 | - | return overviewMaxZoom | 864 | + | return overviewMaxZoom, span |
| 680 | } | 865 | } | ||
| 681 | room := float64(w.overviewArea().W-w.pileWidth()) * 0.96 | 866 | room := float64(w.overviewArea().W-w.pileWidth()) * 0.96 | ||
| 682 | z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom) | 867 | z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom) | ||
| 683 | - | if len(w.stack()) > 1 { | 868 | + | if w.stacked() { |
| 684 | // Reserve two clear peeks on either side of the front row. | 869 | // Reserve two clear peeks on either side of the front row. | ||
| 685 | z = min(z, overviewMaxZoom/(1+4*overviewPeekStep)) | 870 | z = min(z, overviewMaxZoom/(1+4*overviewPeekStep)) | ||
| 686 | } | 871 | } | ||
| 687 | - | return z | 872 | + | return z, span |
| 873 | + | } | |||
| 874 | + | ||||
| 875 | + | // slotWidth is the width of the slot of w in a horizontal overview: its | |||
| 876 | + | // stash pile and its row. | |||
| 877 | + | func (w *Workspace) slotWidth() int { | |||
| 878 | + | return w.pileWidth() + w.rowWidth() | |||
| 879 | + | } | |||
| 880 | + | ||||
| 881 | + | // rowWidth is the width of the row of w in a horizontal overview: what its | |||
| 882 | + | // tiles take (at most what the pile leaves of the area), plus the room for | |||
| 883 | + | // the covering floats peeking on either side of the front card. | |||
| 884 | + | func (w *Workspace) rowWidth() int { | |||
| 885 | + | z, span := w.zoomSpan() | |||
| 886 | + | if span <= 0 { | |||
| 887 | + | // An empty workspace keeps the room of a screen. | |||
| 888 | + | span = w.Usable.W | |||
| 889 | + | } | |||
| 890 | + | width := min(int(math.Round(float64(span)*z)), w.overviewArea().W-w.pileWidth()) | |||
| 891 | + | if w.stacked() { | |||
| 892 | + | width += 4 * w.peekStep() | |||
| 893 | + | } | |||
| 894 | + | return width | |||
| 688 | } | 895 | } | ||
| 689 | 896 | ||||
| 690 | // peekStep is the shared step for a stack row and its front-card inset. | 897 | // peekStep is the shared step for a stack row and its front-card inset. | ||
| @@ -692,20 +899,38 @@ func (w *Workspace) peekStep() int { | |||||
| 692 | return max(1, int(math.Round(float64(w.Usable.H)*w.overviewZoom()*overviewPeekStep))) | 899 | return max(1, int(math.Round(float64(w.Usable.H)*w.overviewZoom()*overviewPeekStep))) | ||
| 693 | } | 900 | } | ||
| 694 | 901 | ||||
| 695 | - | // frontRowY skips the space reserved for two cards peeking above the front. | 902 | + | // frontSlot is the part of slot the front card of w takes: slot shifted |
| 696 | - | // A single-card row starts at y, as before. | 903 | + | // along axis past the room reserved for two cards peeking before it, and |
| 697 | - | func (w *Workspace) frontRowY(y int) int { | 904 | + | // (along a horizontal axis, where the slot is as wide as its cards) without |
| 698 | - | if len(w.stack()) > 1 { | 905 | + | // the room of the two after it. A single-card row keeps slot. |
| 699 | - | return y + 2*w.peekStep() | 906 | + | func (w *Workspace) frontSlot(axis layoutAxis, slot Rect) Rect { |
| 907 | + | if w.stacked() { | |||
| 908 | + | p := w.peekStep() | |||
| 909 | + | axis.offset(&slot, 2*p) | |||
| 910 | + | if axis == horizontalAxis { | |||
| 911 | + | slot.W -= 4 * p | |||
| 912 | + | } | |||
| 700 | } | 913 | } | ||
| 701 | - | return y | 914 | + | return slot |
| 915 | + | } | |||
| 916 | + | ||||
| 917 | + | // pileBand is the band the stash pile of w aligns with in its slot. A | |||
| 918 | + | // vertical overview aligns it with the front card (see frontSlot). A | |||
| 919 | + | // horizontal one puts it level with the selected lane, left of the whole | |||
| 920 | + | // stack of cards. | |||
| 921 | + | func (w *Workspace) pileBand(axis layoutAxis, slot Rect) Rect { | |||
| 922 | + | if axis == verticalAxis { | |||
| 923 | + | return w.frontSlot(axis, slot) | |||
| 924 | + | } | |||
| 925 | + | slot.Y += (slot.H - int(math.Round(float64(w.Usable.H)*w.overviewZoom()))) / 2 | |||
| 926 | + | return slot | |||
| 702 | } | 927 | } | ||
| 703 | 928 | ||||
| 704 | // rowHeight includes the largest possible stack envelope (two peeks above | 929 | // rowHeight includes the largest possible stack envelope (two peeks above | ||
| 705 | // and two below). Neighbor rows use the same measure as the current row. | 930 | // and two below). Neighbor rows use the same measure as the current row. | ||
| 706 | func (w *Workspace) rowHeight() int { | 931 | func (w *Workspace) rowHeight() int { | ||
| 707 | h := int(math.Round(float64(w.Usable.H) * w.overviewZoom())) | 932 | h := int(math.Round(float64(w.Usable.H) * w.overviewZoom())) | ||
| 708 | - | if len(w.stack()) > 1 { | 933 | + | if w.stacked() { |
| 709 | h += 4 * w.peekStep() | 934 | h += 4 * w.peekStep() | ||
| 710 | } | 935 | } | ||
| 711 | return h | 936 | return h | ||
| @@ -745,13 +970,18 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||||
| 745 | rects = w.colBuf | 970 | rects = w.colBuf | ||
| 746 | span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile. | 971 | span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile. | ||
| 747 | } | 972 | } | ||
| 748 | - | maximized := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth | 973 | + | maximized := w.policy().equalCells && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth |
| 749 | for i, c := range w.Columns { | 974 | for i, c := range w.Columns { | ||
| 750 | r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h} | 975 | r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h} | ||
| 751 | if w.policy().wraps { | 976 | if w.policy().wraps { | ||
| 752 | - | width := w.cellWidth() | 977 | + | // The band is the lane: it is encoded in Y, one screen apart. |
| 753 | - | r.X = w.band(i)*w.Usable.W + w.cellX(i) | 978 | + | // A maximized column fills its band as on screen. |
| 754 | - | r.W = width | 979 | + | r.X = w.cellX(i) |
| 980 | + | r.Y = g + w.band(i)*w.Usable.H | |||
| 981 | + | r.W = w.cellWidth() | |||
| 982 | + | if c.FullWidth { | |||
| 983 | + | r.X, r.W = g, max(w.Usable.W-2*g, 0) | |||
| 984 | + | } | |||
| 755 | } else if w.Overflow == OverflowFixed { | 985 | } else if w.Overflow == OverflowFixed { | ||
| 756 | r = rects[i] | 986 | r = rects[i] | ||
| 757 | r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y | 987 | r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y | ||
| @@ -759,10 +989,10 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||||
| 759 | // A fullscreen column shows at the width it had in the row. | 989 | // A fullscreen column shows at the width it had in the row. | ||
| 760 | r.W = max(w.Usable.W-2*g, 0) | 990 | r.W = max(w.Usable.W-2*g, 0) | ||
| 761 | } | 991 | } | ||
| 762 | - | if maximized && i != w.Focus { | 992 | + | if maximized && i != w.Focus && w.sameBand(i, w.Focus) { |
| 763 | - | // FullWidth hides other columns on screen. Their old buffers | 993 | + | // FullWidth hides the other columns of its band on screen. |
| 764 | - | // cannot fit their current spiral rects; show only the maximized | 994 | + | // Their old buffers cannot fit their current rects; show only |
| 765 | - | // column until navigation restores the normal layout. | 995 | + | // the maximized column until navigation restores the layout. |
| 766 | for _, id := range c.Windows { | 996 | for _, id := range c.Windows { | ||
| 767 | tiles = append(tiles, Placement{ID: id, Hidden: true}) | 997 | tiles = append(tiles, Placement{ID: id, Hidden: true}) | ||
| 768 | } | 998 | } | ||
| @@ -772,52 +1002,92 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||||
| 772 | for j, t := range w.rowBuf { | 1002 | for j, t := range w.rowBuf { | ||
| 773 | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus}) | 1003 | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus}) | ||
| 774 | } | 1004 | } | ||
| 775 | - | if w.Overflow != OverflowFixed { | 1005 | + | if w.Overflow != OverflowFixed && !w.policy().wraps { |
| 776 | span = max(span, r.X+r.W+g) | 1006 | span = max(span, r.X+r.W+g) | ||
| 777 | } | 1007 | } | ||
| 778 | if i == w.Focus { | 1008 | if i == w.Focus { | ||
| 779 | sel = r | 1009 | sel = r | ||
| 780 | } | 1010 | } | ||
| 781 | } | 1011 | } | ||
| 1012 | + | if w.policy().wraps && len(w.Columns) > 0 { | |||
| 1013 | + | span = w.Usable.W | |||
| 1014 | + | } | |||
| 782 | return tiles, span, sel | 1015 | return tiles, span, sel | ||
| 783 | } | 1016 | } | ||
| 784 | 1017 | ||||
| 785 | - | // previewRow scales the tiles of w into a row whose top is at y, centred | 1018 | + | // previewRow scales the tiles of w into a row placed in slot, centred |
| 786 | - | // in the usable width, or scrolled so the focused column shows when the | 1019 | + | // in the slot's width, or scrolled so the focused column shows when the |
| 787 | // row is wider, right of the stash pile. Dimmed rows (neighbor | 1020 | // row is wider, right of the stash pile. Dimmed rows (neighbor | ||
| 788 | // workspaces) have no focus; neither has a row whose pile has it (lit | 1021 | // workspaces) have no focus; neither has a row whose pile has it (lit | ||
| 789 | // false). | 1022 | // false). | ||
| 790 | - | func (w *Workspace) previewRow(y int, dim, lit bool) []Placement { | 1023 | + | func (w *Workspace) previewRow(axis layoutAxis, slot Rect, dim, lit bool) []Placement { |
| 791 | tiles, span, sel := w.previewTiles() | 1024 | tiles, span, sel := w.previewTiles() | ||
| 792 | - | return w.previewRowTiles(y, dim, lit, tiles, span, sel) | 1025 | + | return w.previewRowTiles(axis, slot, dim, lit, w.policy().wraps, tiles, span, sel) |
| 793 | } | 1026 | } | ||
| 794 | 1027 | ||||
| 795 | - | func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement { | 1028 | + | // previewRowTiles scales tiles, laid out unscrolled over span with sel the |
| 796 | - | u := w.overviewArea() | 1029 | + | // focused one, into slot of an overview along axis. Placement stays inside |
| 1030 | + | // slot. | |||
| 1031 | + | // | |||
| 1032 | + | // With lanes set (the tiles of a cascade workspace), the tiles carry their | |||
| 1033 | + | // band in Y, one Usable.H apart. The band of sel is the selected lane, | |||
| 1034 | + | // centred across the axis; the other bands sit a lane away from it, dimmed, | |||
| 1035 | + | // and are hidden when they leave the area. A workspace whose lanes run along | |||
| 1036 | + | // the overview axis shows only its selected lane. In a horizontal overview, | |||
| 1037 | + | // tiles entirely outside the area are hidden. | |||
| 1038 | + | func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes bool, tiles []Placement, span int, sel Rect) []Placement { | |||
| 797 | z := w.overviewZoom() | 1039 | z := w.overviewZoom() | ||
| 798 | scale := func(v int) int { return int(math.Round(float64(v) * z)) } | 1040 | scale := func(v int) int { return int(math.Round(float64(v) * z)) } | ||
| 799 | width := scale(span) | 1041 | width := scale(span) | ||
| 1042 | + | u := w.overviewArea() | |||
| 1043 | + | top := slot.Y | |||
| 1044 | + | if axis == horizontalAxis { | |||
| 1045 | + | top = slot.Y + (slot.H-scale(w.Usable.H))/2 | |||
| 1046 | + | } | |||
| 800 | // Rows stay centred on the output like rows without a stash; the pile | 1047 | // Rows stay centred on the output like rows without a stash; the pile | ||
| 801 | // only pushes a row right when they would overlap. | 1048 | // only pushes a row right when they would overlap. | ||
| 802 | - | x := max(u.X+(u.W-width)/2, u.X+w.pileWidth()) | |||
| 803 | left := w.pileWidth() | 1049 | left := w.pileWidth() | ||
| 804 | - | u.X, u.W = u.X+left, u.W-left | 1050 | + | x := max(slot.X+(slot.W-width)/2, slot.X+left) |
| 805 | - | if width > u.W { | 1051 | + | ux, uw := slot.X+left, slot.W-left |
| 806 | - | x = u.X + u.W/2 - scale(sel.X+sel.W/2) | 1052 | + | if width > uw { |
| 807 | - | x = min(max(x, u.X+u.W-width), u.X) | 1053 | + | x = ux + uw/2 - scale(sel.X+sel.W/2) |
| 1054 | + | x = min(max(x, ux+uw-width), ux) | |||
| 1055 | + | } | |||
| 1056 | + | stride := w.policy().content.span(w.Usable) | |||
| 1057 | + | lanes = lanes && stride > 0 | |||
| 1058 | + | // Lanes along the overview axis would overlap the neighbor workspaces. | |||
| 1059 | + | otherLanes := w.policy().content != axis | |||
| 1060 | + | laneStep := scale(stride) + max(1, u.H*3/100) | |||
| 1061 | + | selLane := 0 | |||
| 1062 | + | if lanes { | |||
| 1063 | + | selLane = max(sel.Y, 0) / stride | |||
| 808 | } | 1064 | } | ||
| 809 | for i := range tiles { | 1065 | for i := range tiles { | ||
| 810 | if tiles[i].Hidden { | 1066 | if tiles[i].Hidden { | ||
| 811 | continue | 1067 | continue | ||
| 812 | } | 1068 | } | ||
| 813 | r := tiles[i].Rect | 1069 | r := tiles[i].Rect | ||
| 814 | - | x0, y0 := scale(r.X), scale(r.Y) | 1070 | + | lane, ly := 0, r.Y |
| 815 | - | tiles[i].Rect = Rect{X: x + x0, Y: y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0} | 1071 | + | if lanes { |
| 1072 | + | l := r.Y / stride | |||
| 1073 | + | lane, ly = l-selLane, r.Y-l*stride | |||
| 1074 | + | } | |||
| 1075 | + | x0, y0 := scale(r.X), scale(ly) | |||
| 1076 | + | tiles[i].Rect = Rect{X: x + x0, Y: top + y0 + lane*laneStep, W: scale(r.X+r.W) - x0, H: scale(ly+r.H) - y0} | |||
| 816 | tiles[i].Preview = z | 1077 | tiles[i].Preview = z | ||
| 817 | - | if dim { | 1078 | + | if dim || lane != 0 { |
| 818 | tiles[i].peeking(1) | 1079 | tiles[i].peeking(1) | ||
| 819 | } | 1080 | } | ||
| 820 | - | tiles[i].Focused = tiles[i].Focused && !dim && lit | 1081 | + | tiles[i].Focused = tiles[i].Focused && !dim && lit && lane == 0 |
| 1082 | + | out := false | |||
| 1083 | + | if axis == horizontalAxis { | |||
| 1084 | + | // A row wider than its slot scrolls: what leaves the slot | |||
| 1085 | + | // would sit on the neighbor workspaces. | |||
| 1086 | + | out = tiles[i].Rect.X < ux || tiles[i].Rect.X+tiles[i].Rect.W > ux+uw | |||
| 1087 | + | } | |||
| 1088 | + | if lane != 0 && !otherLanes || out || (lanes || axis == horizontalAxis) && !tiles[i].Rect.Overlaps(u) { | |||
| 1089 | + | tiles[i].Hidden, tiles[i].Preview, tiles[i].Focused = true, 0, false | |||
| 1090 | + | } | |||
| 821 | } | 1091 | } | ||
| 822 | return tiles | 1092 | return tiles | ||
| 823 | } | 1093 | } | ||
| @@ -921,10 +1191,14 @@ func (m *Monitor) overviewFocus(a Action) bool { | |||||
| 921 | m.OverviewMove(-1, 0) | 1191 | m.OverviewMove(-1, 0) | ||
| 922 | case ActionFocusColumnRight: | 1192 | case ActionFocusColumnRight: | ||
| 923 | m.OverviewMove(1, 0) | 1193 | m.OverviewMove(1, 0) | ||
| 924 | - | case ActionFocusWindowUp, ActionFocusWorkspacePrev: | 1194 | + | case ActionFocusWindowUp: |
| 925 | m.OverviewMove(0, -1) | 1195 | m.OverviewMove(0, -1) | ||
| 926 | - | case ActionFocusWindowDown, ActionFocusWorkspaceNext: | 1196 | + | case ActionFocusWindowDown: |
| 927 | m.OverviewMove(0, 1) | 1197 | m.OverviewMove(0, 1) | ||
| 1198 | + | case ActionFocusWorkspacePrev: | |||
| 1199 | + | m.overviewWorkspaceStep(-1) | |||
| 1200 | + | case ActionFocusWorkspaceNext: | |||
| 1201 | + | m.overviewWorkspaceStep(1) | |||
| 928 | default: | 1202 | default: | ||
| 929 | return false | 1203 | return false | ||
| 930 | } | 1204 | } | ||
| @@ -984,20 +1258,43 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |||||
| 984 | add(&m.ov.scrollY, a.Vertical) | 1258 | add(&m.ov.scrollY, a.Vertical) | ||
| 985 | } | 1259 | } | ||
| 986 | add(&m.ov.scrollX, a.Horizontal) | 1260 | add(&m.ov.scrollX, a.Horizontal) | ||
| 1261 | + | // A step that turns the overview returns, so the axis holds for the loop. | |||
| 1262 | + | axis := m.overviewAxis() | |||
| 1263 | + | cascade := axis == horizontalAxis | |||
| 987 | for { | 1264 | for { | ||
| 988 | switch { | 1265 | switch { | ||
| 989 | case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX): | 1266 | case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX): | ||
| 990 | - | m.OverviewMove(0, sign(m.ov.scrollY)) | 1267 | + | d := sign(m.ov.scrollY) |
| 991 | - | m.ov.scrollY -= float64(sign(m.ov.scrollY)) * overviewScrollStep | 1268 | + | // The step leaves before the move: a move that turns the |
| 1269 | + | // overview drops the scroll left (showOverview). | |||
| 1270 | + | m.ov.scrollY -= float64(d) * overviewScrollStep | |||
| 992 | m.ov.scrollX = 0 | 1271 | m.ov.scrollX = 0 | ||
| 1272 | + | if cascade && !finger && m.cardAt(m.Current()) < 0 { | |||
| 1273 | + | m.overviewStep(d) | |||
| 1274 | + | } else { | |||
| 1275 | + | m.OverviewMove(0, d) | |||
| 1276 | + | } | |||
| 1277 | + | if m.overviewTurned(axis, finger) { | |||
| 1278 | + | return true | |||
| 1279 | + | } | |||
| 993 | if finger { | 1280 | if finger { | ||
| 994 | m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true | 1281 | m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true | ||
| 995 | return true | 1282 | return true | ||
| 996 | } | 1283 | } | ||
| 997 | case math.Abs(m.ov.scrollX) >= stepX: | 1284 | case math.Abs(m.ov.scrollX) >= stepX: | ||
| 998 | - | m.OverviewMove(sign(m.ov.scrollX), 0) | 1285 | + | d := sign(m.ov.scrollX) |
| 999 | - | m.ov.scrollX -= float64(sign(m.ov.scrollX)) * stepX | 1286 | + | if cascade && finger { |
| 1287 | + | // One workspace per gesture, even over a stash card. | |||
| 1288 | + | m.overviewStep(d) | |||
| 1289 | + | m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true | |||
| 1290 | + | return true | |||
| 1291 | + | } | |||
| 1292 | + | m.ov.scrollX -= float64(d) * stepX | |||
| 1000 | m.ov.scrollY = 0 | 1293 | m.ov.scrollY = 0 | ||
| 1294 | + | m.OverviewMove(d, 0) | |||
| 1295 | + | if m.overviewTurned(axis, finger) { | |||
| 1296 | + | return true | |||
| 1297 | + | } | |||
| 1001 | if finger { | 1298 | if finger { | ||
| 1002 | m.ov.sideways, stepX = true, overviewScrollRepeat | 1299 | m.ov.sideways, stepX = true, overviewScrollRepeat | ||
| 1003 | } | 1300 | } | ||
| @@ -1008,6 +1305,17 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |||||
| 1008 | } | 1305 | } | ||
| 1009 | } | 1306 | } | ||
| 1010 | 1307 | ||||
| 1308 | + | // overviewTurned reports whether a scroll step turned the overview from the | |||
| 1309 | + | // axis before. A finger scroll then waits for the fingers to lift, so it | |||
| 1310 | + | // cannot bounce back. | |||
| 1311 | + | func (m *Monitor) overviewTurned(before layoutAxis, finger bool) bool { | |||
| 1312 | + | if m.overviewAxis() == before { | |||
| 1313 | + | return false | |||
| 1314 | + | } | |||
| 1315 | + | m.ov.scrolled = m.ov.scrolled || finger | |||
| 1316 | + | return true | |||
| 1317 | + | } | |||
| 1318 | + | ||||
| 1011 | // scrollStop ends a scroll on every monitor: the fingers lifted, maybe | 1319 | // scrollStop ends a scroll on every monitor: the fingers lifted, maybe | ||
| 1012 | // after the pointer took the focus to another output, so the next scroll | 1320 | // after the pointer took the focus to another output, so the next scroll | ||
| 1013 | // starts from zero wherever it goes. | 1321 | // starts from zero wherever it goes. | ||
A internal/core/overview_cascade_test.go +894 -0
| @@ -0,0 +1,894 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "slices" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // cascadeOverview is overviewMonitor with workspace 2 (current) a cascade of | |
| 12 | + | // seven columns, 4..10, in three bands of three (the last holds 10 alone), | |
| 13 | + | // the focus on column 8 (band 2, middle). Workspace 1 (columns 1..3) is a | |
| 14 | + | // scroll workspace on its left, workspace 3 (column 30) on its right. | |
| 15 | + | func cascadeOverview() *Monitor { | |
| 16 | + | m := overviewMonitor() | |
| 17 | + | m.SetBorder(2) | |
| 18 | + | m.Focus(1) | |
| 19 | + | w := m.Current() | |
| 20 | + | w.Overflow = OverflowCascade | |
| 21 | + | for id := WindowID(5); id <= 10; id++ { | |
| 22 | + | w.AddWindow(id) | |
| 23 | + | } | |
| 24 | + | m.Focus(2) | |
| 25 | + | m.AddWindow(30) | |
| 26 | + | m.Focus(1) | |
| 27 | + | w.FocusID(8) | |
| 28 | + | return m | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // withStash stashes windows 40..40+n-1 of the current workspace, with the | |
| 32 | + | // focus back on column 8. | |
| 33 | + | func withStash(m *Monitor, n int) { | |
| 34 | + | w := m.Current() | |
| 35 | + | for id := WindowID(40); id < WindowID(40+n); id++ { | |
| 36 | + | w.AddWindow(id) | |
| 37 | + | w.FocusID(id) | |
| 38 | + | w.ToggleWindowStash() | |
| 39 | + | } | |
| 40 | + | w.FocusID(8) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // cascadeCore is a Core on one output with workspace 0 holding window 1, | |
| 44 | + | // workspace 1 (current) a cascade of windows 2..6 in bands of two ({2,3} | |
| 45 | + | // {4,5} {6}), workspace 2 window 30, and window 2 focused. | |
| 46 | + | func cascadeCore(t *testing.T) (*Core, *Monitor) { | |
| 47 | + | t.Helper() | |
| 48 | + | c, _ := indicatorCore(t) | |
| 49 | + | m := c.cur().mon | |
| 50 | + | m.AddWindow(1) | |
| 51 | + | m.Focus(1) | |
| 52 | + | w := m.Current() | |
| 53 | + | w.Overflow = OverflowCascade | |
| 54 | + | for id := WindowID(2); id <= 6; id++ { | |
| 55 | + | w.AddWindow(id) | |
| 56 | + | } | |
| 57 | + | m.normalize() | |
| 58 | + | m.Focus(2) | |
| 59 | + | m.AddWindow(30) | |
| 60 | + | m.Focus(1) | |
| 61 | + | w.FocusID(2) | |
| 62 | + | return c, m | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func centreY(r Rect) int { return r.Y + r.H/2 } | |
| 66 | + | ||
| 67 | + | func within1(a, b int) bool { return a-b <= 1 && b-a <= 1 } | |
| 68 | + | ||
| 69 | + | func hiddenOff(p Placement) bool { return p.Hidden && p.Preview == 0 && !p.Focused } | |
| 70 | + | ||
| 71 | + | // T1: the zoom does not depend on the number of bands. | |
| 72 | + | func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) { | |
| 73 | + | if one, three := cascadeWorkspace(3).overviewZoom(), cascadeWorkspace(7).overviewZoom(); one != three || one != overviewMaxZoom { | |
| 74 | + | t.Fatalf("zoom with one band %v, with three %v", one, three) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // A maximized column fills its band in the preview as on screen, and hides | |
| 79 | + | // only the other columns of its band. | |
| 80 | + | func TestOverviewCascadeMaximizedColumnFillsBand(t *testing.T) { | |
| 81 | + | // Four columns, three per band: band 0 holds 0..2, band 1 holds 3. | |
| 82 | + | for _, tc := range []struct { | |
| 83 | + | n, max int | |
| 84 | + | hidden []bool | |
| 85 | + | }{ | |
| 86 | + | {n: 1, max: 0, hidden: []bool{false}}, | |
| 87 | + | {n: 4, max: 0, hidden: []bool{false, true, true, false}}, | |
| 88 | + | {n: 4, max: 3, hidden: []bool{false, false, false, false}}, | |
| 89 | + | } { | |
| 90 | + | w := cascadeWorkspace(tc.n) | |
| 91 | + | w.Focus = tc.max | |
| 92 | + | w.maximize(tc.max) | |
| 93 | + | tiles, _, _ := w.previewTiles() | |
| 94 | + | g, h := w.gap(), w.Usable.H-2*w.gap() | |
| 95 | + | for i, p := range tiles { | |
| 96 | + | if p.Hidden != tc.hidden[i] { | |
| 97 | + | t.Fatalf("n=%d max=%d column %d hidden %v, want %v", tc.n, tc.max, i, p.Hidden, tc.hidden[i]) | |
| 98 | + | } | |
| 99 | + | if p.Hidden { | |
| 100 | + | continue | |
| 101 | + | } | |
| 102 | + | band := i / 3 | |
| 103 | + | want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(), H: h} | |
| 104 | + | if i == tc.max { | |
| 105 | + | want.X, want.W = g, w.Usable.W-2*g | |
| 106 | + | } | |
| 107 | + | if p.Rect != want { | |
| 108 | + | t.Fatalf("n=%d max=%d column %d rect %+v, want %+v", tc.n, tc.max, i, p.Rect, want) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // T2: the selected band is the centre lane: undimmed, centred, selected tile | |
| 115 | + | // focused. | |
| 116 | + | func TestOverviewCascadeCentreLane(t *testing.T) { | |
| 117 | + | m := cascadeOverview() | |
| 118 | + | m.ToggleOverview() | |
| 119 | + | ps := m.Layout() | |
| 120 | + | u := m.Current().overviewArea() | |
| 121 | + | for _, id := range []WindowID{7, 8, 9} { | |
| 122 | + | p := previewOf(t, ps, id) | |
| 123 | + | if p.Hidden || p.Peek || p.Preview != overviewMaxZoom || p.Focused != (id == 8) { | |
| 124 | + | t.Fatalf("band tile %d: %+v", id, p) | |
| 125 | + | } | |
| 126 | + | if !within1(centreY(p.Rect), u.Y+u.H/2) { | |
| 127 | + | t.Fatalf("band tile %d centre %d, area centre %d", id, centreY(p.Rect), u.Y+u.H/2) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // T3: the other bands are dimmed lanes a screen (and a gap) away; those | |
| 133 | + | // outside the area are hidden. | |
| 134 | + | func TestOverviewCascadeOtherLanes(t *testing.T) { | |
| 135 | + | m := cascadeOverview() | |
| 136 | + | m.ToggleOverview() | |
| 137 | + | ps := m.Layout() | |
| 138 | + | sel := previewOf(t, ps, 8).Rect | |
| 139 | + | above, below := previewOf(t, ps, 5), previewOf(t, ps, 10) | |
| 140 | + | if above.Hidden || !above.Peek || above.Focused || below.Hidden || !below.Peek || below.Focused { | |
| 141 | + | t.Fatalf("adjacent bands: above %+v, below %+v", above, below) | |
| 142 | + | } | |
| 143 | + | // The lanes sit evenly around the selected one, apart from it (a gap), | |
| 144 | + | // and the area still shows part of each. | |
| 145 | + | up, down := sel.Y-above.Rect.Y, below.Rect.Y-sel.Y | |
| 146 | + | if !within1(up, down) { | |
| 147 | + | t.Fatalf("lanes not evenly spaced: %d above, %d below", up, down) | |
| 148 | + | } | |
| 149 | + | if gap := up - sel.H; gap <= 0 { | |
| 150 | + | t.Fatalf("lane above overlaps the selected one: %v over %v", above.Rect, sel) | |
| 151 | + | } | |
| 152 | + | u := m.Current().overviewArea() | |
| 153 | + | if above.Rect.Y+above.Rect.H <= u.Y || below.Rect.Y >= u.Y+u.H { | |
| 154 | + | t.Fatalf("adjacent lanes not visible in %v: %v, %v", u, above.Rect, below.Rect) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // Five bands (13 columns): the ones two lanes away are off the area. | |
| 158 | + | m = cascadeOverview() | |
| 159 | + | w := m.Current() | |
| 160 | + | for id := WindowID(11); id <= 16; id++ { | |
| 161 | + | w.AddWindow(id) | |
| 162 | + | } | |
| 163 | + | w.FocusID(8) | |
| 164 | + | m.ToggleOverview() | |
| 165 | + | // Columns 4..16: bands {4,5,6} {7,8,9} {10,11,12} {13,14,15} {16}. | |
| 166 | + | w.FocusID(11) | |
| 167 | + | ps = m.Layout() | |
| 168 | + | for _, id := range []WindowID{4, 5, 6, 16} { | |
| 169 | + | if p := previewOf(t, ps, id); !hiddenOff(p) { | |
| 170 | + | t.Fatalf("band two lanes away %d not hidden: %+v", id, p) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | for _, id := range []WindowID{7, 13} { | |
| 174 | + | if p := previewOf(t, ps, id); p.Hidden || !p.Peek { | |
| 175 | + | t.Fatalf("adjacent band %d: %+v", id, p) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // T4: neighbor workspaces sit left and right of the current slot, dimmed, | |
| 181 | + | // their selected lane level with the centre lane. | |
| 182 | + | func TestOverviewCascadeNeighbors(t *testing.T) { | |
| 183 | + | m := cascadeOverview() | |
| 184 | + | m.ToggleOverview() | |
| 185 | + | rows, _ := m.overviewRows() | |
| 186 | + | cur, left, right := rows[m.Workspaces[1]], rows[m.Workspaces[0]], rows[m.Workspaces[2]] | |
| 187 | + | if left.X+left.W > cur.X || right.X < cur.X+cur.W { | |
| 188 | + | t.Fatalf("slots overlap: %v %v %v", left, cur, right) | |
| 189 | + | } | |
| 190 | + | ps := m.Layout() | |
| 191 | + | centre := centreY(previewOf(t, ps, 8).Rect) | |
| 192 | + | shown := 0 | |
| 193 | + | for _, n := range []struct { | |
| 194 | + | id WindowID | |
| 195 | + | side int | |
| 196 | + | }{{1, -1}, {2, -1}, {3, -1}, {30, 1}} { | |
| 197 | + | id, side := n.id, n.side | |
| 198 | + | p := previewOf(t, ps, id) | |
| 199 | + | if p.Hidden { | |
| 200 | + | continue | |
| 201 | + | } | |
| 202 | + | shown++ | |
| 203 | + | if !p.Peek || p.Focused || p.Preview == 0 { | |
| 204 | + | t.Fatalf("neighbor %d not dimmed: %+v", id, p) | |
| 205 | + | } | |
| 206 | + | if side < 0 && p.Rect.X+p.Rect.W > cur.X || side > 0 && p.Rect.X < cur.X+cur.W { | |
| 207 | + | t.Fatalf("neighbor %d overlaps the current slot %v: %+v", id, cur, p.Rect) | |
| 208 | + | } | |
| 209 | + | if !within1(centreY(p.Rect), centre) { | |
| 210 | + | t.Fatalf("neighbor %d centre %d, centre lane %d", id, centreY(p.Rect), centre) | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | if shown < 2 { | |
| 214 | + | t.Fatalf("only %d neighbor tiles drawn", shown) | |
| 215 | + | } | |
| 216 | + | for _, p := range ps { | |
| 217 | + | if p.Hidden || p.Peek { | |
| 218 | + | continue | |
| 219 | + | } | |
| 220 | + | if p.Rect.X < cur.X || p.Rect.X+p.Rect.W > cur.X+cur.W { | |
| 221 | + | t.Fatalf("current tile %d outside its slot %v: %+v", p.ID, cur, p.Rect) | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // T5: lanes along the overview axis would overlap the other workspaces: a | |
| 227 | + | // cascade neighbor of a scroll workspace shows its selected band only. | |
| 228 | + | func TestOverviewCascadeNeighborBelowScrollShowsOneBand(t *testing.T) { | |
| 229 | + | m := cascadeOverview() | |
| 230 | + | m.Focus(0) | |
| 231 | + | m.ToggleOverview() | |
| 232 | + | if m.overviewAxis() != verticalAxis { | |
| 233 | + | t.Fatal("setup: scroll overview is not vertical") | |
| 234 | + | } | |
| 235 | + | ps := m.Layout() | |
| 236 | + | for _, id := range []WindowID{4, 5, 6} { | |
| 237 | + | if p := previewOf(t, ps, id); !hiddenOff(p) { | |
| 238 | + | t.Fatalf("band %d of the neighbor drawn: %+v", id, p) | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | if p := previewOf(t, ps, 8); p.Hidden { | |
| 242 | + | // The neighbor's selected column is 8. | |
| 243 | + | t.Fatalf("selected band hidden: %+v", p) | |
| 244 | + | } | |
| 245 | + | for _, id := range []WindowID{7, 9} { | |
| 246 | + | if p := previewOf(t, ps, id); p.Hidden || !p.Peek { | |
| 247 | + | t.Fatalf("selected band tile %d: %+v", id, p) | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | if p := previewOf(t, ps, 10); !hiddenOff(p) { | |
| 251 | + | t.Fatalf("band 3 drawn: %+v", p) | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | // T6: the stash pile is inside the current slot, left of the band; the | |
| 256 | + | // previous workspace stays clear of it. | |
| 257 | + | func TestOverviewCascadeStashInSlot(t *testing.T) { | |
| 258 | + | m := cascadeOverview() | |
| 259 | + | withStash(m, 2) | |
| 260 | + | m.ToggleOverview() | |
| 261 | + | rows, _ := m.overviewRows() | |
| 262 | + | cur, prev := rows[m.Current()], rows[m.Workspaces[0]] | |
| 263 | + | ps := m.Layout() | |
| 264 | + | left := cur.X + cur.W | |
| 265 | + | for _, id := range []WindowID{7, 8, 9} { | |
| 266 | + | left = min(left, previewOf(t, ps, id).Rect.X) | |
| 267 | + | } | |
| 268 | + | for _, id := range []WindowID{40, 41} { | |
| 269 | + | p := previewOf(t, ps, id) | |
| 270 | + | if p.Hidden || p.Preview != overviewCardZoom { | |
| 271 | + | t.Fatalf("pile card %d: %+v", id, p) | |
| 272 | + | } | |
| 273 | + | if p.Rect.X < cur.X || p.Rect.X+p.Rect.W > left { | |
| 274 | + | t.Fatalf("pile card %d not inside the slot, left of the band (%d..%d): %+v", id, cur.X, left, p.Rect) | |
| 275 | + | } | |
| 276 | + | // 41 is the front card of the pile. | |
| 277 | + | if id == 41 && !within1(centreY(p.Rect), centreY(previewOf(t, ps, 8).Rect)) { | |
| 278 | + | t.Fatalf("pile not level with the selected lane: %+v", p.Rect) | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | // The pile does not shift the band: its row stays centred on the area. | |
| 282 | + | u := m.Current().overviewArea() | |
| 283 | + | lo, hi := previewOf(t, ps, 7).Rect, previewOf(t, ps, 9).Rect | |
| 284 | + | if mid := (lo.X + hi.X + hi.W) / 2; !within1(mid, u.X+u.W/2) { | |
| 285 | + | t.Fatalf("band centre %d with a stash, area centre %d", mid, u.X+u.W/2) | |
| 286 | + | } | |
| 287 | + | for _, id := range []WindowID{1, 2, 3} { | |
| 288 | + | if p := previewOf(t, ps, id); !p.Hidden && p.Rect.X+p.Rect.W > cur.X { | |
| 289 | + | t.Fatalf("previous workspace overlaps the slot with the pile: %+v", p.Rect) | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | if prev.X+prev.W > cur.X { | |
| 293 | + | t.Fatalf("slots overlap: %v %v", prev, cur) | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | type cascadeDriver struct { | |
| 298 | + | name string | |
| 299 | + | move func(m *Monitor, dx, dy int) | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | func keyDriver(left, right, up, down string) func(m *Monitor, dx, dy int) { | |
| 303 | + | return func(m *Monitor, dx, dy int) { | |
| 304 | + | name := map[[2]int]string{{-1, 0}: left, {1, 0}: right, {0, -1}: up, {0, 1}: down}[[2]int{dx, dy}] | |
| 305 | + | m.overviewKey(ports.KeyEvent{Keysym: name, Pressed: true}) | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | var cascadeDrivers = []cascadeDriver{ | |
| 310 | + | {"move", func(m *Monitor, dx, dy int) { m.OverviewMove(dx, dy) }}, | |
| 311 | + | {"hjkl", keyDriver("h", "l", "k", "j")}, | |
| 312 | + | {"arrows", keyDriver("Left", "Right", "Up", "Down")}, | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | func focusedID(m *Monitor) WindowID { | |
| 316 | + | id, _ := m.Focused() | |
| 317 | + | return id | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | // T7: ↑/↓ change band and stop at the ends; ←/→ walk the band, then leave it | |
| 321 | + | // for the stash or the neighbor workspaces; Escape restores everything. | |
| 322 | + | func TestOverviewCascadeKeys(t *testing.T) { | |
| 323 | + | for _, d := range cascadeDrivers { | |
| 324 | + | t.Run(d.name, func(t *testing.T) { | |
| 325 | + | m := cascadeOverview() | |
| 326 | + | w, prev, next := m.Current(), m.Workspaces[0], m.Workspaces[2] | |
| 327 | + | w.FocusID(5) | |
| 328 | + | m.ToggleOverview() | |
| 329 | + | sel := func() WindowID { return w.Columns[w.Focus].Windows[0] } | |
| 330 | + | d.move(m, 0, 1) | |
| 331 | + | if sel() != 8 { | |
| 332 | + | t.Fatalf("down: %d, want 8", sel()) | |
| 333 | + | } | |
| 334 | + | d.move(m, 0, 1) | |
| 335 | + | d.move(m, 0, 1) | |
| 336 | + | if m.Current() != w || sel() != 10 { | |
| 337 | + | t.Fatalf("down at the last band: %d on the current workspace %v", sel(), m.Current() == w) | |
| 338 | + | } | |
| 339 | + | d.move(m, 0, -1) | |
| 340 | + | if sel() != 7 { | |
| 341 | + | t.Fatalf("up: %d, want 7", sel()) | |
| 342 | + | } | |
| 343 | + | d.move(m, 0, -1) | |
| 344 | + | d.move(m, 0, -1) | |
| 345 | + | if m.Current() != w || sel() != 4 { | |
| 346 | + | t.Fatalf("up at the first band: %d", sel()) | |
| 347 | + | } | |
| 348 | + | d.move(m, 1, 0) | |
| 349 | + | d.move(m, 1, 0) | |
| 350 | + | if sel() != 6 || m.Current() != w { | |
| 351 | + | t.Fatalf("right in the band: %d", sel()) | |
| 352 | + | } | |
| 353 | + | d.move(m, 1, 0) | |
| 354 | + | if m.Current() != next { | |
| 355 | + | t.Fatal("right past the last column did not reach the next workspace") | |
| 356 | + | } | |
| 357 | + | m.CancelOverview() | |
| 358 | + | if m.Current() != w || focusedID(m) != 5 { | |
| 359 | + | t.Fatalf("cancel left %d", focusedID(m)) | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | m.ToggleOverview() | |
| 363 | + | d.move(m, -1, 0) | |
| 364 | + | if sel() != 4 || m.Current() != w { | |
| 365 | + | t.Fatal("left within the band") | |
| 366 | + | } | |
| 367 | + | d.move(m, -1, 0) | |
| 368 | + | if m.Current() != prev { | |
| 369 | + | t.Fatal("left from the first column did not reach the previous workspace") | |
| 370 | + | } | |
| 371 | + | m.CancelOverview() | |
| 372 | + | ||
| 373 | + | // With a stash: left from the first column enters it, right | |
| 374 | + | // comes back to the band's first column, left twice goes to | |
| 375 | + | // the previous workspace. | |
| 376 | + | withStash(m, 2) | |
| 377 | + | w.FocusID(7) | |
| 378 | + | m.ToggleOverview() | |
| 379 | + | d.move(m, -1, 0) | |
| 380 | + | if m.cardAt(w) < 0 { | |
| 381 | + | t.Fatal("left from the first column did not select the stash") | |
| 382 | + | } | |
| 383 | + | d.move(m, 1, 0) | |
| 384 | + | if m.cardAt(w) >= 0 || sel() != 7 { | |
| 385 | + | t.Fatalf("right from the stash: card %d, column %d", m.cardAt(w), sel()) | |
| 386 | + | } | |
| 387 | + | d.move(m, -1, 0) | |
| 388 | + | d.move(m, -1, 0) | |
| 389 | + | if m.Current() != prev { | |
| 390 | + | t.Fatal("left from the stash did not reach the previous workspace") | |
| 391 | + | } | |
| 392 | + | m.CancelOverview() | |
| 393 | + | if m.Current() != w || focusedID(m) != 7 { | |
| 394 | + | t.Fatalf("cancel left %d", focusedID(m)) | |
| 395 | + | } | |
| 396 | + | }) | |
| 397 | + | } | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // T7b: with a stash card selected, up/down and the three-finger vertical | |
| 401 | + | // swipe cycle the pile as on main; confirming shows the picked window. | |
| 402 | + | func TestOverviewCascadeStashPile(t *testing.T) { | |
| 403 | + | for _, d := range cascadeDrivers { | |
| 404 | + | t.Run(d.name, func(t *testing.T) { | |
| 405 | + | m := cascadeOverview() | |
| 406 | + | withStash(m, 3) | |
| 407 | + | w := m.Current() | |
| 408 | + | w.FocusID(7) | |
| 409 | + | m.ToggleOverview() | |
| 410 | + | d.move(m, -1, 0) | |
| 411 | + | if m.cardAt(w) != w.stashAt { | |
| 412 | + | t.Fatalf("entered at %d", m.cardAt(w)) | |
| 413 | + | } | |
| 414 | + | at := m.cardAt(w) | |
| 415 | + | d.move(m, 0, -1) | |
| 416 | + | if m.Current() != w || m.cardAt(w) != at-1 { | |
| 417 | + | t.Fatalf("up: card %d, was %d", m.cardAt(w), at) | |
| 418 | + | } | |
| 419 | + | d.move(m, 0, 1) | |
| 420 | + | d.move(m, 0, 1) | |
| 421 | + | d.move(m, 0, 1) | |
| 422 | + | if m.Current() != w || m.cardAt(w) != len(w.Stash)-1 { | |
| 423 | + | t.Fatalf("down: card %d", m.cardAt(w)) | |
| 424 | + | } | |
| 425 | + | d.move(m, 0, -1) | |
| 426 | + | picked := w.Stash[m.cardAt(w)].ID | |
| 427 | + | m.ToggleOverview() | |
| 428 | + | if focusedID(m) != picked || w.stashHidden { | |
| 429 | + | t.Fatalf("confirm on %d, want %d (hidden %v)", focusedID(m), picked, w.stashHidden) | |
| 430 | + | } | |
| 431 | + | }) | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | c, _, sc, _ := stashRig(t, true) | |
| 435 | + | m := sc.mon | |
| 436 | + | w := m.Current() | |
| 437 | + | w.Overflow = OverflowCascade | |
| 438 | + | m.Focus(1) | |
| 439 | + | m.AddWindow(50) | |
| 440 | + | m.Focus(0) | |
| 441 | + | m.ToggleOverview() | |
| 442 | + | if m.cardAt(w) < 0 { | |
| 443 | + | t.Fatal("setup: no card selected") | |
| 444 | + | } | |
| 445 | + | at, before := m.cardAt(w), stashIDs(w) | |
| 446 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |
| 447 | + | for range 3 { | |
| 448 | + | c.swipeUpdate(ports.SwipeUpdate{DY: -40}) | |
| 449 | + | } | |
| 450 | + | c.swipeEnd(ports.SwipeEnd{}) | |
| 451 | + | if m.Current() != w || m.cardAt(w) != at-1 { | |
| 452 | + | t.Fatalf("swipe up: card %d, was %d", m.cardAt(w), at) | |
| 453 | + | } | |
| 454 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |
| 455 | + | for range 3 { | |
| 456 | + | c.swipeUpdate(ports.SwipeUpdate{DX: 40}) | |
| 457 | + | } | |
| 458 | + | c.swipeEnd(ports.SwipeEnd{}) | |
| 459 | + | // A horizontal three-finger swipe is a workspace step, even over a | |
| 460 | + | // stash card. | |
| 461 | + | if m.Current() != m.Workspaces[1] { | |
| 462 | + | t.Fatalf("horizontal swipe over a stash card: workspace %d", indexOf(m.Workspaces, m.Current())) | |
| 463 | + | } | |
| 464 | + | if got := stashIDs(w); !slices.Equal(got, before) { | |
| 465 | + | t.Fatal("stash reordered") | |
| 466 | + | } | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | // T7c: three fingers follow the physical axes, once per gesture. | |
| 470 | + | func TestOverviewCascadeThreeFinger(t *testing.T) { | |
| 471 | + | c, m := cascadeCore(t) | |
| 472 | + | w := m.Current() | |
| 473 | + | m.ToggleOverview() | |
| 474 | + | swipe := func(dx, dy float64) { | |
| 475 | + | t.Helper() | |
| 476 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |
| 477 | + | for range 4 { | |
| 478 | + | c.swipeUpdate(ports.SwipeUpdate{DX: dx, DY: dy}) | |
| 479 | + | } | |
| 480 | + | c.swipeEnd(ports.SwipeEnd{}) | |
| 481 | + | } | |
| 482 | + | swipe(0, 40) | |
| 483 | + | if m.Current() != w || w.band(w.Focus) != 1 { | |
| 484 | + | t.Fatalf("one swipe down: band %d", w.band(w.Focus)) | |
| 485 | + | } | |
| 486 | + | swipe(0, -40) | |
| 487 | + | if w.band(w.Focus) != 0 { | |
| 488 | + | t.Fatalf("one swipe up: band %d", w.band(w.Focus)) | |
| 489 | + | } | |
| 490 | + | swipe(40, 0) | |
| 491 | + | if m.Current() != m.Workspaces[2] { | |
| 492 | + | t.Fatalf("one swipe right: workspace %d", indexOf(m.Workspaces, m.Current())) | |
| 493 | + | } | |
| 494 | + | // Onto a scroll workspace and back: the vertical swipe is the scroll | |
| 495 | + | // overview's workspace step; neither bounces during the gesture. | |
| 496 | + | swipe(0, -40) | |
| 497 | + | if m.Current() != w { | |
| 498 | + | t.Fatalf("swipe up from the scroll workspace: workspace %d", indexOf(m.Workspaces, m.Current())) | |
| 499 | + | } | |
| 500 | + | swipe(-40, 0) | |
| 501 | + | if m.Current() != m.Workspaces[0] { | |
| 502 | + | t.Fatalf("one swipe left: workspace %d", indexOf(m.Workspaces, m.Current())) | |
| 503 | + | } | |
| 504 | + | } | |
| 505 | + | ||
| 506 | + | // T8: a named boundary of a cascade overview is a vertical rule strictly | |
| 507 | + | // between the two slots. | |
| 508 | + | func TestOverviewCascadeNamedDivider(t *testing.T) { | |
| 509 | + | m := namedOverviewMonitor() | |
| 510 | + | m.Workspaces[1].Overflow = OverflowCascade | |
| 511 | + | m.ToggleOverview() | |
| 512 | + | rows, dividers := m.overviewRows() | |
| 513 | + | if len(dividers) != 1 { | |
| 514 | + | t.Fatalf("dividers: %+v", dividers) | |
| 515 | + | } | |
| 516 | + | d, cur, dev := dividers[0].Rect, rows[m.Workspaces[1]], rows[m.byName("dev")] | |
| 517 | + | if d.W != 1 || d.H <= 1 || dividers[0].Active { | |
| 518 | + | t.Fatalf("divider is not an inactive vertical rule: %+v", dividers[0]) | |
| 519 | + | } | |
| 520 | + | if d.X < cur.X+cur.W || d.X+d.W > dev.X { | |
| 521 | + | t.Fatalf("divider %v not between %v and %v", d, cur, dev) | |
| 522 | + | } | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | // T10: covering floats fan along the horizontal axis: left goes deeper in the | |
| 526 | + | // stack (the cards behind show on the left), right comes back to the front. | |
| 527 | + | func TestOverviewCascadeCoveringFloat(t *testing.T) { | |
| 528 | + | for _, d := range cascadeDrivers { | |
| 529 | + | t.Run(d.name, func(t *testing.T) { | |
| 530 | + | m := cascadeOverview() | |
| 531 | + | m.Current().AddFloating(90, 300, 200) | |
| 532 | + | w := m.Current() | |
| 533 | + | w.FocusID(10) | |
| 534 | + | m.ToggleOverview() | |
| 535 | + | if m.stackFront(w).kind != stackColumns { | |
| 536 | + | t.Fatal("setup: columns not in front") | |
| 537 | + | } | |
| 538 | + | d.move(m, 0, -1) // columns: up is a band | |
| 539 | + | d.move(m, -1, 0) | |
| 540 | + | if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 { | |
| 541 | + | t.Fatalf("left from the first column: front %+v", f) | |
| 542 | + | } | |
| 543 | + | if p := previewOf(t, m.Layout(), 90); p.Peek || !p.Focused { | |
| 544 | + | t.Fatalf("float not in front: %+v", p) | |
| 545 | + | } | |
| 546 | + | d.move(m, 0, 1) // a float front takes no band move | |
| 547 | + | if f := m.stackFront(w); f.kind != stackFloat { | |
| 548 | + | t.Fatalf("down left the float: %+v", f) | |
| 549 | + | } | |
| 550 | + | d.move(m, 1, 0) | |
| 551 | + | if m.stackFront(w).kind != stackColumns || m.Current() != w { | |
| 552 | + | t.Fatalf("right from the float: front %+v", m.stackFront(w)) | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | // The float in front of the columns: they fan to its left. | |
| 556 | + | m.CancelOverview() | |
| 557 | + | w.FocusID(90) | |
| 558 | + | m.ToggleOverview() | |
| 559 | + | if f := m.stackFront(w); f.kind != stackFloat { | |
| 560 | + | t.Fatalf("setup: front %+v", f) | |
| 561 | + | } | |
| 562 | + | behind := previewOf(t, m.Layout(), 10) | |
| 563 | + | if !behind.Peek { | |
| 564 | + | t.Fatalf("columns not behind the float: %+v", behind) | |
| 565 | + | } | |
| 566 | + | d.move(m, -1, 0) | |
| 567 | + | if m.stackFront(w).kind != stackColumns || m.Current() != w { | |
| 568 | + | t.Fatalf("left from the front float: front %+v", m.stackFront(w)) | |
| 569 | + | } | |
| 570 | + | // The cards behind fan to the left: coming to the front, the | |
| 571 | + | // columns move right by one peek step. | |
| 572 | + | front := previewOf(t, m.Layout(), 10) | |
| 573 | + | if front.Peek || front.Rect.X-behind.Rect.X != w.peekStep() { | |
| 574 | + | t.Fatalf("columns behind %v, in front %v, peek step %d", behind.Rect, front.Rect, w.peekStep()) | |
| 575 | + | } | |
| 576 | + | d.move(m, 1, 0) | |
| 577 | + | if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 { | |
| 578 | + | t.Fatalf("right from the columns behind the float: front %+v", f) | |
| 579 | + | } | |
| 580 | + | }) | |
| 581 | + | } | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | // T10b: a pinned fullscreen column keeps its Fullscreen tile in the band. | |
| 585 | + | func TestOverviewCascadePinnedFullscreenTile(t *testing.T) { | |
| 586 | + | m := cascadeOverview() | |
| 587 | + | w := m.Current() | |
| 588 | + | w.FocusID(8) | |
| 589 | + | m.ToggleFullscreen() | |
| 590 | + | if !w.pinned() { | |
| 591 | + | t.Fatal("setup: not pinned") | |
| 592 | + | } | |
| 593 | + | m.ToggleOverview() | |
| 594 | + | ps := m.Layout() | |
| 595 | + | fs := previewOf(t, ps, 8) | |
| 596 | + | if !fs.Fullscreen || fs.Hidden { | |
| 597 | + | t.Fatalf("fullscreen tile: %+v", fs) | |
| 598 | + | } | |
| 599 | + | for _, id := range []WindowID{7, 9} { | |
| 600 | + | if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen || p.Peek != (fs.Peek) { | |
| 601 | + | t.Fatalf("band tile %d: %+v", id, p) | |
| 602 | + | } | |
| 603 | + | } | |
| 604 | + | u := w.overviewArea() | |
| 605 | + | if !within1(centreY(fs.Rect), u.Y+u.H/2) { | |
| 606 | + | t.Fatalf("fullscreen tile not on the centre lane: %+v", fs.Rect) | |
| 607 | + | } | |
| 608 | + | } | |
| 609 | + | ||
| 610 | + | // T11: the focus binds keep their meaning in a cascade overview. | |
| 611 | + | func TestOverviewCascadeFocusBinds(t *testing.T) { | |
| 612 | + | m := cascadeOverview() | |
| 613 | + | w := m.Current() | |
| 614 | + | m.ToggleOverview() | |
| 615 | + | if !m.overviewFocus(ActionFocusWindowDown) || w.band(w.Focus) != 2 || m.Current() != w { | |
| 616 | + | t.Fatalf("focus-window-down: band %d", w.band(w.Focus)) | |
| 617 | + | } | |
| 618 | + | if !m.overviewFocus(ActionFocusWindowUp) || w.band(w.Focus) != 1 { | |
| 619 | + | t.Fatalf("focus-window-up: band %d", w.band(w.Focus)) | |
| 620 | + | } | |
| 621 | + | m.overviewFocus(ActionFocusWorkspaceNext) | |
| 622 | + | if m.Current() != m.Workspaces[2] { | |
| 623 | + | t.Fatal("focus-workspace-next did not change workspace") | |
| 624 | + | } | |
| 625 | + | m.overviewFocus(ActionFocusWorkspacePrev) | |
| 626 | + | if m.Current() != w { | |
| 627 | + | t.Fatal("focus-workspace-prev did not come back") | |
| 628 | + | } | |
| 629 | + | m.overviewFocus(ActionFocusWorkspacePrev) | |
| 630 | + | if m.Current() != m.Workspaces[0] { | |
| 631 | + | t.Fatal("focus-workspace-prev did not change workspace") | |
| 632 | + | } | |
| 633 | + | m.CancelOverview() | |
| 634 | + | ||
| 635 | + | // A selected stash card keeps the pile cycle. | |
| 636 | + | withStash(m, 3) | |
| 637 | + | w.FocusID(7) | |
| 638 | + | m.ToggleOverview() | |
| 639 | + | m.OverviewMove(-1, 0) | |
| 640 | + | at := m.cardAt(w) | |
| 641 | + | m.overviewFocus(ActionFocusWorkspacePrev) | |
| 642 | + | if m.Current() != w || m.cardAt(w) != at-1 { | |
| 643 | + | t.Fatalf("focus-workspace-prev over a card: card %d, was %d", m.cardAt(w), at) | |
| 644 | + | } | |
| 645 | + | m.overviewFocus(ActionFocusWorkspaceNext) | |
| 646 | + | if m.Current() != w || m.cardAt(w) != at { | |
| 647 | + | t.Fatalf("focus-workspace-next over a card: card %d", m.cardAt(w)) | |
| 648 | + | } | |
| 649 | + | } | |
| 650 | + | ||
| 651 | + | func TestOverviewMixedLayoutGestureAndCancel(t *testing.T) { | |
| 652 | + | m := overviewMonitor() | |
| 653 | + | m.SetOverflow(OverflowFixed) | |
| 654 | + | m.Current().Overflow = OverflowCascade | |
| 655 | + | from := m.Current() | |
| 656 | + | m.ToggleOverview() | |
| 657 | + | rows, _ := m.overviewRows() | |
| 658 | + | if rows[m.Workspaces[1]].X <= rows[from].X { | |
| 659 | + | t.Fatal("cascade neighbor not horizontal") | |
| 660 | + | } | |
| 661 | + | m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: overviewScrollStep}}) | |
| 662 | + | if m.Current() == from { | |
| 663 | + | t.Fatal("horizontal gesture did not change workspace") | |
| 664 | + | } | |
| 665 | + | if m.Current().Overflow != OverflowFixed { | |
| 666 | + | t.Fatal("destination lost fixed policy") | |
| 667 | + | } | |
| 668 | + | if m.ov.scrollX != 0 || m.ov.scrollY != 0 || !m.ov.scrolled { | |
| 669 | + | t.Fatalf("scroll state after the axis change: %+v", m.ov) | |
| 670 | + | } | |
| 671 | + | m.CancelOverview() | |
| 672 | + | if m.Current() != from { | |
| 673 | + | t.Fatal("cancel lost source workspace") | |
| 674 | + | } | |
| 675 | + | } | |
| 676 | + | ||
| 677 | + | // Showing a workspace with another overview axis drops the scroll leftovers | |
| 678 | + | // of the old one. | |
| 679 | + | func TestOverviewAxisChangeResetsScroll(t *testing.T) { | |
| 680 | + | m := cascadeOverview() | |
| 681 | + | m.ToggleOverview() | |
| 682 | + | m.ov.scrollX, m.ov.scrollY, m.ov.sideways, m.ov.scrolled = 20, 20, true, true | |
| 683 | + | m.showOverview(m.Workspaces[2]) | |
| 684 | + | if m.ov.scrollX != 0 || m.ov.scrollY != 0 || m.ov.sideways || !m.ov.scrolled { | |
| 685 | + | t.Fatalf("scroll kept or latch dropped: %+v", m.ov) | |
| 686 | + | } | |
| 687 | + | m.ov.scrollX = 20 | |
| 688 | + | m.showOverview(m.Workspaces[0]) | |
| 689 | + | if m.ov.scrollX != 20 { | |
| 690 | + | t.Fatal("scroll reset between workspaces of the same axis") | |
| 691 | + | } | |
| 692 | + | } | |
| 693 | + | ||
| 694 | + | // A reload that changes the current workspace's axes drops the scroll state; | |
| 695 | + | // one that does not, keeps it. | |
| 696 | + | func TestOverviewReloadResetsScrollOnAxisChange(t *testing.T) { | |
| 697 | + | c, _ := indicatorCore(t) | |
| 698 | + | m := c.cur().mon | |
| 699 | + | m.AddWindow(1) | |
| 700 | + | m.ToggleOverview() | |
| 701 | + | w := m.Current() | |
| 702 | + | set := func() { m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 20, 10, true, true } | |
| 703 | + | set() | |
| 704 | + | cfg := c.cfg | |
| 705 | + | cfg.Layout.Gaps++ | |
| 706 | + | if err := c.apply(cfg); err != nil { | |
| 707 | + | t.Fatal(err) | |
| 708 | + | } | |
| 709 | + | if m.ov.scrollX != 20 || !m.ov.scrolled { | |
| 710 | + | t.Fatalf("unrelated reload reset the scroll: %+v", m.ov) | |
| 711 | + | } | |
| 712 | + | cfg.Layout.Overflow = string(OverflowCascade) | |
| 713 | + | if err := c.apply(cfg); err != nil { | |
| 714 | + | t.Fatal(err) | |
| 715 | + | } | |
| 716 | + | if m.Current().policy().workspace != horizontalAxis { | |
| 717 | + | t.Fatal("setup: not cascade") | |
| 718 | + | } | |
| 719 | + | // The reload keeps the overview open on its selection, and cancelling | |
| 720 | + | // still gives the original focus back. | |
| 721 | + | if id, _ := w.Focused(); !m.ov.open || id != 1 { | |
| 722 | + | t.Fatalf("reload lost the overview selection: open %v, focus %d", m.ov.open, id) | |
| 723 | + | } | |
| 724 | + | // The scroll is dropped, but not the latch that waits for the fingers | |
| 725 | + | // to lift. | |
| 726 | + | if m.ov.scrollX != 0 || m.ov.scrollY != 0 || !m.ov.scrolled || m.ov.sideways { | |
| 727 | + | t.Fatalf("axis change kept the scroll or dropped the latch: %+v", m.ov) | |
| 728 | + | } | |
| 729 | + | set() | |
| 730 | + | cfg.Layout.Overflow = string(OverflowFixed) | |
| 731 | + | if err := c.apply(cfg); err != nil { | |
| 732 | + | t.Fatal(err) | |
| 733 | + | } | |
| 734 | + | if m.ov.scrollX != 0 || !m.ov.scrolled { | |
| 735 | + | t.Fatalf("workspace axis change kept the scroll or dropped the latch: %+v", m.ov) | |
| 736 | + | } | |
| 737 | + | m.CancelOverview() | |
| 738 | + | if id, _ := w.Focused(); id != 1 { | |
| 739 | + | t.Fatalf("cancel left the focus on %d", id) | |
| 740 | + | } | |
| 741 | + | } | |
| 742 | + | ||
| 743 | + | // The finger latch outlives an axis change: a gesture that turned the | |
| 744 | + | // overview, then was turned back by a key, does not step again. | |
| 745 | + | func TestOverviewFingerLatchSurvivesAxisChange(t *testing.T) { | |
| 746 | + | c, m := cascadeCore(t) | |
| 747 | + | w := m.Current() | |
| 748 | + | m.ToggleOverview() | |
| 749 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |
| 750 | + | c.swipeUpdate(ports.SwipeUpdate{DX: 70}) | |
| 751 | + | if m.Current() != m.Workspaces[2] || m.overviewAxis() != verticalAxis { | |
| 752 | + | t.Fatalf("setup: workspace %d", indexOf(m.Workspaces, m.Current())) | |
| 753 | + | } | |
| 754 | + | // Back to the cascade workspace by a key, with the fingers still down. | |
| 755 | + | m.overviewKey(ports.KeyEvent{Keysym: "k", Pressed: true}) | |
| 756 | + | if m.Current() != w || m.overviewAxis() != horizontalAxis { | |
| 757 | + | t.Fatal("setup: the key did not turn the overview back") | |
| 758 | + | } | |
| 759 | + | for _, u := range []ports.SwipeUpdate{{DY: 70}, {DX: 70}, {DY: -70}, {DX: -70}} { | |
| 760 | + | c.swipeUpdate(u) | |
| 761 | + | } | |
| 762 | + | c.swipeEnd(ports.SwipeEnd{}) | |
| 763 | + | if m.Current() != w || w.Focus != 0 { | |
| 764 | + | t.Fatalf("the gesture stepped again: workspace %d, column %d", indexOf(m.Workspaces, m.Current()), w.Focus) | |
| 765 | + | } | |
| 766 | + | // The next gesture steps again. | |
| 767 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |
| 768 | + | c.swipeUpdate(ports.SwipeUpdate{DY: 70}) | |
| 769 | + | c.swipeEnd(ports.SwipeEnd{}) | |
| 770 | + | if w.band(w.Focus) != 1 { | |
| 771 | + | t.Fatalf("the next gesture did not step: band %d", w.band(w.Focus)) | |
| 772 | + | } | |
| 773 | + | } | |
| 774 | + | ||
| 775 | + | // A stash-only cascade workspace has no columns to select: right from its | |
| 776 | + | // pile goes straight to the next workspace. | |
| 777 | + | func TestOverviewCascadeStashOnlyRight(t *testing.T) { | |
| 778 | + | m := overviewMonitor() | |
| 779 | + | m.Focus(1) | |
| 780 | + | w := m.Current() | |
| 781 | + | w.Overflow = OverflowCascade | |
| 782 | + | w.ToggleWindowStash() | |
| 783 | + | m.Focus(2) | |
| 784 | + | m.AddWindow(30) | |
| 785 | + | m.Focus(1) | |
| 786 | + | m.ToggleOverview() | |
| 787 | + | if len(w.Columns) != 0 || m.cardAt(w) < 0 { | |
| 788 | + | t.Fatalf("setup: %d columns, card %d", len(w.Columns), m.cardAt(w)) | |
| 789 | + | } | |
| 790 | + | m.OverviewMove(1, 0) | |
| 791 | + | if m.Current() != m.Workspaces[2] { | |
| 792 | + | t.Fatalf("right from the pile: workspace %d", indexOf(m.Workspaces, m.Current())) | |
| 793 | + | } | |
| 794 | + | m.OverviewMove(0, -1) // the next workspace is a scroll one: up goes back | |
| 795 | + | if m.Current() != w || m.cardAt(w) < 0 { | |
| 796 | + | t.Fatal("setup: back on the stash-only workspace without its card") | |
| 797 | + | } | |
| 798 | + | m.OverviewMove(-1, 0) | |
| 799 | + | if m.Current() != m.Workspaces[0] { | |
| 800 | + | t.Fatalf("left from the pile: workspace %d", indexOf(m.Workspaces, m.Current())) | |
| 801 | + | } | |
| 802 | + | } | |
| 803 | + | ||
| 804 | + | // A cascade neighbor beside a cascade workspace draws its other lanes | |
| 805 | + | // dimmed, its selected lane level with the centre lane. | |
| 806 | + | func TestOverviewCascadeNeighborLanes(t *testing.T) { | |
| 807 | + | m := cascadeOverview() | |
| 808 | + | prev := m.Workspaces[0] | |
| 809 | + | prev.Overflow = OverflowCascade | |
| 810 | + | for id := WindowID(60); id <= 65; id++ { | |
| 811 | + | prev.AddWindow(id) // bands {1,2,3} {60,61,62} {63,64,65}, 65 selected | |
| 812 | + | } | |
| 813 | + | m.ToggleOverview() | |
| 814 | + | ps := m.Layout() | |
| 815 | + | centre := centreY(previewOf(t, ps, 8).Rect) | |
| 816 | + | sel := previewOf(t, ps, 65) | |
| 817 | + | if sel.Hidden || !sel.Peek || sel.Focused || !within1(centreY(sel.Rect), centre) { | |
| 818 | + | t.Fatalf("selected lane of the neighbor: %+v, centre lane %d", sel, centre) | |
| 819 | + | } | |
| 820 | + | above := previewOf(t, ps, 62) | |
| 821 | + | if above.Hidden || !above.Peek || above.Rect.Y+above.Rect.H > sel.Rect.Y { | |
| 822 | + | t.Fatalf("lane above in the neighbor: %+v, selected %v", above, sel.Rect) | |
| 823 | + | } | |
| 824 | + | if p := previewOf(t, ps, 3); !hiddenOff(p) { | |
| 825 | + | t.Fatalf("lane two away in the neighbor: %+v", p) | |
| 826 | + | } | |
| 827 | + | } | |
| 828 | + | ||
| 829 | + | // A scroll neighbor wider than its slot scrolls inside it: what leaves the | |
| 830 | + | // slot is hidden, so it never sits on the current slot. | |
| 831 | + | func TestOverviewCascadeWideScrollNeighborIsClipped(t *testing.T) { | |
| 832 | + | m := cascadeOverview() | |
| 833 | + | prev := m.Workspaces[0] | |
| 834 | + | for id := WindowID(60); id <= 67; id++ { | |
| 835 | + | prev.AddWindow(id) | |
| 836 | + | } | |
| 837 | + | m.ToggleOverview() | |
| 838 | + | rows, _ := m.overviewRows() | |
| 839 | + | cur, slot := rows[m.Current()], rows[prev] | |
| 840 | + | hidden, shown := 0, 0 | |
| 841 | + | for _, p := range m.Layout() { | |
| 842 | + | if !prev.has(p.ID) { | |
| 843 | + | continue | |
| 844 | + | } | |
| 845 | + | if p.Hidden { | |
| 846 | + | hidden++ | |
| 847 | + | continue | |
| 848 | + | } | |
| 849 | + | shown++ | |
| 850 | + | if p.Rect.X < slot.X || p.Rect.X+p.Rect.W > slot.X+slot.W || p.Rect.X+p.Rect.W > cur.X { | |
| 851 | + | t.Fatalf("tile %d leaves its slot %v or reaches the current slot %v: %v", p.ID, slot, cur, p.Rect) | |
| 852 | + | } | |
| 853 | + | } | |
| 854 | + | if hidden == 0 || shown == 0 { | |
| 855 | + | t.Fatalf("setup: %d tiles shown, %d hidden", shown, hidden) | |
| 856 | + | } | |
| 857 | + | } | |
| 858 | + | ||
| 859 | + | // A wheel and a continuous scroll in a cascade overview: vertical changes | |
| 860 | + | // workspace (and does not bounce back on a workspace with another axis), | |
| 861 | + | // horizontal moves along the band. | |
| 862 | + | func TestOverviewCascadeScroll(t *testing.T) { | |
| 863 | + | vertical := ports.ScrollAxis{Set: true, Value: overviewScrollStep, V120: 120} | |
| 864 | + | for _, source := range []ports.AxisSource{ports.AxisWheel, ports.AxisContinuous} { | |
| 865 | + | t.Run(fmt.Sprint(source), func(t *testing.T) { | |
| 866 | + | m := cascadeOverview() | |
| 867 | + | w := m.Current() | |
| 868 | + | m.ToggleOverview() | |
| 869 | + | m.overviewScroll(ports.PointerAxis{Source: source, Vertical: vertical}) | |
| 870 | + | if m.Current() != m.Workspaces[2] || w.band(w.Focus) != 1 || m.ov.scrollX != 0 || m.ov.scrollY != 0 { | |
| 871 | + | t.Fatalf("vertical: workspace %d, band %d, scroll %v,%v", indexOf(m.Workspaces, m.Current()), w.band(w.Focus), m.ov.scrollX, m.ov.scrollY) | |
| 872 | + | } | |
| 873 | + | ||
| 874 | + | m = cascadeOverview() | |
| 875 | + | w = m.Current() | |
| 876 | + | w.FocusID(7) | |
| 877 | + | m.ToggleOverview() | |
| 878 | + | m.overviewScroll(ports.PointerAxis{Source: source, Horizontal: vertical}) | |
| 879 | + | if m.Current() != w || w.Columns[w.Focus].Windows[0] != 8 { | |
| 880 | + | t.Fatalf("horizontal: column %d", w.Columns[w.Focus].Windows[0]) | |
| 881 | + | } | |
| 882 | + | ||
| 883 | + | // Onto a workspace with another axis: one step, no bounce. | |
| 884 | + | m = overviewMonitor() | |
| 885 | + | m.SetOverflow(OverflowFixed) | |
| 886 | + | m.Workspaces[1].Overflow = OverflowCascade | |
| 887 | + | m.ToggleOverview() | |
| 888 | + | m.overviewScroll(ports.PointerAxis{Source: source, Vertical: vertical}) | |
| 889 | + | if m.Current() != m.Workspaces[1] || m.ov.scrollX != 0 || m.ov.scrollY != 0 { | |
| 890 | + | t.Fatalf("mixed: workspace %d, scroll %v,%v", indexOf(m.Workspaces, m.Current()), m.ov.scrollX, m.ov.scrollY) | |
| 891 | + | } | |
| 892 | + | }) | |
| 893 | + | } | |
| 894 | + | } | |
| @@ -0,0 +1,894 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "slices" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // cascadeOverview is overviewMonitor with workspace 2 (current) a cascade of | |||
| 12 | + | // seven columns, 4..10, in three bands of three (the last holds 10 alone), | |||
| 13 | + | // the focus on column 8 (band 2, middle). Workspace 1 (columns 1..3) is a | |||
| 14 | + | // scroll workspace on its left, workspace 3 (column 30) on its right. | |||
| 15 | + | func cascadeOverview() *Monitor { | |||
| 16 | + | m := overviewMonitor() | |||
| 17 | + | m.SetBorder(2) | |||
| 18 | + | m.Focus(1) | |||
| 19 | + | w := m.Current() | |||
| 20 | + | w.Overflow = OverflowCascade | |||
| 21 | + | for id := WindowID(5); id <= 10; id++ { | |||
| 22 | + | w.AddWindow(id) | |||
| 23 | + | } | |||
| 24 | + | m.Focus(2) | |||
| 25 | + | m.AddWindow(30) | |||
| 26 | + | m.Focus(1) | |||
| 27 | + | w.FocusID(8) | |||
| 28 | + | return m | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // withStash stashes windows 40..40+n-1 of the current workspace, with the | |||
| 32 | + | // focus back on column 8. | |||
| 33 | + | func withStash(m *Monitor, n int) { | |||
| 34 | + | w := m.Current() | |||
| 35 | + | for id := WindowID(40); id < WindowID(40+n); id++ { | |||
| 36 | + | w.AddWindow(id) | |||
| 37 | + | w.FocusID(id) | |||
| 38 | + | w.ToggleWindowStash() | |||
| 39 | + | } | |||
| 40 | + | w.FocusID(8) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // cascadeCore is a Core on one output with workspace 0 holding window 1, | |||
| 44 | + | // workspace 1 (current) a cascade of windows 2..6 in bands of two ({2,3} | |||
| 45 | + | // {4,5} {6}), workspace 2 window 30, and window 2 focused. | |||
| 46 | + | func cascadeCore(t *testing.T) (*Core, *Monitor) { | |||
| 47 | + | t.Helper() | |||
| 48 | + | c, _ := indicatorCore(t) | |||
| 49 | + | m := c.cur().mon | |||
| 50 | + | m.AddWindow(1) | |||
| 51 | + | m.Focus(1) | |||
| 52 | + | w := m.Current() | |||
| 53 | + | w.Overflow = OverflowCascade | |||
| 54 | + | for id := WindowID(2); id <= 6; id++ { | |||
| 55 | + | w.AddWindow(id) | |||
| 56 | + | } | |||
| 57 | + | m.normalize() | |||
| 58 | + | m.Focus(2) | |||
| 59 | + | m.AddWindow(30) | |||
| 60 | + | m.Focus(1) | |||
| 61 | + | w.FocusID(2) | |||
| 62 | + | return c, m | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func centreY(r Rect) int { return r.Y + r.H/2 } | |||
| 66 | + | ||||
| 67 | + | func within1(a, b int) bool { return a-b <= 1 && b-a <= 1 } | |||
| 68 | + | ||||
| 69 | + | func hiddenOff(p Placement) bool { return p.Hidden && p.Preview == 0 && !p.Focused } | |||
| 70 | + | ||||
| 71 | + | // T1: the zoom does not depend on the number of bands. | |||
| 72 | + | func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) { | |||
| 73 | + | if one, three := cascadeWorkspace(3).overviewZoom(), cascadeWorkspace(7).overviewZoom(); one != three || one != overviewMaxZoom { | |||
| 74 | + | t.Fatalf("zoom with one band %v, with three %v", one, three) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // A maximized column fills its band in the preview as on screen, and hides | |||
| 79 | + | // only the other columns of its band. | |||
| 80 | + | func TestOverviewCascadeMaximizedColumnFillsBand(t *testing.T) { | |||
| 81 | + | // Four columns, three per band: band 0 holds 0..2, band 1 holds 3. | |||
| 82 | + | for _, tc := range []struct { | |||
| 83 | + | n, max int | |||
| 84 | + | hidden []bool | |||
| 85 | + | }{ | |||
| 86 | + | {n: 1, max: 0, hidden: []bool{false}}, | |||
| 87 | + | {n: 4, max: 0, hidden: []bool{false, true, true, false}}, | |||
| 88 | + | {n: 4, max: 3, hidden: []bool{false, false, false, false}}, | |||
| 89 | + | } { | |||
| 90 | + | w := cascadeWorkspace(tc.n) | |||
| 91 | + | w.Focus = tc.max | |||
| 92 | + | w.maximize(tc.max) | |||
| 93 | + | tiles, _, _ := w.previewTiles() | |||
| 94 | + | g, h := w.gap(), w.Usable.H-2*w.gap() | |||
| 95 | + | for i, p := range tiles { | |||
| 96 | + | if p.Hidden != tc.hidden[i] { | |||
| 97 | + | t.Fatalf("n=%d max=%d column %d hidden %v, want %v", tc.n, tc.max, i, p.Hidden, tc.hidden[i]) | |||
| 98 | + | } | |||
| 99 | + | if p.Hidden { | |||
| 100 | + | continue | |||
| 101 | + | } | |||
| 102 | + | band := i / 3 | |||
| 103 | + | want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(), H: h} | |||
| 104 | + | if i == tc.max { | |||
| 105 | + | want.X, want.W = g, w.Usable.W-2*g | |||
| 106 | + | } | |||
| 107 | + | if p.Rect != want { | |||
| 108 | + | t.Fatalf("n=%d max=%d column %d rect %+v, want %+v", tc.n, tc.max, i, p.Rect, want) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // T2: the selected band is the centre lane: undimmed, centred, selected tile | |||
| 115 | + | // focused. | |||
| 116 | + | func TestOverviewCascadeCentreLane(t *testing.T) { | |||
| 117 | + | m := cascadeOverview() | |||
| 118 | + | m.ToggleOverview() | |||
| 119 | + | ps := m.Layout() | |||
| 120 | + | u := m.Current().overviewArea() | |||
| 121 | + | for _, id := range []WindowID{7, 8, 9} { | |||
| 122 | + | p := previewOf(t, ps, id) | |||
| 123 | + | if p.Hidden || p.Peek || p.Preview != overviewMaxZoom || p.Focused != (id == 8) { | |||
| 124 | + | t.Fatalf("band tile %d: %+v", id, p) | |||
| 125 | + | } | |||
| 126 | + | if !within1(centreY(p.Rect), u.Y+u.H/2) { | |||
| 127 | + | t.Fatalf("band tile %d centre %d, area centre %d", id, centreY(p.Rect), u.Y+u.H/2) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // T3: the other bands are dimmed lanes a screen (and a gap) away; those | |||
| 133 | + | // outside the area are hidden. | |||
| 134 | + | func TestOverviewCascadeOtherLanes(t *testing.T) { | |||
| 135 | + | m := cascadeOverview() | |||
| 136 | + | m.ToggleOverview() | |||
| 137 | + | ps := m.Layout() | |||
| 138 | + | sel := previewOf(t, ps, 8).Rect | |||
| 139 | + | above, below := previewOf(t, ps, 5), previewOf(t, ps, 10) | |||
| 140 | + | if above.Hidden || !above.Peek || above.Focused || below.Hidden || !below.Peek || below.Focused { | |||
| 141 | + | t.Fatalf("adjacent bands: above %+v, below %+v", above, below) | |||
| 142 | + | } | |||
| 143 | + | // The lanes sit evenly around the selected one, apart from it (a gap), | |||
| 144 | + | // and the area still shows part of each. | |||
| 145 | + | up, down := sel.Y-above.Rect.Y, below.Rect.Y-sel.Y | |||
| 146 | + | if !within1(up, down) { | |||
| 147 | + | t.Fatalf("lanes not evenly spaced: %d above, %d below", up, down) | |||
| 148 | + | } | |||
| 149 | + | if gap := up - sel.H; gap <= 0 { | |||
| 150 | + | t.Fatalf("lane above overlaps the selected one: %v over %v", above.Rect, sel) | |||
| 151 | + | } | |||
| 152 | + | u := m.Current().overviewArea() | |||
| 153 | + | if above.Rect.Y+above.Rect.H <= u.Y || below.Rect.Y >= u.Y+u.H { | |||
| 154 | + | t.Fatalf("adjacent lanes not visible in %v: %v, %v", u, above.Rect, below.Rect) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // Five bands (13 columns): the ones two lanes away are off the area. | |||
| 158 | + | m = cascadeOverview() | |||
| 159 | + | w := m.Current() | |||
| 160 | + | for id := WindowID(11); id <= 16; id++ { | |||
| 161 | + | w.AddWindow(id) | |||
| 162 | + | } | |||
| 163 | + | w.FocusID(8) | |||
| 164 | + | m.ToggleOverview() | |||
| 165 | + | // Columns 4..16: bands {4,5,6} {7,8,9} {10,11,12} {13,14,15} {16}. | |||
| 166 | + | w.FocusID(11) | |||
| 167 | + | ps = m.Layout() | |||
| 168 | + | for _, id := range []WindowID{4, 5, 6, 16} { | |||
| 169 | + | if p := previewOf(t, ps, id); !hiddenOff(p) { | |||
| 170 | + | t.Fatalf("band two lanes away %d not hidden: %+v", id, p) | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | for _, id := range []WindowID{7, 13} { | |||
| 174 | + | if p := previewOf(t, ps, id); p.Hidden || !p.Peek { | |||
| 175 | + | t.Fatalf("adjacent band %d: %+v", id, p) | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // T4: neighbor workspaces sit left and right of the current slot, dimmed, | |||
| 181 | + | // their selected lane level with the centre lane. | |||
| 182 | + | func TestOverviewCascadeNeighbors(t *testing.T) { | |||
| 183 | + | m := cascadeOverview() | |||
| 184 | + | m.ToggleOverview() | |||
| 185 | + | rows, _ := m.overviewRows() | |||
| 186 | + | cur, left, right := rows[m.Workspaces[1]], rows[m.Workspaces[0]], rows[m.Workspaces[2]] | |||
| 187 | + | if left.X+left.W > cur.X || right.X < cur.X+cur.W { | |||
| 188 | + | t.Fatalf("slots overlap: %v %v %v", left, cur, right) | |||
| 189 | + | } | |||
| 190 | + | ps := m.Layout() | |||
| 191 | + | centre := centreY(previewOf(t, ps, 8).Rect) | |||
| 192 | + | shown := 0 | |||
| 193 | + | for _, n := range []struct { | |||
| 194 | + | id WindowID | |||
| 195 | + | side int | |||
| 196 | + | }{{1, -1}, {2, -1}, {3, -1}, {30, 1}} { | |||
| 197 | + | id, side := n.id, n.side | |||
| 198 | + | p := previewOf(t, ps, id) | |||
| 199 | + | if p.Hidden { | |||
| 200 | + | continue | |||
| 201 | + | } | |||
| 202 | + | shown++ | |||
| 203 | + | if !p.Peek || p.Focused || p.Preview == 0 { | |||
| 204 | + | t.Fatalf("neighbor %d not dimmed: %+v", id, p) | |||
| 205 | + | } | |||
| 206 | + | if side < 0 && p.Rect.X+p.Rect.W > cur.X || side > 0 && p.Rect.X < cur.X+cur.W { | |||
| 207 | + | t.Fatalf("neighbor %d overlaps the current slot %v: %+v", id, cur, p.Rect) | |||
| 208 | + | } | |||
| 209 | + | if !within1(centreY(p.Rect), centre) { | |||
| 210 | + | t.Fatalf("neighbor %d centre %d, centre lane %d", id, centreY(p.Rect), centre) | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | if shown < 2 { | |||
| 214 | + | t.Fatalf("only %d neighbor tiles drawn", shown) | |||
| 215 | + | } | |||
| 216 | + | for _, p := range ps { | |||
| 217 | + | if p.Hidden || p.Peek { | |||
| 218 | + | continue | |||
| 219 | + | } | |||
| 220 | + | if p.Rect.X < cur.X || p.Rect.X+p.Rect.W > cur.X+cur.W { | |||
| 221 | + | t.Fatalf("current tile %d outside its slot %v: %+v", p.ID, cur, p.Rect) | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // T5: lanes along the overview axis would overlap the other workspaces: a | |||
| 227 | + | // cascade neighbor of a scroll workspace shows its selected band only. | |||
| 228 | + | func TestOverviewCascadeNeighborBelowScrollShowsOneBand(t *testing.T) { | |||
| 229 | + | m := cascadeOverview() | |||
| 230 | + | m.Focus(0) | |||
| 231 | + | m.ToggleOverview() | |||
| 232 | + | if m.overviewAxis() != verticalAxis { | |||
| 233 | + | t.Fatal("setup: scroll overview is not vertical") | |||
| 234 | + | } | |||
| 235 | + | ps := m.Layout() | |||
| 236 | + | for _, id := range []WindowID{4, 5, 6} { | |||
| 237 | + | if p := previewOf(t, ps, id); !hiddenOff(p) { | |||
| 238 | + | t.Fatalf("band %d of the neighbor drawn: %+v", id, p) | |||
| 239 | + | } | |||
| 240 | + | } | |||
| 241 | + | if p := previewOf(t, ps, 8); p.Hidden { | |||
| 242 | + | // The neighbor's selected column is 8. | |||
| 243 | + | t.Fatalf("selected band hidden: %+v", p) | |||
| 244 | + | } | |||
| 245 | + | for _, id := range []WindowID{7, 9} { | |||
| 246 | + | if p := previewOf(t, ps, id); p.Hidden || !p.Peek { | |||
| 247 | + | t.Fatalf("selected band tile %d: %+v", id, p) | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | if p := previewOf(t, ps, 10); !hiddenOff(p) { | |||
| 251 | + | t.Fatalf("band 3 drawn: %+v", p) | |||
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | // T6: the stash pile is inside the current slot, left of the band; the | |||
| 256 | + | // previous workspace stays clear of it. | |||
| 257 | + | func TestOverviewCascadeStashInSlot(t *testing.T) { | |||
| 258 | + | m := cascadeOverview() | |||
| 259 | + | withStash(m, 2) | |||
| 260 | + | m.ToggleOverview() | |||
| 261 | + | rows, _ := m.overviewRows() | |||
| 262 | + | cur, prev := rows[m.Current()], rows[m.Workspaces[0]] | |||
| 263 | + | ps := m.Layout() | |||
| 264 | + | left := cur.X + cur.W | |||
| 265 | + | for _, id := range []WindowID{7, 8, 9} { | |||
| 266 | + | left = min(left, previewOf(t, ps, id).Rect.X) | |||
| 267 | + | } | |||
| 268 | + | for _, id := range []WindowID{40, 41} { | |||
| 269 | + | p := previewOf(t, ps, id) | |||
| 270 | + | if p.Hidden || p.Preview != overviewCardZoom { | |||
| 271 | + | t.Fatalf("pile card %d: %+v", id, p) | |||
| 272 | + | } | |||
| 273 | + | if p.Rect.X < cur.X || p.Rect.X+p.Rect.W > left { | |||
| 274 | + | t.Fatalf("pile card %d not inside the slot, left of the band (%d..%d): %+v", id, cur.X, left, p.Rect) | |||
| 275 | + | } | |||
| 276 | + | // 41 is the front card of the pile. | |||
| 277 | + | if id == 41 && !within1(centreY(p.Rect), centreY(previewOf(t, ps, 8).Rect)) { | |||
| 278 | + | t.Fatalf("pile not level with the selected lane: %+v", p.Rect) | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | // The pile does not shift the band: its row stays centred on the area. | |||
| 282 | + | u := m.Current().overviewArea() | |||
| 283 | + | lo, hi := previewOf(t, ps, 7).Rect, previewOf(t, ps, 9).Rect | |||
| 284 | + | if mid := (lo.X + hi.X + hi.W) / 2; !within1(mid, u.X+u.W/2) { | |||
| 285 | + | t.Fatalf("band centre %d with a stash, area centre %d", mid, u.X+u.W/2) | |||
| 286 | + | } | |||
| 287 | + | for _, id := range []WindowID{1, 2, 3} { | |||
| 288 | + | if p := previewOf(t, ps, id); !p.Hidden && p.Rect.X+p.Rect.W > cur.X { | |||
| 289 | + | t.Fatalf("previous workspace overlaps the slot with the pile: %+v", p.Rect) | |||
| 290 | + | } | |||
| 291 | + | } | |||
| 292 | + | if prev.X+prev.W > cur.X { | |||
| 293 | + | t.Fatalf("slots overlap: %v %v", prev, cur) | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | type cascadeDriver struct { | |||
| 298 | + | name string | |||
| 299 | + | move func(m *Monitor, dx, dy int) | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | func keyDriver(left, right, up, down string) func(m *Monitor, dx, dy int) { | |||
| 303 | + | return func(m *Monitor, dx, dy int) { | |||
| 304 | + | name := map[[2]int]string{{-1, 0}: left, {1, 0}: right, {0, -1}: up, {0, 1}: down}[[2]int{dx, dy}] | |||
| 305 | + | m.overviewKey(ports.KeyEvent{Keysym: name, Pressed: true}) | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | var cascadeDrivers = []cascadeDriver{ | |||
| 310 | + | {"move", func(m *Monitor, dx, dy int) { m.OverviewMove(dx, dy) }}, | |||
| 311 | + | {"hjkl", keyDriver("h", "l", "k", "j")}, | |||
| 312 | + | {"arrows", keyDriver("Left", "Right", "Up", "Down")}, | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | func focusedID(m *Monitor) WindowID { | |||
| 316 | + | id, _ := m.Focused() | |||
| 317 | + | return id | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | // T7: ↑/↓ change band and stop at the ends; ←/→ walk the band, then leave it | |||
| 321 | + | // for the stash or the neighbor workspaces; Escape restores everything. | |||
| 322 | + | func TestOverviewCascadeKeys(t *testing.T) { | |||
| 323 | + | for _, d := range cascadeDrivers { | |||
| 324 | + | t.Run(d.name, func(t *testing.T) { | |||
| 325 | + | m := cascadeOverview() | |||
| 326 | + | w, prev, next := m.Current(), m.Workspaces[0], m.Workspaces[2] | |||
| 327 | + | w.FocusID(5) | |||
| 328 | + | m.ToggleOverview() | |||
| 329 | + | sel := func() WindowID { return w.Columns[w.Focus].Windows[0] } | |||
| 330 | + | d.move(m, 0, 1) | |||
| 331 | + | if sel() != 8 { | |||
| 332 | + | t.Fatalf("down: %d, want 8", sel()) | |||
| 333 | + | } | |||
| 334 | + | d.move(m, 0, 1) | |||
| 335 | + | d.move(m, 0, 1) | |||
| 336 | + | if m.Current() != w || sel() != 10 { | |||
| 337 | + | t.Fatalf("down at the last band: %d on the current workspace %v", sel(), m.Current() == w) | |||
| 338 | + | } | |||
| 339 | + | d.move(m, 0, -1) | |||
| 340 | + | if sel() != 7 { | |||
| 341 | + | t.Fatalf("up: %d, want 7", sel()) | |||
| 342 | + | } | |||
| 343 | + | d.move(m, 0, -1) | |||
| 344 | + | d.move(m, 0, -1) | |||
| 345 | + | if m.Current() != w || sel() != 4 { | |||
| 346 | + | t.Fatalf("up at the first band: %d", sel()) | |||
| 347 | + | } | |||
| 348 | + | d.move(m, 1, 0) | |||
| 349 | + | d.move(m, 1, 0) | |||
| 350 | + | if sel() != 6 || m.Current() != w { | |||
| 351 | + | t.Fatalf("right in the band: %d", sel()) | |||
| 352 | + | } | |||
| 353 | + | d.move(m, 1, 0) | |||
| 354 | + | if m.Current() != next { | |||
| 355 | + | t.Fatal("right past the last column did not reach the next workspace") | |||
| 356 | + | } | |||
| 357 | + | m.CancelOverview() | |||
| 358 | + | if m.Current() != w || focusedID(m) != 5 { | |||
| 359 | + | t.Fatalf("cancel left %d", focusedID(m)) | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | m.ToggleOverview() | |||
| 363 | + | d.move(m, -1, 0) | |||
| 364 | + | if sel() != 4 || m.Current() != w { | |||
| 365 | + | t.Fatal("left within the band") | |||
| 366 | + | } | |||
| 367 | + | d.move(m, -1, 0) | |||
| 368 | + | if m.Current() != prev { | |||
| 369 | + | t.Fatal("left from the first column did not reach the previous workspace") | |||
| 370 | + | } | |||
| 371 | + | m.CancelOverview() | |||
| 372 | + | ||||
| 373 | + | // With a stash: left from the first column enters it, right | |||
| 374 | + | // comes back to the band's first column, left twice goes to | |||
| 375 | + | // the previous workspace. | |||
| 376 | + | withStash(m, 2) | |||
| 377 | + | w.FocusID(7) | |||
| 378 | + | m.ToggleOverview() | |||
| 379 | + | d.move(m, -1, 0) | |||
| 380 | + | if m.cardAt(w) < 0 { | |||
| 381 | + | t.Fatal("left from the first column did not select the stash") | |||
| 382 | + | } | |||
| 383 | + | d.move(m, 1, 0) | |||
| 384 | + | if m.cardAt(w) >= 0 || sel() != 7 { | |||
| 385 | + | t.Fatalf("right from the stash: card %d, column %d", m.cardAt(w), sel()) | |||
| 386 | + | } | |||
| 387 | + | d.move(m, -1, 0) | |||
| 388 | + | d.move(m, -1, 0) | |||
| 389 | + | if m.Current() != prev { | |||
| 390 | + | t.Fatal("left from the stash did not reach the previous workspace") | |||
| 391 | + | } | |||
| 392 | + | m.CancelOverview() | |||
| 393 | + | if m.Current() != w || focusedID(m) != 7 { | |||
| 394 | + | t.Fatalf("cancel left %d", focusedID(m)) | |||
| 395 | + | } | |||
| 396 | + | }) | |||
| 397 | + | } | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | // T7b: with a stash card selected, up/down and the three-finger vertical | |||
| 401 | + | // swipe cycle the pile as on main; confirming shows the picked window. | |||
| 402 | + | func TestOverviewCascadeStashPile(t *testing.T) { | |||
| 403 | + | for _, d := range cascadeDrivers { | |||
| 404 | + | t.Run(d.name, func(t *testing.T) { | |||
| 405 | + | m := cascadeOverview() | |||
| 406 | + | withStash(m, 3) | |||
| 407 | + | w := m.Current() | |||
| 408 | + | w.FocusID(7) | |||
| 409 | + | m.ToggleOverview() | |||
| 410 | + | d.move(m, -1, 0) | |||
| 411 | + | if m.cardAt(w) != w.stashAt { | |||
| 412 | + | t.Fatalf("entered at %d", m.cardAt(w)) | |||
| 413 | + | } | |||
| 414 | + | at := m.cardAt(w) | |||
| 415 | + | d.move(m, 0, -1) | |||
| 416 | + | if m.Current() != w || m.cardAt(w) != at-1 { | |||
| 417 | + | t.Fatalf("up: card %d, was %d", m.cardAt(w), at) | |||
| 418 | + | } | |||
| 419 | + | d.move(m, 0, 1) | |||
| 420 | + | d.move(m, 0, 1) | |||
| 421 | + | d.move(m, 0, 1) | |||
| 422 | + | if m.Current() != w || m.cardAt(w) != len(w.Stash)-1 { | |||
| 423 | + | t.Fatalf("down: card %d", m.cardAt(w)) | |||
| 424 | + | } | |||
| 425 | + | d.move(m, 0, -1) | |||
| 426 | + | picked := w.Stash[m.cardAt(w)].ID | |||
| 427 | + | m.ToggleOverview() | |||
| 428 | + | if focusedID(m) != picked || w.stashHidden { | |||
| 429 | + | t.Fatalf("confirm on %d, want %d (hidden %v)", focusedID(m), picked, w.stashHidden) | |||
| 430 | + | } | |||
| 431 | + | }) | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | c, _, sc, _ := stashRig(t, true) | |||
| 435 | + | m := sc.mon | |||
| 436 | + | w := m.Current() | |||
| 437 | + | w.Overflow = OverflowCascade | |||
| 438 | + | m.Focus(1) | |||
| 439 | + | m.AddWindow(50) | |||
| 440 | + | m.Focus(0) | |||
| 441 | + | m.ToggleOverview() | |||
| 442 | + | if m.cardAt(w) < 0 { | |||
| 443 | + | t.Fatal("setup: no card selected") | |||
| 444 | + | } | |||
| 445 | + | at, before := m.cardAt(w), stashIDs(w) | |||
| 446 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |||
| 447 | + | for range 3 { | |||
| 448 | + | c.swipeUpdate(ports.SwipeUpdate{DY: -40}) | |||
| 449 | + | } | |||
| 450 | + | c.swipeEnd(ports.SwipeEnd{}) | |||
| 451 | + | if m.Current() != w || m.cardAt(w) != at-1 { | |||
| 452 | + | t.Fatalf("swipe up: card %d, was %d", m.cardAt(w), at) | |||
| 453 | + | } | |||
| 454 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |||
| 455 | + | for range 3 { | |||
| 456 | + | c.swipeUpdate(ports.SwipeUpdate{DX: 40}) | |||
| 457 | + | } | |||
| 458 | + | c.swipeEnd(ports.SwipeEnd{}) | |||
| 459 | + | // A horizontal three-finger swipe is a workspace step, even over a | |||
| 460 | + | // stash card. | |||
| 461 | + | if m.Current() != m.Workspaces[1] { | |||
| 462 | + | t.Fatalf("horizontal swipe over a stash card: workspace %d", indexOf(m.Workspaces, m.Current())) | |||
| 463 | + | } | |||
| 464 | + | if got := stashIDs(w); !slices.Equal(got, before) { | |||
| 465 | + | t.Fatal("stash reordered") | |||
| 466 | + | } | |||
| 467 | + | } | |||
| 468 | + | ||||
| 469 | + | // T7c: three fingers follow the physical axes, once per gesture. | |||
| 470 | + | func TestOverviewCascadeThreeFinger(t *testing.T) { | |||
| 471 | + | c, m := cascadeCore(t) | |||
| 472 | + | w := m.Current() | |||
| 473 | + | m.ToggleOverview() | |||
| 474 | + | swipe := func(dx, dy float64) { | |||
| 475 | + | t.Helper() | |||
| 476 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |||
| 477 | + | for range 4 { | |||
| 478 | + | c.swipeUpdate(ports.SwipeUpdate{DX: dx, DY: dy}) | |||
| 479 | + | } | |||
| 480 | + | c.swipeEnd(ports.SwipeEnd{}) | |||
| 481 | + | } | |||
| 482 | + | swipe(0, 40) | |||
| 483 | + | if m.Current() != w || w.band(w.Focus) != 1 { | |||
| 484 | + | t.Fatalf("one swipe down: band %d", w.band(w.Focus)) | |||
| 485 | + | } | |||
| 486 | + | swipe(0, -40) | |||
| 487 | + | if w.band(w.Focus) != 0 { | |||
| 488 | + | t.Fatalf("one swipe up: band %d", w.band(w.Focus)) | |||
| 489 | + | } | |||
| 490 | + | swipe(40, 0) | |||
| 491 | + | if m.Current() != m.Workspaces[2] { | |||
| 492 | + | t.Fatalf("one swipe right: workspace %d", indexOf(m.Workspaces, m.Current())) | |||
| 493 | + | } | |||
| 494 | + | // Onto a scroll workspace and back: the vertical swipe is the scroll | |||
| 495 | + | // overview's workspace step; neither bounces during the gesture. | |||
| 496 | + | swipe(0, -40) | |||
| 497 | + | if m.Current() != w { | |||
| 498 | + | t.Fatalf("swipe up from the scroll workspace: workspace %d", indexOf(m.Workspaces, m.Current())) | |||
| 499 | + | } | |||
| 500 | + | swipe(-40, 0) | |||
| 501 | + | if m.Current() != m.Workspaces[0] { | |||
| 502 | + | t.Fatalf("one swipe left: workspace %d", indexOf(m.Workspaces, m.Current())) | |||
| 503 | + | } | |||
| 504 | + | } | |||
| 505 | + | ||||
| 506 | + | // T8: a named boundary of a cascade overview is a vertical rule strictly | |||
| 507 | + | // between the two slots. | |||
| 508 | + | func TestOverviewCascadeNamedDivider(t *testing.T) { | |||
| 509 | + | m := namedOverviewMonitor() | |||
| 510 | + | m.Workspaces[1].Overflow = OverflowCascade | |||
| 511 | + | m.ToggleOverview() | |||
| 512 | + | rows, dividers := m.overviewRows() | |||
| 513 | + | if len(dividers) != 1 { | |||
| 514 | + | t.Fatalf("dividers: %+v", dividers) | |||
| 515 | + | } | |||
| 516 | + | d, cur, dev := dividers[0].Rect, rows[m.Workspaces[1]], rows[m.byName("dev")] | |||
| 517 | + | if d.W != 1 || d.H <= 1 || dividers[0].Active { | |||
| 518 | + | t.Fatalf("divider is not an inactive vertical rule: %+v", dividers[0]) | |||
| 519 | + | } | |||
| 520 | + | if d.X < cur.X+cur.W || d.X+d.W > dev.X { | |||
| 521 | + | t.Fatalf("divider %v not between %v and %v", d, cur, dev) | |||
| 522 | + | } | |||
| 523 | + | } | |||
| 524 | + | ||||
| 525 | + | // T10: covering floats fan along the horizontal axis: left goes deeper in the | |||
| 526 | + | // stack (the cards behind show on the left), right comes back to the front. | |||
| 527 | + | func TestOverviewCascadeCoveringFloat(t *testing.T) { | |||
| 528 | + | for _, d := range cascadeDrivers { | |||
| 529 | + | t.Run(d.name, func(t *testing.T) { | |||
| 530 | + | m := cascadeOverview() | |||
| 531 | + | m.Current().AddFloating(90, 300, 200) | |||
| 532 | + | w := m.Current() | |||
| 533 | + | w.FocusID(10) | |||
| 534 | + | m.ToggleOverview() | |||
| 535 | + | if m.stackFront(w).kind != stackColumns { | |||
| 536 | + | t.Fatal("setup: columns not in front") | |||
| 537 | + | } | |||
| 538 | + | d.move(m, 0, -1) // columns: up is a band | |||
| 539 | + | d.move(m, -1, 0) | |||
| 540 | + | if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 { | |||
| 541 | + | t.Fatalf("left from the first column: front %+v", f) | |||
| 542 | + | } | |||
| 543 | + | if p := previewOf(t, m.Layout(), 90); p.Peek || !p.Focused { | |||
| 544 | + | t.Fatalf("float not in front: %+v", p) | |||
| 545 | + | } | |||
| 546 | + | d.move(m, 0, 1) // a float front takes no band move | |||
| 547 | + | if f := m.stackFront(w); f.kind != stackFloat { | |||
| 548 | + | t.Fatalf("down left the float: %+v", f) | |||
| 549 | + | } | |||
| 550 | + | d.move(m, 1, 0) | |||
| 551 | + | if m.stackFront(w).kind != stackColumns || m.Current() != w { | |||
| 552 | + | t.Fatalf("right from the float: front %+v", m.stackFront(w)) | |||
| 553 | + | } | |||
| 554 | + | ||||
| 555 | + | // The float in front of the columns: they fan to its left. | |||
| 556 | + | m.CancelOverview() | |||
| 557 | + | w.FocusID(90) | |||
| 558 | + | m.ToggleOverview() | |||
| 559 | + | if f := m.stackFront(w); f.kind != stackFloat { | |||
| 560 | + | t.Fatalf("setup: front %+v", f) | |||
| 561 | + | } | |||
| 562 | + | behind := previewOf(t, m.Layout(), 10) | |||
| 563 | + | if !behind.Peek { | |||
| 564 | + | t.Fatalf("columns not behind the float: %+v", behind) | |||
| 565 | + | } | |||
| 566 | + | d.move(m, -1, 0) | |||
| 567 | + | if m.stackFront(w).kind != stackColumns || m.Current() != w { | |||
| 568 | + | t.Fatalf("left from the front float: front %+v", m.stackFront(w)) | |||
| 569 | + | } | |||
| 570 | + | // The cards behind fan to the left: coming to the front, the | |||
| 571 | + | // columns move right by one peek step. | |||
| 572 | + | front := previewOf(t, m.Layout(), 10) | |||
| 573 | + | if front.Peek || front.Rect.X-behind.Rect.X != w.peekStep() { | |||
| 574 | + | t.Fatalf("columns behind %v, in front %v, peek step %d", behind.Rect, front.Rect, w.peekStep()) | |||
| 575 | + | } | |||
| 576 | + | d.move(m, 1, 0) | |||
| 577 | + | if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 { | |||
| 578 | + | t.Fatalf("right from the columns behind the float: front %+v", f) | |||
| 579 | + | } | |||
| 580 | + | }) | |||
| 581 | + | } | |||
| 582 | + | } | |||
| 583 | + | ||||
| 584 | + | // T10b: a pinned fullscreen column keeps its Fullscreen tile in the band. | |||
| 585 | + | func TestOverviewCascadePinnedFullscreenTile(t *testing.T) { | |||
| 586 | + | m := cascadeOverview() | |||
| 587 | + | w := m.Current() | |||
| 588 | + | w.FocusID(8) | |||
| 589 | + | m.ToggleFullscreen() | |||
| 590 | + | if !w.pinned() { | |||
| 591 | + | t.Fatal("setup: not pinned") | |||
| 592 | + | } | |||
| 593 | + | m.ToggleOverview() | |||
| 594 | + | ps := m.Layout() | |||
| 595 | + | fs := previewOf(t, ps, 8) | |||
| 596 | + | if !fs.Fullscreen || fs.Hidden { | |||
| 597 | + | t.Fatalf("fullscreen tile: %+v", fs) | |||
| 598 | + | } | |||
| 599 | + | for _, id := range []WindowID{7, 9} { | |||
| 600 | + | if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen || p.Peek != (fs.Peek) { | |||
| 601 | + | t.Fatalf("band tile %d: %+v", id, p) | |||
| 602 | + | } | |||
| 603 | + | } | |||
| 604 | + | u := w.overviewArea() | |||
| 605 | + | if !within1(centreY(fs.Rect), u.Y+u.H/2) { | |||
| 606 | + | t.Fatalf("fullscreen tile not on the centre lane: %+v", fs.Rect) | |||
| 607 | + | } | |||
| 608 | + | } | |||
| 609 | + | ||||
| 610 | + | // T11: the focus binds keep their meaning in a cascade overview. | |||
| 611 | + | func TestOverviewCascadeFocusBinds(t *testing.T) { | |||
| 612 | + | m := cascadeOverview() | |||
| 613 | + | w := m.Current() | |||
| 614 | + | m.ToggleOverview() | |||
| 615 | + | if !m.overviewFocus(ActionFocusWindowDown) || w.band(w.Focus) != 2 || m.Current() != w { | |||
| 616 | + | t.Fatalf("focus-window-down: band %d", w.band(w.Focus)) | |||
| 617 | + | } | |||
| 618 | + | if !m.overviewFocus(ActionFocusWindowUp) || w.band(w.Focus) != 1 { | |||
| 619 | + | t.Fatalf("focus-window-up: band %d", w.band(w.Focus)) | |||
| 620 | + | } | |||
| 621 | + | m.overviewFocus(ActionFocusWorkspaceNext) | |||
| 622 | + | if m.Current() != m.Workspaces[2] { | |||
| 623 | + | t.Fatal("focus-workspace-next did not change workspace") | |||
| 624 | + | } | |||
| 625 | + | m.overviewFocus(ActionFocusWorkspacePrev) | |||
| 626 | + | if m.Current() != w { | |||
| 627 | + | t.Fatal("focus-workspace-prev did not come back") | |||
| 628 | + | } | |||
| 629 | + | m.overviewFocus(ActionFocusWorkspacePrev) | |||
| 630 | + | if m.Current() != m.Workspaces[0] { | |||
| 631 | + | t.Fatal("focus-workspace-prev did not change workspace") | |||
| 632 | + | } | |||
| 633 | + | m.CancelOverview() | |||
| 634 | + | ||||
| 635 | + | // A selected stash card keeps the pile cycle. | |||
| 636 | + | withStash(m, 3) | |||
| 637 | + | w.FocusID(7) | |||
| 638 | + | m.ToggleOverview() | |||
| 639 | + | m.OverviewMove(-1, 0) | |||
| 640 | + | at := m.cardAt(w) | |||
| 641 | + | m.overviewFocus(ActionFocusWorkspacePrev) | |||
| 642 | + | if m.Current() != w || m.cardAt(w) != at-1 { | |||
| 643 | + | t.Fatalf("focus-workspace-prev over a card: card %d, was %d", m.cardAt(w), at) | |||
| 644 | + | } | |||
| 645 | + | m.overviewFocus(ActionFocusWorkspaceNext) | |||
| 646 | + | if m.Current() != w || m.cardAt(w) != at { | |||
| 647 | + | t.Fatalf("focus-workspace-next over a card: card %d", m.cardAt(w)) | |||
| 648 | + | } | |||
| 649 | + | } | |||
| 650 | + | ||||
| 651 | + | func TestOverviewMixedLayoutGestureAndCancel(t *testing.T) { | |||
| 652 | + | m := overviewMonitor() | |||
| 653 | + | m.SetOverflow(OverflowFixed) | |||
| 654 | + | m.Current().Overflow = OverflowCascade | |||
| 655 | + | from := m.Current() | |||
| 656 | + | m.ToggleOverview() | |||
| 657 | + | rows, _ := m.overviewRows() | |||
| 658 | + | if rows[m.Workspaces[1]].X <= rows[from].X { | |||
| 659 | + | t.Fatal("cascade neighbor not horizontal") | |||
| 660 | + | } | |||
| 661 | + | m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: overviewScrollStep}}) | |||
| 662 | + | if m.Current() == from { | |||
| 663 | + | t.Fatal("horizontal gesture did not change workspace") | |||
| 664 | + | } | |||
| 665 | + | if m.Current().Overflow != OverflowFixed { | |||
| 666 | + | t.Fatal("destination lost fixed policy") | |||
| 667 | + | } | |||
| 668 | + | if m.ov.scrollX != 0 || m.ov.scrollY != 0 || !m.ov.scrolled { | |||
| 669 | + | t.Fatalf("scroll state after the axis change: %+v", m.ov) | |||
| 670 | + | } | |||
| 671 | + | m.CancelOverview() | |||
| 672 | + | if m.Current() != from { | |||
| 673 | + | t.Fatal("cancel lost source workspace") | |||
| 674 | + | } | |||
| 675 | + | } | |||
| 676 | + | ||||
| 677 | + | // Showing a workspace with another overview axis drops the scroll leftovers | |||
| 678 | + | // of the old one. | |||
| 679 | + | func TestOverviewAxisChangeResetsScroll(t *testing.T) { | |||
| 680 | + | m := cascadeOverview() | |||
| 681 | + | m.ToggleOverview() | |||
| 682 | + | m.ov.scrollX, m.ov.scrollY, m.ov.sideways, m.ov.scrolled = 20, 20, true, true | |||
| 683 | + | m.showOverview(m.Workspaces[2]) | |||
| 684 | + | if m.ov.scrollX != 0 || m.ov.scrollY != 0 || m.ov.sideways || !m.ov.scrolled { | |||
| 685 | + | t.Fatalf("scroll kept or latch dropped: %+v", m.ov) | |||
| 686 | + | } | |||
| 687 | + | m.ov.scrollX = 20 | |||
| 688 | + | m.showOverview(m.Workspaces[0]) | |||
| 689 | + | if m.ov.scrollX != 20 { | |||
| 690 | + | t.Fatal("scroll reset between workspaces of the same axis") | |||
| 691 | + | } | |||
| 692 | + | } | |||
| 693 | + | ||||
| 694 | + | // A reload that changes the current workspace's axes drops the scroll state; | |||
| 695 | + | // one that does not, keeps it. | |||
| 696 | + | func TestOverviewReloadResetsScrollOnAxisChange(t *testing.T) { | |||
| 697 | + | c, _ := indicatorCore(t) | |||
| 698 | + | m := c.cur().mon | |||
| 699 | + | m.AddWindow(1) | |||
| 700 | + | m.ToggleOverview() | |||
| 701 | + | w := m.Current() | |||
| 702 | + | set := func() { m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 20, 10, true, true } | |||
| 703 | + | set() | |||
| 704 | + | cfg := c.cfg | |||
| 705 | + | cfg.Layout.Gaps++ | |||
| 706 | + | if err := c.apply(cfg); err != nil { | |||
| 707 | + | t.Fatal(err) | |||
| 708 | + | } | |||
| 709 | + | if m.ov.scrollX != 20 || !m.ov.scrolled { | |||
| 710 | + | t.Fatalf("unrelated reload reset the scroll: %+v", m.ov) | |||
| 711 | + | } | |||
| 712 | + | cfg.Layout.Overflow = string(OverflowCascade) | |||
| 713 | + | if err := c.apply(cfg); err != nil { | |||
| 714 | + | t.Fatal(err) | |||
| 715 | + | } | |||
| 716 | + | if m.Current().policy().workspace != horizontalAxis { | |||
| 717 | + | t.Fatal("setup: not cascade") | |||
| 718 | + | } | |||
| 719 | + | // The reload keeps the overview open on its selection, and cancelling | |||
| 720 | + | // still gives the original focus back. | |||
| 721 | + | if id, _ := w.Focused(); !m.ov.open || id != 1 { | |||
| 722 | + | t.Fatalf("reload lost the overview selection: open %v, focus %d", m.ov.open, id) | |||
| 723 | + | } | |||
| 724 | + | // The scroll is dropped, but not the latch that waits for the fingers | |||
| 725 | + | // to lift. | |||
| 726 | + | if m.ov.scrollX != 0 || m.ov.scrollY != 0 || !m.ov.scrolled || m.ov.sideways { | |||
| 727 | + | t.Fatalf("axis change kept the scroll or dropped the latch: %+v", m.ov) | |||
| 728 | + | } | |||
| 729 | + | set() | |||
| 730 | + | cfg.Layout.Overflow = string(OverflowFixed) | |||
| 731 | + | if err := c.apply(cfg); err != nil { | |||
| 732 | + | t.Fatal(err) | |||
| 733 | + | } | |||
| 734 | + | if m.ov.scrollX != 0 || !m.ov.scrolled { | |||
| 735 | + | t.Fatalf("workspace axis change kept the scroll or dropped the latch: %+v", m.ov) | |||
| 736 | + | } | |||
| 737 | + | m.CancelOverview() | |||
| 738 | + | if id, _ := w.Focused(); id != 1 { | |||
| 739 | + | t.Fatalf("cancel left the focus on %d", id) | |||
| 740 | + | } | |||
| 741 | + | } | |||
| 742 | + | ||||
| 743 | + | // The finger latch outlives an axis change: a gesture that turned the | |||
| 744 | + | // overview, then was turned back by a key, does not step again. | |||
| 745 | + | func TestOverviewFingerLatchSurvivesAxisChange(t *testing.T) { | |||
| 746 | + | c, m := cascadeCore(t) | |||
| 747 | + | w := m.Current() | |||
| 748 | + | m.ToggleOverview() | |||
| 749 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |||
| 750 | + | c.swipeUpdate(ports.SwipeUpdate{DX: 70}) | |||
| 751 | + | if m.Current() != m.Workspaces[2] || m.overviewAxis() != verticalAxis { | |||
| 752 | + | t.Fatalf("setup: workspace %d", indexOf(m.Workspaces, m.Current())) | |||
| 753 | + | } | |||
| 754 | + | // Back to the cascade workspace by a key, with the fingers still down. | |||
| 755 | + | m.overviewKey(ports.KeyEvent{Keysym: "k", Pressed: true}) | |||
| 756 | + | if m.Current() != w || m.overviewAxis() != horizontalAxis { | |||
| 757 | + | t.Fatal("setup: the key did not turn the overview back") | |||
| 758 | + | } | |||
| 759 | + | for _, u := range []ports.SwipeUpdate{{DY: 70}, {DX: 70}, {DY: -70}, {DX: -70}} { | |||
| 760 | + | c.swipeUpdate(u) | |||
| 761 | + | } | |||
| 762 | + | c.swipeEnd(ports.SwipeEnd{}) | |||
| 763 | + | if m.Current() != w || w.Focus != 0 { | |||
| 764 | + | t.Fatalf("the gesture stepped again: workspace %d, column %d", indexOf(m.Workspaces, m.Current()), w.Focus) | |||
| 765 | + | } | |||
| 766 | + | // The next gesture steps again. | |||
| 767 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |||
| 768 | + | c.swipeUpdate(ports.SwipeUpdate{DY: 70}) | |||
| 769 | + | c.swipeEnd(ports.SwipeEnd{}) | |||
| 770 | + | if w.band(w.Focus) != 1 { | |||
| 771 | + | t.Fatalf("the next gesture did not step: band %d", w.band(w.Focus)) | |||
| 772 | + | } | |||
| 773 | + | } | |||
| 774 | + | ||||
| 775 | + | // A stash-only cascade workspace has no columns to select: right from its | |||
| 776 | + | // pile goes straight to the next workspace. | |||
| 777 | + | func TestOverviewCascadeStashOnlyRight(t *testing.T) { | |||
| 778 | + | m := overviewMonitor() | |||
| 779 | + | m.Focus(1) | |||
| 780 | + | w := m.Current() | |||
| 781 | + | w.Overflow = OverflowCascade | |||
| 782 | + | w.ToggleWindowStash() | |||
| 783 | + | m.Focus(2) | |||
| 784 | + | m.AddWindow(30) | |||
| 785 | + | m.Focus(1) | |||
| 786 | + | m.ToggleOverview() | |||
| 787 | + | if len(w.Columns) != 0 || m.cardAt(w) < 0 { | |||
| 788 | + | t.Fatalf("setup: %d columns, card %d", len(w.Columns), m.cardAt(w)) | |||
| 789 | + | } | |||
| 790 | + | m.OverviewMove(1, 0) | |||
| 791 | + | if m.Current() != m.Workspaces[2] { | |||
| 792 | + | t.Fatalf("right from the pile: workspace %d", indexOf(m.Workspaces, m.Current())) | |||
| 793 | + | } | |||
| 794 | + | m.OverviewMove(0, -1) // the next workspace is a scroll one: up goes back | |||
| 795 | + | if m.Current() != w || m.cardAt(w) < 0 { | |||
| 796 | + | t.Fatal("setup: back on the stash-only workspace without its card") | |||
| 797 | + | } | |||
| 798 | + | m.OverviewMove(-1, 0) | |||
| 799 | + | if m.Current() != m.Workspaces[0] { | |||
| 800 | + | t.Fatalf("left from the pile: workspace %d", indexOf(m.Workspaces, m.Current())) | |||
| 801 | + | } | |||
| 802 | + | } | |||
| 803 | + | ||||
| 804 | + | // A cascade neighbor beside a cascade workspace draws its other lanes | |||
| 805 | + | // dimmed, its selected lane level with the centre lane. | |||
| 806 | + | func TestOverviewCascadeNeighborLanes(t *testing.T) { | |||
| 807 | + | m := cascadeOverview() | |||
| 808 | + | prev := m.Workspaces[0] | |||
| 809 | + | prev.Overflow = OverflowCascade | |||
| 810 | + | for id := WindowID(60); id <= 65; id++ { | |||
| 811 | + | prev.AddWindow(id) // bands {1,2,3} {60,61,62} {63,64,65}, 65 selected | |||
| 812 | + | } | |||
| 813 | + | m.ToggleOverview() | |||
| 814 | + | ps := m.Layout() | |||
| 815 | + | centre := centreY(previewOf(t, ps, 8).Rect) | |||
| 816 | + | sel := previewOf(t, ps, 65) | |||
| 817 | + | if sel.Hidden || !sel.Peek || sel.Focused || !within1(centreY(sel.Rect), centre) { | |||
| 818 | + | t.Fatalf("selected lane of the neighbor: %+v, centre lane %d", sel, centre) | |||
| 819 | + | } | |||
| 820 | + | above := previewOf(t, ps, 62) | |||
| 821 | + | if above.Hidden || !above.Peek || above.Rect.Y+above.Rect.H > sel.Rect.Y { | |||
| 822 | + | t.Fatalf("lane above in the neighbor: %+v, selected %v", above, sel.Rect) | |||
| 823 | + | } | |||
| 824 | + | if p := previewOf(t, ps, 3); !hiddenOff(p) { | |||
| 825 | + | t.Fatalf("lane two away in the neighbor: %+v", p) | |||
| 826 | + | } | |||
| 827 | + | } | |||
| 828 | + | ||||
| 829 | + | // A scroll neighbor wider than its slot scrolls inside it: what leaves the | |||
| 830 | + | // slot is hidden, so it never sits on the current slot. | |||
| 831 | + | func TestOverviewCascadeWideScrollNeighborIsClipped(t *testing.T) { | |||
| 832 | + | m := cascadeOverview() | |||
| 833 | + | prev := m.Workspaces[0] | |||
| 834 | + | for id := WindowID(60); id <= 67; id++ { | |||
| 835 | + | prev.AddWindow(id) | |||
| 836 | + | } | |||
| 837 | + | m.ToggleOverview() | |||
| 838 | + | rows, _ := m.overviewRows() | |||
| 839 | + | cur, slot := rows[m.Current()], rows[prev] | |||
| 840 | + | hidden, shown := 0, 0 | |||
| 841 | + | for _, p := range m.Layout() { | |||
| 842 | + | if !prev.has(p.ID) { | |||
| 843 | + | continue | |||
| 844 | + | } | |||
| 845 | + | if p.Hidden { | |||
| 846 | + | hidden++ | |||
| 847 | + | continue | |||
| 848 | + | } | |||
| 849 | + | shown++ | |||
| 850 | + | if p.Rect.X < slot.X || p.Rect.X+p.Rect.W > slot.X+slot.W || p.Rect.X+p.Rect.W > cur.X { | |||
| 851 | + | t.Fatalf("tile %d leaves its slot %v or reaches the current slot %v: %v", p.ID, slot, cur, p.Rect) | |||
| 852 | + | } | |||
| 853 | + | } | |||
| 854 | + | if hidden == 0 || shown == 0 { | |||
| 855 | + | t.Fatalf("setup: %d tiles shown, %d hidden", shown, hidden) | |||
| 856 | + | } | |||
| 857 | + | } | |||
| 858 | + | ||||
| 859 | + | // A wheel and a continuous scroll in a cascade overview: vertical changes | |||
| 860 | + | // workspace (and does not bounce back on a workspace with another axis), | |||
| 861 | + | // horizontal moves along the band. | |||
| 862 | + | func TestOverviewCascadeScroll(t *testing.T) { | |||
| 863 | + | vertical := ports.ScrollAxis{Set: true, Value: overviewScrollStep, V120: 120} | |||
| 864 | + | for _, source := range []ports.AxisSource{ports.AxisWheel, ports.AxisContinuous} { | |||
| 865 | + | t.Run(fmt.Sprint(source), func(t *testing.T) { | |||
| 866 | + | m := cascadeOverview() | |||
| 867 | + | w := m.Current() | |||
| 868 | + | m.ToggleOverview() | |||
| 869 | + | m.overviewScroll(ports.PointerAxis{Source: source, Vertical: vertical}) | |||
| 870 | + | if m.Current() != m.Workspaces[2] || w.band(w.Focus) != 1 || m.ov.scrollX != 0 || m.ov.scrollY != 0 { | |||
| 871 | + | t.Fatalf("vertical: workspace %d, band %d, scroll %v,%v", indexOf(m.Workspaces, m.Current()), w.band(w.Focus), m.ov.scrollX, m.ov.scrollY) | |||
| 872 | + | } | |||
| 873 | + | ||||
| 874 | + | m = cascadeOverview() | |||
| 875 | + | w = m.Current() | |||
| 876 | + | w.FocusID(7) | |||
| 877 | + | m.ToggleOverview() | |||
| 878 | + | m.overviewScroll(ports.PointerAxis{Source: source, Horizontal: vertical}) | |||
| 879 | + | if m.Current() != w || w.Columns[w.Focus].Windows[0] != 8 { | |||
| 880 | + | t.Fatalf("horizontal: column %d", w.Columns[w.Focus].Windows[0]) | |||
| 881 | + | } | |||
| 882 | + | ||||
| 883 | + | // Onto a workspace with another axis: one step, no bounce. | |||
| 884 | + | m = overviewMonitor() | |||
| 885 | + | m.SetOverflow(OverflowFixed) | |||
| 886 | + | m.Workspaces[1].Overflow = OverflowCascade | |||
| 887 | + | m.ToggleOverview() | |||
| 888 | + | m.overviewScroll(ports.PointerAxis{Source: source, Vertical: vertical}) | |||
| 889 | + | if m.Current() != m.Workspaces[1] || m.ov.scrollX != 0 || m.ov.scrollY != 0 { | |||
| 890 | + | t.Fatalf("mixed: workspace %d, scroll %v,%v", indexOf(m.Workspaces, m.Current()), m.ov.scrollX, m.ov.scrollY) | |||
| 891 | + | } | |||
| 892 | + | }) | |||
| 893 | + | } | |||
| 894 | + | } | |||
M internal/core/overview_stack.go +69 -28
| @@ -42,6 +42,35 @@ func (w *Workspace) columnOf(id WindowID) int { | |||
| 42 | 42 | // stashed one is its card in the pile. | |
| 43 | 43 | func (w *Workspace) stack() []stackItem { | |
| 44 | 44 | var items []stackItem | |
| 45 | + | w.walkStack(func(item stackItem) bool { | |
| 46 | + | items = append(items, item) | |
| 47 | + | return true | |
| 48 | + | }) | |
| 49 | + | return items | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // stacked reports whether the stack has several cards, without building it: | |
| 53 | + | // the render path asks for every workspace of every frame. | |
| 54 | + | func (w *Workspace) stacked() bool { | |
| 55 | + | _, n := w.stackHead() | |
| 56 | + | return n > 1 | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // stackHead is the first item of the stack and the number of items, counted | |
| 60 | + | // up to 2, without building the stack. | |
| 61 | + | func (w *Workspace) stackHead() (first stackItem, n int) { | |
| 62 | + | w.walkStack(func(item stackItem) bool { | |
| 63 | + | if n == 0 { | |
| 64 | + | first = item | |
| 65 | + | } | |
| 66 | + | n++ | |
| 67 | + | return n < 2 | |
| 68 | + | }) | |
| 69 | + | return first, n | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // walkStack yields the items of stack in order until yield returns false. | |
| 73 | + | func (w *Workspace) walkStack(yield func(stackItem) bool) { | |
| 45 | 74 | c, columns := WindowID(0), len(w.Columns) > 0 | |
| 46 | 75 | if w.pinned() { | |
| 47 | 76 | c = w.cover() | |
| @@ -49,33 +78,38 @@ func (w *Workspace) stack() []stackItem { | |||
| 49 | 78 | switch { | |
| 50 | 79 | case c == 0, w.stashIndex(c) >= 0: | |
| 51 | 80 | case w.floatIndex(c) >= 0: | |
| 52 | - | items = append(items, stackItem{stackFloat, c}) | |
| 81 | + | if !yield(stackItem{stackFloat, c}) { | |
| 82 | + | return | |
| 83 | + | } | |
| 53 | 84 | case w.overviewMaximized(): | |
| 54 | - | items = append(items, stackItem{stackColumn, c}) | |
| 85 | + | if !yield(stackItem{stackColumn, c}) { | |
| 86 | + | return | |
| 87 | + | } | |
| 55 | 88 | default: | |
| 56 | 89 | // One card of all columns, its own marked fullscreen. | |
| 57 | - | items = append(items, stackItem{kind: stackColumns}) | |
| 90 | + | if !yield(stackItem{kind: stackColumns}) { | |
| 91 | + | return | |
| 92 | + | } | |
| 58 | 93 | columns = false | |
| 59 | 94 | } | |
| 60 | 95 | for i := len(w.Floats) - 1; i >= 0; i-- { | |
| 61 | 96 | f := w.Floats[i] | |
| 62 | - | if !f.below && w.coversFloat(f) { | |
| 63 | - | items = append(items, stackItem{stackFloat, f.ID}) | |
| 97 | + | if !f.below && w.coversFloat(f) && !yield(stackItem{stackFloat, f.ID}) { | |
| 98 | + | return | |
| 64 | 99 | } | |
| 65 | 100 | } | |
| 66 | - | if (c == 0 || w.isFloat(c)) && w.overviewMaximized() { | |
| 67 | - | items = append(items, stackItem{stackColumn, w.Columns[w.Focus].Windows[0]}) | |
| 101 | + | if (c == 0 || w.isFloat(c)) && w.overviewMaximized() && !yield(stackItem{stackColumn, w.Columns[w.Focus].Windows[0]}) { | |
| 102 | + | return | |
| 68 | 103 | } | |
| 69 | - | if columns { | |
| 70 | - | items = append(items, stackItem{kind: stackColumns}) | |
| 104 | + | if columns && !yield(stackItem{kind: stackColumns}) { | |
| 105 | + | return | |
| 71 | 106 | } | |
| 72 | 107 | for i := len(w.Floats) - 1; i >= 0; i-- { | |
| 73 | 108 | f := w.Floats[i] | |
| 74 | - | if f.below && w.coversFloat(f) { | |
| 75 | - | items = append(items, stackItem{stackFloat, f.ID}) | |
| 109 | + | if f.below && w.coversFloat(f) && !yield(stackItem{stackFloat, f.ID}) { | |
| 110 | + | return | |
| 76 | 111 | } | |
| 77 | 112 | } | |
| 78 | - | return items | |
| 79 | 113 | } | |
| 80 | 114 | ||
| 81 | 115 | // itemOf resolves a clicked tile to the card currently containing it. | |
| @@ -234,10 +268,10 @@ func (w *Workspace) apply(item stackItem, selected WindowID) { | |||
| 234 | 268 | ||
| 235 | 269 | // previewItem draws a group using screen geometry and a single card at its | |
| 236 | 270 | // configured size. A prior maximized column retains its full-width buffer. | |
| 237 | - | func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool) []Placement { | |
| 271 | + | func (m *Monitor) previewItem(w *Workspace, item stackItem, axis layoutAxis, slot Rect, dim, lit bool) []Placement { | |
| 238 | 272 | if item.kind == stackColumns { | |
| 239 | 273 | if !w.overviewMaximized() { | |
| 240 | - | return w.previewRow(y, dim, lit) | |
| 274 | + | return w.previewRow(axis, slot, dim, lit) | |
| 241 | 275 | } | |
| 242 | 276 | // The hidden columns fill the screen as if the maximized column, | |
| 243 | 277 | // shown on its own card, were not there. In particular, an earlier | |
| @@ -260,20 +294,20 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool | |||
| 260 | 294 | } | |
| 261 | 295 | sel := rects[selected] | |
| 262 | 296 | sel.X -= w.Usable.X | |
| 263 | - | return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, sel) | |
| 297 | + | return w.previewRowTiles(axis, slot, dim, lit, false, tiles, w.Usable.W, sel) | |
| 264 | 298 | } | |
| 265 | 299 | g := w.gap() | |
| 266 | 300 | if item.kind == stackFloat && item.id == w.cover() { | |
| 267 | 301 | // A fullscreen float shows at the size of a fullscreen column. | |
| 268 | 302 | r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)} | |
| 269 | - | return w.previewRowTiles(y, dim, lit, []Placement{{ID: item.id, Rect: r, Focused: true, Fullscreen: true}}, w.Usable.W, r) | |
| 303 | + | return w.previewRowTiles(axis, slot, dim, lit, false, []Placement{{ID: item.id, Rect: r, Focused: true, Fullscreen: true}}, w.Usable.W, r) | |
| 270 | 304 | } | |
| 271 | 305 | if item.kind == stackFloat { | |
| 272 | 306 | f := w.Floats[w.floatIndex(item.id)] | |
| 273 | 307 | r := w.floatRect(f) | |
| 274 | 308 | r.X -= w.Usable.X | |
| 275 | 309 | r.Y -= w.Usable.Y | |
| 276 | - | return w.previewRowTiles(y, dim, lit, []Placement{{ID: item.id, Rect: r, Floating: true, Focused: true}}, w.Usable.W, r) | |
| 310 | + | return w.previewRowTiles(axis, slot, dim, lit, false, []Placement{{ID: item.id, Rect: r, Floating: true, Focused: true}}, w.Usable.W, r) | |
| 277 | 311 | } | |
| 278 | 312 | i := w.columnOf(item.id) | |
| 279 | 313 | if i < 0 { | |
| @@ -288,7 +322,7 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool | |||
| 288 | 322 | for j, t := range w.rowBuf { | |
| 289 | 323 | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()}) | |
| 290 | 324 | } | |
| 291 | - | return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, r) | |
| 325 | + | return w.previewRowTiles(axis, slot, dim, lit, false, tiles, w.Usable.W, r) | |
| 292 | 326 | } | |
| 293 | 327 | ||
| 294 | 328 | // hiddenColumnRects lays out the columns behind the maximized one with the | |
| @@ -301,27 +335,34 @@ func (w *Workspace) hiddenColumnRects() []Rect { | |||
| 301 | 335 | return slices.Insert(v.columnRectsFor(true), w.Focus, Rect{}) | |
| 302 | 336 | } | |
| 303 | 337 | ||
| 304 | - | // stackRow draws up to two cards on each side of the provisional front. | |
| 305 | - | func (m *Monitor) stackRow(w *Workspace, y int, dim, lit bool) []Placement { | |
| 306 | - | items := w.stack() | |
| 307 | - | if len(items) == 0 { | |
| 308 | - | return w.previewRow(y, dim, lit) | |
| 338 | + | // stackRow draws up to two cards on each side of the provisional front, | |
| 339 | + | // fanned along axis. | |
| 340 | + | func (m *Monitor) stackRow(w *Workspace, axis layoutAxis, slot Rect, dim, lit bool) []Placement { | |
| 341 | + | first, n := w.stackHead() | |
| 342 | + | if n == 0 { | |
| 343 | + | return w.previewRow(axis, slot, dim, lit) | |
| 309 | 344 | } | |
| 310 | - | if len(items) == 1 { | |
| 311 | - | return m.previewItem(w, items[0], y, dim, lit) | |
| 345 | + | if n == 1 { | |
| 346 | + | return m.previewItem(w, first, axis, slot, dim, lit) | |
| 312 | 347 | } | |
| 348 | + | items := w.stack() | |
| 313 | 349 | at := 0 | |
| 314 | 350 | if w == m.Current() { | |
| 315 | 351 | at = slices.Index(items, m.stackFront(w)) | |
| 316 | 352 | } | |
| 317 | 353 | cards := make([][]Placement, len(items)) | |
| 318 | 354 | step := w.peekStep() | |
| 355 | + | // along is the offset of d steps on the overview axis. | |
| 356 | + | along := func(d int) (int, int) { | |
| 357 | + | var r Rect | |
| 358 | + | axis.offset(&r, d*step) | |
| 359 | + | return r.X, r.Y | |
| 360 | + | } | |
| 319 | 361 | for i, item := range items { | |
| 320 | - | cards[i] = m.previewItem(w, item, w.frontRowY(y), dim, lit) | |
| 362 | + | cards[i] = m.previewItem(w, item, axis, w.frontSlot(axis, slot), dim, lit) | |
| 321 | 363 | } | |
| 322 | 364 | ||
| 323 | 365 | // fan's behind side advances through the linear stack. | |
| 324 | 366 | return fan(cards, at, 2, 2, false, | |
| 325 | - | func(d int) (int, int) { return 0, -d * step }, | |
| 326 | - | func(d int) (int, int) { return 0, d * step }, dim, lit) | |
| 367 | + | func(d int) (int, int) { return along(-d) }, along, dim, lit) | |
| 327 | 368 | } | |
| @@ -42,6 +42,35 @@ func (w *Workspace) columnOf(id WindowID) int { | |||||
| 42 | // stashed one is its card in the pile. | 42 | // stashed one is its card in the pile. | ||
| 43 | func (w *Workspace) stack() []stackItem { | 43 | func (w *Workspace) stack() []stackItem { | ||
| 44 | var items []stackItem | 44 | var items []stackItem | ||
| 45 | + | w.walkStack(func(item stackItem) bool { | |||
| 46 | + | items = append(items, item) | |||
| 47 | + | return true | |||
| 48 | + | }) | |||
| 49 | + | return items | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // stacked reports whether the stack has several cards, without building it: | |||
| 53 | + | // the render path asks for every workspace of every frame. | |||
| 54 | + | func (w *Workspace) stacked() bool { | |||
| 55 | + | _, n := w.stackHead() | |||
| 56 | + | return n > 1 | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // stackHead is the first item of the stack and the number of items, counted | |||
| 60 | + | // up to 2, without building the stack. | |||
| 61 | + | func (w *Workspace) stackHead() (first stackItem, n int) { | |||
| 62 | + | w.walkStack(func(item stackItem) bool { | |||
| 63 | + | if n == 0 { | |||
| 64 | + | first = item | |||
| 65 | + | } | |||
| 66 | + | n++ | |||
| 67 | + | return n < 2 | |||
| 68 | + | }) | |||
| 69 | + | return first, n | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // walkStack yields the items of stack in order until yield returns false. | |||
| 73 | + | func (w *Workspace) walkStack(yield func(stackItem) bool) { | |||
| 45 | c, columns := WindowID(0), len(w.Columns) > 0 | 74 | c, columns := WindowID(0), len(w.Columns) > 0 | ||
| 46 | if w.pinned() { | 75 | if w.pinned() { | ||
| 47 | c = w.cover() | 76 | c = w.cover() | ||
| @@ -49,33 +78,38 @@ func (w *Workspace) stack() []stackItem { | |||||
| 49 | switch { | 78 | switch { | ||
| 50 | case c == 0, w.stashIndex(c) >= 0: | 79 | case c == 0, w.stashIndex(c) >= 0: | ||
| 51 | case w.floatIndex(c) >= 0: | 80 | case w.floatIndex(c) >= 0: | ||
| 52 | - | items = append(items, stackItem{stackFloat, c}) | 81 | + | if !yield(stackItem{stackFloat, c}) { |
| 82 | + | return | |||
| 83 | + | } | |||
| 53 | case w.overviewMaximized(): | 84 | case w.overviewMaximized(): | ||
| 54 | - | items = append(items, stackItem{stackColumn, c}) | 85 | + | if !yield(stackItem{stackColumn, c}) { |
| 86 | + | return | |||
| 87 | + | } | |||
| 55 | default: | 88 | default: | ||
| 56 | // One card of all columns, its own marked fullscreen. | 89 | // One card of all columns, its own marked fullscreen. | ||
| 57 | - | items = append(items, stackItem{kind: stackColumns}) | 90 | + | if !yield(stackItem{kind: stackColumns}) { |
| 91 | + | return | |||
| 92 | + | } | |||
| 58 | columns = false | 93 | columns = false | ||
| 59 | } | 94 | } | ||
| 60 | for i := len(w.Floats) - 1; i >= 0; i-- { | 95 | for i := len(w.Floats) - 1; i >= 0; i-- { | ||
| 61 | f := w.Floats[i] | 96 | f := w.Floats[i] | ||
| 62 | - | if !f.below && w.coversFloat(f) { | 97 | + | if !f.below && w.coversFloat(f) && !yield(stackItem{stackFloat, f.ID}) { |
| 63 | - | items = append(items, stackItem{stackFloat, f.ID}) | 98 | + | return |
| 64 | } | 99 | } | ||
| 65 | } | 100 | } | ||
| 66 | - | if (c == 0 || w.isFloat(c)) && w.overviewMaximized() { | 101 | + | if (c == 0 || w.isFloat(c)) && w.overviewMaximized() && !yield(stackItem{stackColumn, w.Columns[w.Focus].Windows[0]}) { |
| 67 | - | items = append(items, stackItem{stackColumn, w.Columns[w.Focus].Windows[0]}) | 102 | + | return |
| 68 | } | 103 | } | ||
| 69 | - | if columns { | 104 | + | if columns && !yield(stackItem{kind: stackColumns}) { |
| 70 | - | items = append(items, stackItem{kind: stackColumns}) | 105 | + | return |
| 71 | } | 106 | } | ||
| 72 | for i := len(w.Floats) - 1; i >= 0; i-- { | 107 | for i := len(w.Floats) - 1; i >= 0; i-- { | ||
| 73 | f := w.Floats[i] | 108 | f := w.Floats[i] | ||
| 74 | - | if f.below && w.coversFloat(f) { | 109 | + | if f.below && w.coversFloat(f) && !yield(stackItem{stackFloat, f.ID}) { |
| 75 | - | items = append(items, stackItem{stackFloat, f.ID}) | 110 | + | return |
| 76 | } | 111 | } | ||
| 77 | } | 112 | } | ||
| 78 | - | return items | |||
| 79 | } | 113 | } | ||
| 80 | 114 | ||||
| 81 | // itemOf resolves a clicked tile to the card currently containing it. | 115 | // itemOf resolves a clicked tile to the card currently containing it. | ||
| @@ -234,10 +268,10 @@ func (w *Workspace) apply(item stackItem, selected WindowID) { | |||||
| 234 | 268 | ||||
| 235 | // previewItem draws a group using screen geometry and a single card at its | 269 | // previewItem draws a group using screen geometry and a single card at its | ||
| 236 | // configured size. A prior maximized column retains its full-width buffer. | 270 | // configured size. A prior maximized column retains its full-width buffer. | ||
| 237 | - | func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool) []Placement { | 271 | + | func (m *Monitor) previewItem(w *Workspace, item stackItem, axis layoutAxis, slot Rect, dim, lit bool) []Placement { |
| 238 | if item.kind == stackColumns { | 272 | if item.kind == stackColumns { | ||
| 239 | if !w.overviewMaximized() { | 273 | if !w.overviewMaximized() { | ||
| 240 | - | return w.previewRow(y, dim, lit) | 274 | + | return w.previewRow(axis, slot, dim, lit) |
| 241 | } | 275 | } | ||
| 242 | // The hidden columns fill the screen as if the maximized column, | 276 | // The hidden columns fill the screen as if the maximized column, | ||
| 243 | // shown on its own card, were not there. In particular, an earlier | 277 | // shown on its own card, were not there. In particular, an earlier | ||
| @@ -260,20 +294,20 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool | |||||
| 260 | } | 294 | } | ||
| 261 | sel := rects[selected] | 295 | sel := rects[selected] | ||
| 262 | sel.X -= w.Usable.X | 296 | sel.X -= w.Usable.X | ||
| 263 | - | return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, sel) | 297 | + | return w.previewRowTiles(axis, slot, dim, lit, false, tiles, w.Usable.W, sel) |
| 264 | } | 298 | } | ||
| 265 | g := w.gap() | 299 | g := w.gap() | ||
| 266 | if item.kind == stackFloat && item.id == w.cover() { | 300 | if item.kind == stackFloat && item.id == w.cover() { | ||
| 267 | // A fullscreen float shows at the size of a fullscreen column. | 301 | // A fullscreen float shows at the size of a fullscreen column. | ||
| 268 | r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)} | 302 | r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)} | ||
| 269 | - | return w.previewRowTiles(y, dim, lit, []Placement{{ID: item.id, Rect: r, Focused: true, Fullscreen: true}}, w.Usable.W, r) | 303 | + | return w.previewRowTiles(axis, slot, dim, lit, false, []Placement{{ID: item.id, Rect: r, Focused: true, Fullscreen: true}}, w.Usable.W, r) |
| 270 | } | 304 | } | ||
| 271 | if item.kind == stackFloat { | 305 | if item.kind == stackFloat { | ||
| 272 | f := w.Floats[w.floatIndex(item.id)] | 306 | f := w.Floats[w.floatIndex(item.id)] | ||
| 273 | r := w.floatRect(f) | 307 | r := w.floatRect(f) | ||
| 274 | r.X -= w.Usable.X | 308 | r.X -= w.Usable.X | ||
| 275 | r.Y -= w.Usable.Y | 309 | r.Y -= w.Usable.Y | ||
| 276 | - | return w.previewRowTiles(y, dim, lit, []Placement{{ID: item.id, Rect: r, Floating: true, Focused: true}}, w.Usable.W, r) | 310 | + | return w.previewRowTiles(axis, slot, dim, lit, false, []Placement{{ID: item.id, Rect: r, Floating: true, Focused: true}}, w.Usable.W, r) |
| 277 | } | 311 | } | ||
| 278 | i := w.columnOf(item.id) | 312 | i := w.columnOf(item.id) | ||
| 279 | if i < 0 { | 313 | if i < 0 { | ||
| @@ -288,7 +322,7 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool | |||||
| 288 | for j, t := range w.rowBuf { | 322 | for j, t := range w.rowBuf { | ||
| 289 | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()}) | 323 | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()}) | ||
| 290 | } | 324 | } | ||
| 291 | - | return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, r) | 325 | + | return w.previewRowTiles(axis, slot, dim, lit, false, tiles, w.Usable.W, r) |
| 292 | } | 326 | } | ||
| 293 | 327 | ||||
| 294 | // hiddenColumnRects lays out the columns behind the maximized one with the | 328 | // hiddenColumnRects lays out the columns behind the maximized one with the | ||
| @@ -301,27 +335,34 @@ func (w *Workspace) hiddenColumnRects() []Rect { | |||||
| 301 | return slices.Insert(v.columnRectsFor(true), w.Focus, Rect{}) | 335 | return slices.Insert(v.columnRectsFor(true), w.Focus, Rect{}) | ||
| 302 | } | 336 | } | ||
| 303 | 337 | ||||
| 304 | - | // stackRow draws up to two cards on each side of the provisional front. | 338 | + | // stackRow draws up to two cards on each side of the provisional front, |
| 305 | - | func (m *Monitor) stackRow(w *Workspace, y int, dim, lit bool) []Placement { | 339 | + | // fanned along axis. |
| 306 | - | items := w.stack() | 340 | + | func (m *Monitor) stackRow(w *Workspace, axis layoutAxis, slot Rect, dim, lit bool) []Placement { |
| 307 | - | if len(items) == 0 { | 341 | + | first, n := w.stackHead() |
| 308 | - | return w.previewRow(y, dim, lit) | 342 | + | if n == 0 { |
| 343 | + | return w.previewRow(axis, slot, dim, lit) | |||
| 309 | } | 344 | } | ||
| 310 | - | if len(items) == 1 { | 345 | + | if n == 1 { |
| 311 | - | return m.previewItem(w, items[0], y, dim, lit) | 346 | + | return m.previewItem(w, first, axis, slot, dim, lit) |
| 312 | } | 347 | } | ||
| 348 | + | items := w.stack() | |||
| 313 | at := 0 | 349 | at := 0 | ||
| 314 | if w == m.Current() { | 350 | if w == m.Current() { | ||
| 315 | at = slices.Index(items, m.stackFront(w)) | 351 | at = slices.Index(items, m.stackFront(w)) | ||
| 316 | } | 352 | } | ||
| 317 | cards := make([][]Placement, len(items)) | 353 | cards := make([][]Placement, len(items)) | ||
| 318 | step := w.peekStep() | 354 | step := w.peekStep() | ||
| 355 | + | // along is the offset of d steps on the overview axis. | |||
| 356 | + | along := func(d int) (int, int) { | |||
| 357 | + | var r Rect | |||
| 358 | + | axis.offset(&r, d*step) | |||
| 359 | + | return r.X, r.Y | |||
| 360 | + | } | |||
| 319 | for i, item := range items { | 361 | for i, item := range items { | ||
| 320 | - | cards[i] = m.previewItem(w, item, w.frontRowY(y), dim, lit) | 362 | + | cards[i] = m.previewItem(w, item, axis, w.frontSlot(axis, slot), dim, lit) |
| 321 | } | 363 | } | ||
| 322 | 364 | ||||
| 323 | // fan's behind side advances through the linear stack. | 365 | // fan's behind side advances through the linear stack. | ||
| 324 | return fan(cards, at, 2, 2, false, | 366 | return fan(cards, at, 2, 2, false, | ||
| 325 | - | func(d int) (int, int) { return 0, -d * step }, | 367 | + | func(d int) (int, int) { return along(-d) }, along, dim, lit) |
| 326 | - | func(d int) (int, int) { return 0, d * step }, dim, lit) | |||
| 327 | } | 368 | } | ||
M internal/core/publish_alloc_test.go +55 -19
| @@ -187,26 +187,62 @@ func TestPublishStashSlideAllocations(t *testing.T) { | |||
| 187 | 187 | ||
| 188 | 188 | // TestPublishOverviewAllocations pins the cost of publishing while the | |
| 189 | 189 | // overview opens: its cards fly from the full rects (scale and fade motions | |
| 190 | - | // on every card), which guards the reuse of Core.overviewReal. | |
| 190 | + | // on every card), which guards the reuse of Core.overviewReal. The current | |
| 191 | + | // workspace is scroll or cascade, alone or between two workspaces with | |
| 192 | + | // windows (their slots and stacks are laid out every frame too). | |
| 191 | 193 | func TestPublishOverviewAllocations(t *testing.T) { | |
| 192 | - | c := publishRig(t, 1) | |
| 193 | - | sc := c.cur() | |
| 194 | - | now := time.Now() | |
| 195 | - | shots := c.snapshot(now) | |
| 196 | - | sc.mon.ToggleOverview() | |
| 197 | - | c.transition(shots, now) | |
| 198 | - | step := publishStep(t, c, nil) | |
| 199 | - | step() | |
| 200 | - | if !sc.mon.ov.open || len(c.overviewReal) != 6 || len(sc.rects) == 0 || !c.animating() { | |
| 201 | - | t.Fatalf("setup: overview %v, real layouts %d, rects %d, animating %v", sc.mon.ov.open, len(c.overviewReal), len(sc.rects), c.animating()) | |
| 202 | - | } | |
| 203 | - | n := testing.AllocsPerRun(50, step) | |
| 204 | - | t.Logf("overview allocs per step = %v", n) | |
| 205 | - | if len(sc.rects) == 0 { | |
| 206 | - | t.Fatal("card motions settled during the measure") | |
| 207 | - | } | |
| 208 | - | if n > publishOverviewAllocBudget { | |
| 209 | - | t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget) | |
| 194 | + | for _, overflow := range []Overflow{OverflowScroll, OverflowCascade} { | |
| 195 | + | for _, neighbors := range []bool{false, true} { | |
| 196 | + | name := string(overflow) + "/alone" | |
| 197 | + | if neighbors { | |
| 198 | + | name = string(overflow) + "/neighbors" | |
| 199 | + | } | |
| 200 | + | t.Run(name, func(t *testing.T) { | |
| 201 | + | c := publishRig(t, 1) | |
| 202 | + | sc := c.cur() | |
| 203 | + | m := sc.mon | |
| 204 | + | if neighbors { | |
| 205 | + | // Windows 1..6 are on workspace 0: move to 1 with a | |
| 206 | + | // workspace on each side. | |
| 207 | + | m.Focus(1) | |
| 208 | + | for id := WindowID(10); id < 16; id++ { | |
| 209 | + | m.AddWindow(id) | |
| 210 | + | } | |
| 211 | + | m.Focus(2) | |
| 212 | + | for id := WindowID(20); id < 26; id++ { | |
| 213 | + | m.AddWindow(id) | |
| 214 | + | } | |
| 215 | + | m.Focus(1) | |
| 216 | + | } | |
| 217 | + | m.Current().Overflow = overflow | |
| 218 | + | sc.arrange() | |
| 219 | + | c.refreshShown() | |
| 220 | + | now := time.Now() | |
| 221 | + | shots := c.snapshot(now) | |
| 222 | + | m.ToggleOverview() | |
| 223 | + | c.transition(shots, now) | |
| 224 | + | step := publishStep(t, c, nil) | |
| 225 | + | step() | |
| 226 | + | want, slots := 6, 1 | |
| 227 | + | if neighbors { | |
| 228 | + | want, slots = 18, 3 | |
| 229 | + | } | |
| 230 | + | if rows, _ := m.overviewRows(); len(rows) != slots { | |
| 231 | + | t.Fatalf("setup: %d overview slots, want %d", len(rows), slots) | |
| 232 | + | } | |
| 233 | + | if !m.ov.open || m.overviewAxis() != m.Current().policy().workspace || len(c.overviewReal) != want || len(sc.rects) == 0 || !c.animating() { | |
| 234 | + | t.Fatalf("setup: overview %v, real layouts %d (want %d), rects %d, animating %v", m.ov.open, len(c.overviewReal), want, len(sc.rects), c.animating()) | |
| 235 | + | } | |
| 236 | + | n := testing.AllocsPerRun(50, step) | |
| 237 | + | t.Logf("%s overview allocs per step = %v", name, n) | |
| 238 | + | if len(sc.rects) == 0 { | |
| 239 | + | t.Fatal("card motions settled during the measure") | |
| 240 | + | } | |
| 241 | + | if n > publishOverviewAllocBudget { | |
| 242 | + | t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget) | |
| 243 | + | } | |
| 244 | + | }) | |
| 245 | + | } | |
| 210 | 246 | } | |
| 211 | 247 | } | |
| 212 | 248 | ||
| @@ -187,26 +187,62 @@ func TestPublishStashSlideAllocations(t *testing.T) { | |||||
| 187 | 187 | ||||
| 188 | // TestPublishOverviewAllocations pins the cost of publishing while the | 188 | // TestPublishOverviewAllocations pins the cost of publishing while the | ||
| 189 | // overview opens: its cards fly from the full rects (scale and fade motions | 189 | // overview opens: its cards fly from the full rects (scale and fade motions | ||
| 190 | - | // on every card), which guards the reuse of Core.overviewReal. | 190 | + | // on every card), which guards the reuse of Core.overviewReal. The current |
| 191 | + | // workspace is scroll or cascade, alone or between two workspaces with | |||
| 192 | + | // windows (their slots and stacks are laid out every frame too). | |||
| 191 | func TestPublishOverviewAllocations(t *testing.T) { | 193 | func TestPublishOverviewAllocations(t *testing.T) { | ||
| 192 | - | c := publishRig(t, 1) | 194 | + | for _, overflow := range []Overflow{OverflowScroll, OverflowCascade} { |
| 193 | - | sc := c.cur() | 195 | + | for _, neighbors := range []bool{false, true} { |
| 194 | - | now := time.Now() | 196 | + | name := string(overflow) + "/alone" |
| 195 | - | shots := c.snapshot(now) | 197 | + | if neighbors { |
| 196 | - | sc.mon.ToggleOverview() | 198 | + | name = string(overflow) + "/neighbors" |
| 197 | - | c.transition(shots, now) | 199 | + | } |
| 198 | - | step := publishStep(t, c, nil) | 200 | + | t.Run(name, func(t *testing.T) { |
| 199 | - | step() | 201 | + | c := publishRig(t, 1) |
| 200 | - | if !sc.mon.ov.open || len(c.overviewReal) != 6 || len(sc.rects) == 0 || !c.animating() { | 202 | + | sc := c.cur() |
| 201 | - | t.Fatalf("setup: overview %v, real layouts %d, rects %d, animating %v", sc.mon.ov.open, len(c.overviewReal), len(sc.rects), c.animating()) | 203 | + | m := sc.mon |
| 202 | - | } | 204 | + | if neighbors { |
| 203 | - | n := testing.AllocsPerRun(50, step) | 205 | + | // Windows 1..6 are on workspace 0: move to 1 with a |
| 204 | - | t.Logf("overview allocs per step = %v", n) | 206 | + | // workspace on each side. |
| 205 | - | if len(sc.rects) == 0 { | 207 | + | m.Focus(1) |
| 206 | - | t.Fatal("card motions settled during the measure") | 208 | + | for id := WindowID(10); id < 16; id++ { |
| 207 | - | } | 209 | + | m.AddWindow(id) |
| 208 | - | if n > publishOverviewAllocBudget { | 210 | + | } |
| 209 | - | t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget) | 211 | + | m.Focus(2) |
| 212 | + | for id := WindowID(20); id < 26; id++ { | |||
| 213 | + | m.AddWindow(id) | |||
| 214 | + | } | |||
| 215 | + | m.Focus(1) | |||
| 216 | + | } | |||
| 217 | + | m.Current().Overflow = overflow | |||
| 218 | + | sc.arrange() | |||
| 219 | + | c.refreshShown() | |||
| 220 | + | now := time.Now() | |||
| 221 | + | shots := c.snapshot(now) | |||
| 222 | + | m.ToggleOverview() | |||
| 223 | + | c.transition(shots, now) | |||
| 224 | + | step := publishStep(t, c, nil) | |||
| 225 | + | step() | |||
| 226 | + | want, slots := 6, 1 | |||
| 227 | + | if neighbors { | |||
| 228 | + | want, slots = 18, 3 | |||
| 229 | + | } | |||
| 230 | + | if rows, _ := m.overviewRows(); len(rows) != slots { | |||
| 231 | + | t.Fatalf("setup: %d overview slots, want %d", len(rows), slots) | |||
| 232 | + | } | |||
| 233 | + | if !m.ov.open || m.overviewAxis() != m.Current().policy().workspace || len(c.overviewReal) != want || len(sc.rects) == 0 || !c.animating() { | |||
| 234 | + | t.Fatalf("setup: overview %v, real layouts %d (want %d), rects %d, animating %v", m.ov.open, len(c.overviewReal), want, len(sc.rects), c.animating()) | |||
| 235 | + | } | |||
| 236 | + | n := testing.AllocsPerRun(50, step) | |||
| 237 | + | t.Logf("%s overview allocs per step = %v", name, n) | |||
| 238 | + | if len(sc.rects) == 0 { | |||
| 239 | + | t.Fatal("card motions settled during the measure") | |||
| 240 | + | } | |||
| 241 | + | if n > publishOverviewAllocBudget { | |||
| 242 | + | t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget) | |||
| 243 | + | } | |||
| 244 | + | }) | |||
| 245 | + | } | |||
| 210 | } | 246 | } | ||
| 211 | } | 247 | } | ||
| 212 | 248 | ||||