feat(core): overview row restore, stash pile fix and move binds (#84)
* test(core): cover overview click and escape through core input * fix(core): restore browsed overview rows on close * fix(core): leave the overview stash pile toward floats without columns * refactor(core): route overview binds through one gate * feat(core): move the selected overview preview to a workspace with move binds * fix(core): move only a selected overview preview and keep its destination focus * fix(core): keep the opening row snapshot when an overview move lands elsewhere * test(core): cover overview moves from a fixed fullscreen tile
- commit
- 01dbe2a42f9c76f6d4597bbbebeaaefbe2103482
- author
- bnema · · 2026-10-01 16:13 UTC
- committer
- GitHub · · 2026-10-01 16:13 UTC
- parent
- 348689c
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
11 files · +677 -117
M docs/config.md +3 -3
| @@ -152,8 +152,8 @@ Native floating windows are not in the stash: they stay centred in the usable ou | |||
| 152 | 152 | | --- | --- | | |
| 153 | 153 | | `h` / `l`, `left` / `right` | Move within a front column-group card; left of its first tile enters the stash. Left on a single-window card sends it behind, or enters the stash when none is behind; right does nothing on a single card. | | |
| 154 | 154 | | `k` / `j`, `up` / `down` | Bring the next card above / previous card below to the front, then change workspace at the stack boundary | | |
| 155 | - | | `return` | Show the selected front card; picking a hidden column moves the maximization to it | | |
| 156 | - | | `escape` | Restore the original focus, maximization and float order | | |
| 155 | + | | `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 | | |
| 156 | + | | `escape` | Return to the original workspace; every row shown keeps its focus, maximization and float order | | |
| 157 | 157 | ||
| 158 | 158 | 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-up/down` also traverse cards before crossing workspace rows, including named ones. | |
| 159 | 159 | ||
| @@ -161,7 +161,7 @@ Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the | |||
| 161 | 161 | ||
| 162 | 162 | A four-finger swipe up opens the overview and a swipe down closes it on the selection, whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through stack cards before crossing workspaces, following `touchpad.natural-scroll`. A three-finger swipe moves it one step when the fingers lift. | |
| 163 | 163 | ||
| 164 | - | 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. Window mutation binds (moving, resizing, maximizing, fullscreen and stash toggles) and workspace moves are disabled while the overview is open. Workspace and monitor navigation, launch and quit binds remain active. | |
| 164 | + | 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. | |
| 165 | 165 | ||
| 166 | 166 | ### Default binds | |
| 167 | 167 | ||
| @@ -152,8 +152,8 @@ Native floating windows are not in the stash: they stay centred in the usable ou | |||||
| 152 | | --- | --- | | 152 | | --- | --- | | ||
| 153 | | `h` / `l`, `left` / `right` | Move within a front column-group card; left of its first tile enters the stash. Left on a single-window card sends it behind, or enters the stash when none is behind; right does nothing on a single card. | | 153 | | `h` / `l`, `left` / `right` | Move within a front column-group card; left of its first tile enters the stash. Left on a single-window card sends it behind, or enters the stash when none is behind; right does nothing on a single card. | | ||
| 154 | | `k` / `j`, `up` / `down` | Bring the next card above / previous card below to the front, then change workspace at the stack boundary | | 154 | | `k` / `j`, `up` / `down` | Bring the next card above / previous card below to the front, then change workspace at the stack boundary | | ||
| 155 | - | | `return` | Show the selected front card; picking a hidden column moves the maximization to it | | 155 | + | | `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 | |
| 156 | - | | `escape` | Restore the original focus, maximization and float order | | 156 | + | | `escape` | Return to the original workspace; every row shown keeps its focus, maximization and float order | |
| 157 | 157 | ||||
| 158 | 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-up/down` also traverse cards before crossing workspace rows, including named ones. | 158 | 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-up/down` also traverse cards before crossing workspace rows, including named ones. | ||
| 159 | 159 | ||||
| @@ -161,7 +161,7 @@ Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the | |||||
| 161 | 161 | ||||
| 162 | A four-finger swipe up opens the overview and a swipe down closes it on the selection, whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through stack cards before crossing workspaces, following `touchpad.natural-scroll`. A three-finger swipe moves it one step when the fingers lift. | 162 | A four-finger swipe up opens the overview and a swipe down closes it on the selection, whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through stack cards before crossing workspaces, following `touchpad.natural-scroll`. A three-finger swipe moves it one step when the fingers lift. | ||
| 163 | 163 | ||||
| 164 | - | 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. Window mutation binds (moving, resizing, maximizing, fullscreen and stash toggles) and workspace moves are disabled while the overview is open. Workspace and monitor navigation, launch and quit binds remain active. | 164 | + | 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. |
| 165 | 165 | ||||
| 166 | ### Default binds | 166 | ### Default binds | ||
| 167 | 167 | ||||
M internal/core/action.go +45 -31
| @@ -109,13 +109,8 @@ type Effect struct { | |||
| 109 | 109 | // screen; column focus past the edge column moves to the neighbor screen; | |
| 110 | 110 | // everything else applies to the focused screen's monitor. | |
| 111 | 111 | func (c *Core) applyAction(a Action) Effect { | |
| 112 | - | if m := c.cur().mon; m.ov.open { | |
| 113 | - | if m.overviewFocus(a) || overviewBlocks(a) { | |
| 114 | - | return Effect{} | |
| 115 | - | } | |
| 116 | - | if a == ActionCloseWindow { | |
| 117 | - | return m.Apply(a) | |
| 118 | - | } | |
| 112 | + | if e, ok := c.cur().mon.overviewAction(a); ok { | |
| 113 | + | return e | |
| 119 | 114 | } | |
| 120 | 115 | // A bind acts on the window on screen: under a covering fullscreen | |
| 121 | 116 | // window, that is the one Focused reports. | |
| @@ -222,23 +217,20 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 222 | 217 | ||
| 223 | 218 | // Apply runs a bind action on the monitor. | |
| 224 | 219 | func (m *Monitor) Apply(a Action) Effect { | |
| 225 | - | if m.ov.open { | |
| 226 | - | if a == ActionCloseWindow { | |
| 227 | - | return Effect{Close: m.overviewTarget()} | |
| 228 | - | } | |
| 229 | - | if m.overviewFocus(a) || overviewBlocks(a) { | |
| 230 | - | return Effect{} | |
| 220 | + | if e, ok := m.overviewAction(a); ok { | |
| 221 | + | return e | |
| 222 | + | } | |
| 223 | + | if movesToWorkspace(a) { | |
| 224 | + | if i, column, ok := m.moveDest(a); ok { | |
| 225 | + | m.MoveToWorkspace(i, column) | |
| 231 | 226 | } | |
| 227 | + | return Effect{} | |
| 232 | 228 | } | |
| 233 | - | if n, op, ok := WorkspaceArg(a); ok { | |
| 234 | - | if op == FocusWorkspace { | |
| 235 | - | before := m.Current() | |
| 236 | - | m.FocusNumber(n) | |
| 237 | - | if m.ov.open && m.Current() != before { | |
| 238 | - | m.selectRow() | |
| 239 | - | } | |
| 240 | - | } else { | |
| 241 | - | m.MoveToWorkspace(n-1, op == MoveColumnToWorkspace) | |
| 229 | + | if n, _, ok := WorkspaceArg(a); ok { | |
| 230 | + | before := m.Current() | |
| 231 | + | m.FocusNumber(n) | |
| 232 | + | if m.ov.open && m.Current() != before { | |
| 233 | + | m.selectRow() | |
| 242 | 234 | } | |
| 243 | 235 | return Effect{} | |
| 244 | 236 | } | |
| @@ -291,18 +283,40 @@ func (m *Monitor) Apply(a Action) Effect { | |||
| 291 | 283 | m.Focus(m.Active + 1) | |
| 292 | 284 | } | |
| 293 | 285 | return Effect{} | |
| 286 | + | } | |
| 287 | + | return m.Current().Apply(a) | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | // movesToWorkspace reports a bind that moves a window to another workspace. | |
| 291 | + | func movesToWorkspace(a Action) bool { | |
| 292 | + | if _, op, ok := WorkspaceArg(a); ok { | |
| 293 | + | return op != FocusWorkspace | |
| 294 | + | } | |
| 295 | + | switch a { | |
| 296 | + | case ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, | |
| 297 | + | ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown: | |
| 298 | + | return true | |
| 299 | + | } | |
| 300 | + | return false | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | // moveDest resolves a move-to-workspace bind to the workspace index it | |
| 304 | + | // moves to and whether the whole column goes; ok is false when it has | |
| 305 | + | // nowhere to go. Up and down do nothing on a named workspace. | |
| 306 | + | func (m *Monitor) moveDest(a Action) (i int, column, ok bool) { | |
| 307 | + | if n, op, isArg := WorkspaceArg(a); isArg { | |
| 308 | + | return min(max(n-1, 0), len(m.Workspaces)-1), op == MoveColumnToWorkspace, true | |
| 309 | + | } | |
| 310 | + | if m.shown != nil { | |
| 311 | + | return 0, false, false | |
| 312 | + | } | |
| 313 | + | switch a { | |
| 294 | 314 | case ActionMoveColumnToWorkspaceUp, ActionMoveWindowToWorkspaceUp: | |
| 295 | - | if m.shown == nil && m.Active > 0 { | |
| 296 | - | m.MoveToWorkspace(m.Active-1, a == ActionMoveColumnToWorkspaceUp) | |
| 297 | - | } | |
| 298 | - | return Effect{} | |
| 315 | + | return m.Active - 1, a == ActionMoveColumnToWorkspaceUp, m.Active > 0 | |
| 299 | 316 | case ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceDown: | |
| 300 | - | if m.shown == nil { | |
| 301 | - | m.MoveToWorkspace(m.Active+1, a == ActionMoveColumnToWorkspaceDown) | |
| 302 | - | } | |
| 303 | - | return Effect{} | |
| 317 | + | return min(m.Active+1, len(m.Workspaces)-1), a == ActionMoveColumnToWorkspaceDown, true | |
| 304 | 318 | } | |
| 305 | - | return m.Current().Apply(a) | |
| 319 | + | return 0, false, false | |
| 306 | 320 | } | |
| 307 | 321 | ||
| 308 | 322 | // Apply runs a bind action that only touches this workspace. | |
| @@ -109,13 +109,8 @@ type Effect struct { | |||||
| 109 | // screen; column focus past the edge column moves to the neighbor screen; | 109 | // screen; column focus past the edge column moves to the neighbor screen; | ||
| 110 | // everything else applies to the focused screen's monitor. | 110 | // everything else applies to the focused screen's monitor. | ||
| 111 | func (c *Core) applyAction(a Action) Effect { | 111 | func (c *Core) applyAction(a Action) Effect { | ||
| 112 | - | if m := c.cur().mon; m.ov.open { | 112 | + | if e, ok := c.cur().mon.overviewAction(a); ok { |
| 113 | - | if m.overviewFocus(a) || overviewBlocks(a) { | 113 | + | return e |
| 114 | - | return Effect{} | |||
| 115 | - | } | |||
| 116 | - | if a == ActionCloseWindow { | |||
| 117 | - | return m.Apply(a) | |||
| 118 | - | } | |||
| 119 | } | 114 | } | ||
| 120 | // A bind acts on the window on screen: under a covering fullscreen | 115 | // A bind acts on the window on screen: under a covering fullscreen | ||
| 121 | // window, that is the one Focused reports. | 116 | // window, that is the one Focused reports. | ||
| @@ -222,23 +217,20 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 222 | 217 | ||||
| 223 | // Apply runs a bind action on the monitor. | 218 | // Apply runs a bind action on the monitor. | ||
| 224 | func (m *Monitor) Apply(a Action) Effect { | 219 | func (m *Monitor) Apply(a Action) Effect { | ||
| 225 | - | if m.ov.open { | 220 | + | if e, ok := m.overviewAction(a); ok { |
| 226 | - | if a == ActionCloseWindow { | 221 | + | return e |
| 227 | - | return Effect{Close: m.overviewTarget()} | 222 | + | } |
| 228 | - | } | 223 | + | if movesToWorkspace(a) { |
| 229 | - | if m.overviewFocus(a) || overviewBlocks(a) { | 224 | + | if i, column, ok := m.moveDest(a); ok { |
| 230 | - | return Effect{} | 225 | + | m.MoveToWorkspace(i, column) |
| 231 | } | 226 | } | ||
| 227 | + | return Effect{} | |||
| 232 | } | 228 | } | ||
| 233 | - | if n, op, ok := WorkspaceArg(a); ok { | 229 | + | if n, _, ok := WorkspaceArg(a); ok { |
| 234 | - | if op == FocusWorkspace { | 230 | + | before := m.Current() |
| 235 | - | before := m.Current() | 231 | + | m.FocusNumber(n) |
| 236 | - | m.FocusNumber(n) | 232 | + | if m.ov.open && m.Current() != before { |
| 237 | - | if m.ov.open && m.Current() != before { | 233 | + | m.selectRow() |
| 238 | - | m.selectRow() | |||
| 239 | - | } | |||
| 240 | - | } else { | |||
| 241 | - | m.MoveToWorkspace(n-1, op == MoveColumnToWorkspace) | |||
| 242 | } | 234 | } | ||
| 243 | return Effect{} | 235 | return Effect{} | ||
| 244 | } | 236 | } | ||
| @@ -291,18 +283,40 @@ func (m *Monitor) Apply(a Action) Effect { | |||||
| 291 | m.Focus(m.Active + 1) | 283 | m.Focus(m.Active + 1) | ||
| 292 | } | 284 | } | ||
| 293 | return Effect{} | 285 | return Effect{} | ||
| 286 | + | } | |||
| 287 | + | return m.Current().Apply(a) | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | // movesToWorkspace reports a bind that moves a window to another workspace. | |||
| 291 | + | func movesToWorkspace(a Action) bool { | |||
| 292 | + | if _, op, ok := WorkspaceArg(a); ok { | |||
| 293 | + | return op != FocusWorkspace | |||
| 294 | + | } | |||
| 295 | + | switch a { | |||
| 296 | + | case ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, | |||
| 297 | + | ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown: | |||
| 298 | + | return true | |||
| 299 | + | } | |||
| 300 | + | return false | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | // moveDest resolves a move-to-workspace bind to the workspace index it | |||
| 304 | + | // moves to and whether the whole column goes; ok is false when it has | |||
| 305 | + | // nowhere to go. Up and down do nothing on a named workspace. | |||
| 306 | + | func (m *Monitor) moveDest(a Action) (i int, column, ok bool) { | |||
| 307 | + | if n, op, isArg := WorkspaceArg(a); isArg { | |||
| 308 | + | return min(max(n-1, 0), len(m.Workspaces)-1), op == MoveColumnToWorkspace, true | |||
| 309 | + | } | |||
| 310 | + | if m.shown != nil { | |||
| 311 | + | return 0, false, false | |||
| 312 | + | } | |||
| 313 | + | switch a { | |||
| 294 | case ActionMoveColumnToWorkspaceUp, ActionMoveWindowToWorkspaceUp: | 314 | case ActionMoveColumnToWorkspaceUp, ActionMoveWindowToWorkspaceUp: | ||
| 295 | - | if m.shown == nil && m.Active > 0 { | 315 | + | return m.Active - 1, a == ActionMoveColumnToWorkspaceUp, m.Active > 0 |
| 296 | - | m.MoveToWorkspace(m.Active-1, a == ActionMoveColumnToWorkspaceUp) | |||
| 297 | - | } | |||
| 298 | - | return Effect{} | |||
| 299 | case ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceDown: | 316 | case ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceDown: | ||
| 300 | - | if m.shown == nil { | 317 | + | return min(m.Active+1, len(m.Workspaces)-1), a == ActionMoveColumnToWorkspaceDown, true |
| 301 | - | m.MoveToWorkspace(m.Active+1, a == ActionMoveColumnToWorkspaceDown) | |||
| 302 | - | } | |||
| 303 | - | return Effect{} | |||
| 304 | } | 318 | } | ||
| 305 | - | return m.Current().Apply(a) | 319 | + | return 0, false, false |
| 306 | } | 320 | } | ||
| 307 | 321 | ||||
| 308 | // Apply runs a bind action that only touches this workspace. | 322 | // Apply runs a bind action that only touches this workspace. | ||
M internal/core/gesture.go +1 -1
| @@ -268,7 +268,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 268 | 268 | before := c.cur().mon.Current() | |
| 269 | 269 | c.keyboard.takeBack() | |
| 270 | 270 | if mon := c.cur().mon; mon.ov.open { | |
| 271 | - | mon.overviewSwipe(a) | |
| 271 | + | mon.overviewFocus(a) | |
| 272 | 272 | } else { | |
| 273 | 273 | c.applyAction(a) | |
| 274 | 274 | } | |
| @@ -268,7 +268,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 268 | before := c.cur().mon.Current() | 268 | before := c.cur().mon.Current() | ||
| 269 | c.keyboard.takeBack() | 269 | c.keyboard.takeBack() | ||
| 270 | if mon := c.cur().mon; mon.ov.open { | 270 | if mon := c.cur().mon; mon.ov.open { | ||
| 271 | - | mon.overviewSwipe(a) | 271 | + | mon.overviewFocus(a) |
| 272 | } else { | 272 | } else { | ||
| 273 | c.applyAction(a) | 273 | c.applyAction(a) | ||
| 274 | } | 274 | } | ||
M internal/core/overview.go +135 -73
| @@ -8,27 +8,34 @@ import ( | |||
| 8 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | - | // overviewState owns the provisional selection and opening snapshot. row is | |
| 12 | - | // the workspace whose stack has a provisional front; nil uses its real front. | |
| 11 | + | // overviewState owns the provisional selection and the snapshot of each row | |
| 12 | + | // it showed. row is the workspace whose stack has a provisional front; nil | |
| 13 | + | // uses its real front. | |
| 13 | 14 | type overviewState struct { | |
| 14 | - | open bool | |
| 15 | - | from *Workspace | |
| 16 | - | back *Workspace | |
| 17 | - | fromID WindowID | |
| 18 | - | floats []Float | |
| 19 | - | fullWidth WindowID | |
| 20 | - | row *Workspace | |
| 21 | - | front stackItem | |
| 22 | - | frontAt int | |
| 23 | - | cardOf *Workspace | |
| 24 | - | card WindowID | |
| 25 | - | selected WindowID | |
| 26 | - | selectedAt int | |
| 27 | - | // FullWidth history is restored on Escape when active binds changed it. | |
| 28 | - | maximized []WindowID | |
| 15 | + | open bool | |
| 16 | + | from *Workspace | |
| 17 | + | back *Workspace | |
| 18 | + | rows map[*Workspace]rowSnapshot | |
| 19 | + | row *Workspace | |
| 20 | + | front stackItem | |
| 21 | + | frontAt int | |
| 22 | + | cardOf *Workspace | |
| 23 | + | card WindowID | |
| 24 | + | selected WindowID | |
| 25 | + | selectedAt int | |
| 29 | 26 | scrollX, scrollY float64 | |
| 30 | 27 | } | |
| 31 | 28 | ||
| 29 | + | // rowSnapshot is a row as the overview first showed it: browsing moves its | |
| 30 | + | // focus and scroll, closing puts them back. | |
| 31 | + | type rowSnapshot struct { | |
| 32 | + | focus WindowID | |
| 33 | + | floats []Float | |
| 34 | + | fullWidth WindowID | |
| 35 | + | // FullWidth history is restored when active binds changed it. | |
| 36 | + | maximized []WindowID | |
| 37 | + | } | |
| 38 | + | ||
| 32 | 39 | // The overview scales workspace rows without resizing clients. Covering | |
| 33 | 40 | // floats and the columns form a linear stack of cards; small floats stay | |
| 34 | 41 | // hidden. Up/down traverse the stack before changing workspace. A pinned | |
| @@ -64,21 +71,34 @@ func (m *Monitor) ToggleOverview() { | |||
| 64 | 71 | m.stopSwitch() | |
| 65 | 72 | m.each(func(w *Workspace) { w.stopSlide() }) | |
| 66 | 73 | m.ov.open, m.ov.from, m.ov.back = true, w, m.back | |
| 67 | - | m.ov.fromID, _ = w.Focused() | |
| 68 | - | m.ov.floats = append([]Float(nil), w.Floats...) | |
| 69 | - | m.ov.maximized = slices.Clone(w.maximized) | |
| 70 | - | m.ov.fullWidth = 0 | |
| 71 | - | if len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth { | |
| 72 | - | c := w.Columns[w.Focus] | |
| 73 | - | m.ov.fullWidth = c.Windows[c.Focus] | |
| 74 | - | } | |
| 75 | 74 | m.ov.scrollX, m.ov.scrollY = 0, 0 | |
| 76 | 75 | m.overviewOpens++ | |
| 77 | 76 | m.selectRow() | |
| 78 | 77 | } | |
| 79 | 78 | ||
| 79 | + | // remember snapshots w the first time the overview shows it. | |
| 80 | + | func (m *Monitor) remember(w *Workspace) { | |
| 81 | + | if _, ok := m.ov.rows[w]; ok { | |
| 82 | + | return | |
| 83 | + | } | |
| 84 | + | if m.ov.rows == nil { | |
| 85 | + | m.ov.rows = make(map[*Workspace]rowSnapshot) | |
| 86 | + | } | |
| 87 | + | s := rowSnapshot{ | |
| 88 | + | floats: append([]Float(nil), w.Floats...), | |
| 89 | + | maximized: slices.Clone(w.maximized), | |
| 90 | + | } | |
| 91 | + | s.focus, _ = w.Focused() | |
| 92 | + | if len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth { | |
| 93 | + | c := w.Columns[w.Focus] | |
| 94 | + | s.fullWidth = c.Windows[c.Focus] | |
| 95 | + | } | |
| 96 | + | m.ov.rows[w] = s | |
| 97 | + | } | |
| 98 | + | ||
| 80 | 99 | // selectRow resets provisional stack and stash selection for a new row. | |
| 81 | 100 | func (m *Monitor) selectRow() { | |
| 101 | + | m.remember(m.Current()) | |
| 82 | 102 | m.ov.card, m.ov.cardOf = 0, nil | |
| 83 | 103 | m.ov.front, m.ov.row, m.ov.frontAt = stackItem{}, nil, 0 | |
| 84 | 104 | m.ov.selected, m.ov.selectedAt = 0, -1 | |
| @@ -158,23 +178,38 @@ func (m *Monitor) overviewTarget() WindowID { | |||
| 158 | 178 | } | |
| 159 | 179 | ||
| 160 | 180 | // overviewBlocks makes window mutations explicit: they are disabled while | |
| 161 | - | // selection is provisional. Workspace and monitor navigation remain active. | |
| 181 | + | // selection is provisional. Workspace and monitor navigation and moves to | |
| 182 | + | // another workspace remain active. | |
| 162 | 183 | func overviewBlocks(a Action) bool { | |
| 163 | - | if _, op, ok := WorkspaceArg(a); ok { | |
| 164 | - | return op != FocusWorkspace | |
| 165 | - | } | |
| 166 | 184 | switch a { | |
| 167 | 185 | case ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, | |
| 168 | 186 | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | |
| 169 | - | ActionToggleStashVisible, ActionMoveColumnToWorkspaceUp, | |
| 170 | - | ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, | |
| 171 | - | ActionMoveWindowToWorkspaceDown, ActionConsumeOrExpelLeft, | |
| 187 | + | ActionToggleStashVisible, ActionConsumeOrExpelLeft, | |
| 172 | 188 | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight: | |
| 173 | 189 | return true | |
| 174 | 190 | } | |
| 175 | 191 | return false | |
| 176 | 192 | } | |
| 177 | 193 | ||
| 194 | + | // overviewMoveTo moves the selected preview at once, like close-window: the | |
| 195 | + | // overview stays open and closing it does not undo the move. Nothing moves | |
| 196 | + | // without a selected preview, from a stash card, or when the bind has | |
| 197 | + | // nowhere to go. | |
| 198 | + | func (m *Monitor) overviewMoveTo(a Action) { | |
| 199 | + | cur, id := m.Current(), m.overviewTarget() | |
| 200 | + | i, column, ok := m.moveDest(a) | |
| 201 | + | if m.card() != 0 || id == 0 || !ok || m.Workspaces[i] == cur { | |
| 202 | + | return | |
| 203 | + | } | |
| 204 | + | cur.FocusID(id) | |
| 205 | + | m.MoveToWorkspace(i, column) | |
| 206 | + | // The row that received the window keeps it focused on close. | |
| 207 | + | if to, _ := m.find(id); to != nil && to != cur { | |
| 208 | + | delete(m.ov.rows, to) | |
| 209 | + | } | |
| 210 | + | m.selectRow() | |
| 211 | + | } | |
| 212 | + | ||
| 178 | 213 | // closeOverview closes the overview on the selection: a card shows the | |
| 179 | 214 | // stash on it. A fullscreen request another stashed window made while | |
| 180 | 215 | // the stash was hidden is dropped: the picked card stays in front. | |
| @@ -189,50 +224,25 @@ func (m *Monitor) closeOverview() { | |||
| 189 | 224 | } else if !w.pinned() && len(w.stack()) > 0 { | |
| 190 | 225 | w.apply(m.stackFront(w), m.overviewTarget()) | |
| 191 | 226 | } | |
| 227 | + | for r, s := range m.ov.rows { | |
| 228 | + | if r != w && m.has(r) { | |
| 229 | + | restoreRow(r, s) | |
| 230 | + | } | |
| 231 | + | } | |
| 192 | 232 | m.ov = overviewState{} | |
| 193 | 233 | } | |
| 194 | 234 | ||
| 195 | 235 | // CancelOverview closes the overview and returns to the workspace and | |
| 196 | - | // window it opened on, if they are still there. | |
| 236 | + | // window it opened on, if they are still there. Every row it showed gets | |
| 237 | + | // its focus back. | |
| 197 | 238 | func (m *Monitor) CancelOverview() { | |
| 198 | - | from, id := m.ov.from, m.ov.fromID | |
| 199 | - | if from != nil && m.has(from) { | |
| 239 | + | if from := m.ov.from; from != nil && m.has(from) { | |
| 200 | 240 | m.show(from) | |
| 201 | - | if id != 0 { | |
| 202 | - | from.FocusID(id) | |
| 203 | - | } else { | |
| 204 | - | from.floatFocus, from.stashFocus = false, false | |
| 205 | - | } | |
| 206 | - | // FocusID on a float raises it; restore the initial order and below | |
| 207 | - | // flags for floats still present, leaving new floats in their order. | |
| 208 | - | initial := m.ov.floats | |
| 209 | - | current := from.Floats | |
| 210 | - | from.Floats = nil | |
| 211 | - | for _, f := range initial { | |
| 212 | - | for i, now := range current { | |
| 213 | - | if now.ID == f.ID { | |
| 214 | - | now.below = f.below | |
| 215 | - | from.Floats = append(from.Floats, now) | |
| 216 | - | current = append(current[:i], current[i+1:]...) | |
| 217 | - | break | |
| 218 | - | } | |
| 219 | - | } | |
| 220 | - | } | |
| 221 | - | from.Floats = append(from.Floats, current...) | |
| 222 | - | // Fixed overflow maximizes at most one column: only the one holding | |
| 223 | - | // the anchor window, wherever active binds moved it. Scroll overflow | |
| 224 | - | // keeps each column's own width. | |
| 225 | - | if from.Overflow == OverflowFixed { | |
| 226 | - | from.unmaximize() | |
| 227 | - | for i := range from.Columns { | |
| 228 | - | if m.ov.fullWidth != 0 && slices.Contains(from.Columns[i].Windows, m.ov.fullWidth) { | |
| 229 | - | from.maximize(i) | |
| 230 | - | break | |
| 231 | - | } | |
| 232 | - | } | |
| 241 | + | } | |
| 242 | + | for r, s := range m.ov.rows { | |
| 243 | + | if m.has(r) { | |
| 244 | + | restoreRow(r, s) | |
| 233 | 245 | } | |
| 234 | - | from.maximized = slices.Clone(m.ov.maximized) | |
| 235 | - | from.scroll() | |
| 236 | 246 | } | |
| 237 | 247 | m.back = m.ov.back | |
| 238 | 248 | if m.back != nil && !m.has(m.back) { | |
| @@ -241,6 +251,45 @@ func (m *Monitor) CancelOverview() { | |||
| 241 | 251 | m.ov = overviewState{} | |
| 242 | 252 | } | |
| 243 | 253 | ||
| 254 | + | // restoreRow puts w back as snapshot s found it. | |
| 255 | + | func restoreRow(w *Workspace, s rowSnapshot) { | |
| 256 | + | if s.focus != 0 { | |
| 257 | + | w.FocusID(s.focus) | |
| 258 | + | } else { | |
| 259 | + | w.floatFocus, w.stashFocus = false, false | |
| 260 | + | } | |
| 261 | + | // FocusID on a float raises it; restore the initial order and below | |
| 262 | + | // flags for floats still present, leaving new floats in their order. | |
| 263 | + | current := w.Floats | |
| 264 | + | w.Floats = nil | |
| 265 | + | for _, f := range s.floats { | |
| 266 | + | for i, now := range current { | |
| 267 | + | if now.ID == f.ID { | |
| 268 | + | now.below = f.below | |
| 269 | + | w.Floats = append(w.Floats, now) | |
| 270 | + | current = append(current[:i], current[i+1:]...) | |
| 271 | + | break | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | w.Floats = append(w.Floats, current...) | |
| 276 | + | // Fixed overflow maximizes at most one column: only the one holding | |
| 277 | + | // the anchor window, wherever active binds moved it. Scroll overflow | |
| 278 | + | // keeps each column's own width. | |
| 279 | + | if w.Overflow == OverflowFixed { | |
| 280 | + | w.unmaximize() | |
| 281 | + | for i := range w.Columns { | |
| 282 | + | if s.fullWidth != 0 && slices.Contains(w.Columns[i].Windows, s.fullWidth) { | |
| 283 | + | w.maximize(i) | |
| 284 | + | break | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | // Windows that left the row since leave its maximize history too. | |
| 289 | + | w.maximized = slices.DeleteFunc(slices.Clone(s.maximized), func(id WindowID) bool { return !w.has(id) }) | |
| 290 | + | w.scroll() | |
| 291 | + | } | |
| 292 | + | ||
| 244 | 293 | // overviewWorkspaces inserts named rows below their invocation workspace, | |
| 245 | 294 | // in configured order within each group. Unattached rows follow the last | |
| 246 | 295 | // occupied numbered row (or the first). Empty invocation anchors stay visible; | |
| @@ -321,7 +370,7 @@ func (m *Monitor) OverviewMove(dx, dy int) { | |||
| 321 | 370 | switch { | |
| 322 | 371 | case at+dx >= 0 && at+dx < len(w.Stash): | |
| 323 | 372 | m.selectCard(w, at+dx) | |
| 324 | - | case dx > 0 && len(w.Columns) > 0: | |
| 373 | + | case dx > 0 && len(w.stack()) > 0: | |
| 325 | 374 | m.ov.card, m.ov.cardOf = 0, nil | |
| 326 | 375 | item := m.stackFront(w) | |
| 327 | 376 | if item.kind == stackColumns { | |
| @@ -801,9 +850,22 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) { | |||
| 801 | 850 | return true, c.publish(ctx) | |
| 802 | 851 | } | |
| 803 | 852 | ||
| 804 | - | // overviewSwipe moves the selection like the keys, through stacks and rows. | |
| 805 | - | func (m *Monitor) overviewSwipe(a Action) { | |
| 806 | - | m.overviewFocus(a) | |
| 853 | + | // overviewAction runs a bind while the overview is open: focus binds move | |
| 854 | + | // the selection, close-window and move-to-workspace binds act on the | |
| 855 | + | // selected preview, other mutations are dropped. handled is false for binds | |
| 856 | + | // that run as usual. | |
| 857 | + | func (m *Monitor) overviewAction(a Action) (e Effect, handled bool) { | |
| 858 | + | if !m.ov.open { | |
| 859 | + | return Effect{}, false | |
| 860 | + | } | |
| 861 | + | if a == ActionCloseWindow { | |
| 862 | + | return Effect{Close: m.overviewTarget()}, true | |
| 863 | + | } | |
| 864 | + | if movesToWorkspace(a) { | |
| 865 | + | m.overviewMoveTo(a) | |
| 866 | + | return Effect{}, true | |
| 867 | + | } | |
| 868 | + | return Effect{}, m.overviewFocus(a) || overviewBlocks(a) | |
| 807 | 869 | } | |
| 808 | 870 | ||
| 809 | 871 | // overviewFocus runs a focus bind as an overview move, so the focus binds | |
| @@ -8,27 +8,34 @@ import ( | |||||
| 8 | "github.com/bnema/neferwl/internal/ports" | 8 | "github.com/bnema/neferwl/internal/ports" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | - | // overviewState owns the provisional selection and opening snapshot. row is | 11 | + | // overviewState owns the provisional selection and the snapshot of each row |
| 12 | - | // the workspace whose stack has a provisional front; nil uses its real front. | 12 | + | // it showed. row is the workspace whose stack has a provisional front; nil |
| 13 | + | // uses its real front. | |||
| 13 | type overviewState struct { | 14 | type overviewState struct { | ||
| 14 | - | open bool | 15 | + | open bool |
| 15 | - | from *Workspace | 16 | + | from *Workspace |
| 16 | - | back *Workspace | 17 | + | back *Workspace |
| 17 | - | fromID WindowID | 18 | + | rows map[*Workspace]rowSnapshot |
| 18 | - | floats []Float | 19 | + | row *Workspace |
| 19 | - | fullWidth WindowID | 20 | + | front stackItem |
| 20 | - | row *Workspace | 21 | + | frontAt int |
| 21 | - | front stackItem | 22 | + | cardOf *Workspace |
| 22 | - | frontAt int | 23 | + | card WindowID |
| 23 | - | cardOf *Workspace | 24 | + | selected WindowID |
| 24 | - | card WindowID | 25 | + | selectedAt int |
| 25 | - | selected WindowID | |||
| 26 | - | selectedAt int | |||
| 27 | - | // FullWidth history is restored on Escape when active binds changed it. | |||
| 28 | - | maximized []WindowID | |||
| 29 | scrollX, scrollY float64 | 26 | scrollX, scrollY float64 | ||
| 30 | } | 27 | } | ||
| 31 | 28 | ||||
| 29 | + | // rowSnapshot is a row as the overview first showed it: browsing moves its | |||
| 30 | + | // focus and scroll, closing puts them back. | |||
| 31 | + | type rowSnapshot struct { | |||
| 32 | + | focus WindowID | |||
| 33 | + | floats []Float | |||
| 34 | + | fullWidth WindowID | |||
| 35 | + | // FullWidth history is restored when active binds changed it. | |||
| 36 | + | maximized []WindowID | |||
| 37 | + | } | |||
| 38 | + | ||||
| 32 | // The overview scales workspace rows without resizing clients. Covering | 39 | // The overview scales workspace rows without resizing clients. Covering | ||
| 33 | // floats and the columns form a linear stack of cards; small floats stay | 40 | // floats and the columns form a linear stack of cards; small floats stay | ||
| 34 | // hidden. Up/down traverse the stack before changing workspace. A pinned | 41 | // hidden. Up/down traverse the stack before changing workspace. A pinned | ||
| @@ -64,21 +71,34 @@ func (m *Monitor) ToggleOverview() { | |||||
| 64 | m.stopSwitch() | 71 | m.stopSwitch() | ||
| 65 | m.each(func(w *Workspace) { w.stopSlide() }) | 72 | m.each(func(w *Workspace) { w.stopSlide() }) | ||
| 66 | m.ov.open, m.ov.from, m.ov.back = true, w, m.back | 73 | m.ov.open, m.ov.from, m.ov.back = true, w, m.back | ||
| 67 | - | m.ov.fromID, _ = w.Focused() | |||
| 68 | - | m.ov.floats = append([]Float(nil), w.Floats...) | |||
| 69 | - | m.ov.maximized = slices.Clone(w.maximized) | |||
| 70 | - | m.ov.fullWidth = 0 | |||
| 71 | - | if len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth { | |||
| 72 | - | c := w.Columns[w.Focus] | |||
| 73 | - | m.ov.fullWidth = c.Windows[c.Focus] | |||
| 74 | - | } | |||
| 75 | m.ov.scrollX, m.ov.scrollY = 0, 0 | 74 | m.ov.scrollX, m.ov.scrollY = 0, 0 | ||
| 76 | m.overviewOpens++ | 75 | m.overviewOpens++ | ||
| 77 | m.selectRow() | 76 | m.selectRow() | ||
| 78 | } | 77 | } | ||
| 79 | 78 | ||||
| 79 | + | // remember snapshots w the first time the overview shows it. | |||
| 80 | + | func (m *Monitor) remember(w *Workspace) { | |||
| 81 | + | if _, ok := m.ov.rows[w]; ok { | |||
| 82 | + | return | |||
| 83 | + | } | |||
| 84 | + | if m.ov.rows == nil { | |||
| 85 | + | m.ov.rows = make(map[*Workspace]rowSnapshot) | |||
| 86 | + | } | |||
| 87 | + | s := rowSnapshot{ | |||
| 88 | + | floats: append([]Float(nil), w.Floats...), | |||
| 89 | + | maximized: slices.Clone(w.maximized), | |||
| 90 | + | } | |||
| 91 | + | s.focus, _ = w.Focused() | |||
| 92 | + | if len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth { | |||
| 93 | + | c := w.Columns[w.Focus] | |||
| 94 | + | s.fullWidth = c.Windows[c.Focus] | |||
| 95 | + | } | |||
| 96 | + | m.ov.rows[w] = s | |||
| 97 | + | } | |||
| 98 | + | ||||
| 80 | // selectRow resets provisional stack and stash selection for a new row. | 99 | // selectRow resets provisional stack and stash selection for a new row. | ||
| 81 | func (m *Monitor) selectRow() { | 100 | func (m *Monitor) selectRow() { | ||
| 101 | + | m.remember(m.Current()) | |||
| 82 | m.ov.card, m.ov.cardOf = 0, nil | 102 | m.ov.card, m.ov.cardOf = 0, nil | ||
| 83 | m.ov.front, m.ov.row, m.ov.frontAt = stackItem{}, nil, 0 | 103 | m.ov.front, m.ov.row, m.ov.frontAt = stackItem{}, nil, 0 | ||
| 84 | m.ov.selected, m.ov.selectedAt = 0, -1 | 104 | m.ov.selected, m.ov.selectedAt = 0, -1 | ||
| @@ -158,23 +178,38 @@ func (m *Monitor) overviewTarget() WindowID { | |||||
| 158 | } | 178 | } | ||
| 159 | 179 | ||||
| 160 | // overviewBlocks makes window mutations explicit: they are disabled while | 180 | // overviewBlocks makes window mutations explicit: they are disabled while | ||
| 161 | - | // selection is provisional. Workspace and monitor navigation remain active. | 181 | + | // selection is provisional. Workspace and monitor navigation and moves to |
| 182 | + | // another workspace remain active. | |||
| 162 | func overviewBlocks(a Action) bool { | 183 | func overviewBlocks(a Action) bool { | ||
| 163 | - | if _, op, ok := WorkspaceArg(a); ok { | |||
| 164 | - | return op != FocusWorkspace | |||
| 165 | - | } | |||
| 166 | switch a { | 184 | switch a { | ||
| 167 | case ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, | 185 | case ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, | ||
| 168 | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | 186 | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | ||
| 169 | - | ActionToggleStashVisible, ActionMoveColumnToWorkspaceUp, | 187 | + | ActionToggleStashVisible, ActionConsumeOrExpelLeft, |
| 170 | - | ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, | |||
| 171 | - | ActionMoveWindowToWorkspaceDown, ActionConsumeOrExpelLeft, | |||
| 172 | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight: | 188 | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight: | ||
| 173 | return true | 189 | return true | ||
| 174 | } | 190 | } | ||
| 175 | return false | 191 | return false | ||
| 176 | } | 192 | } | ||
| 177 | 193 | ||||
| 194 | + | // overviewMoveTo moves the selected preview at once, like close-window: the | |||
| 195 | + | // overview stays open and closing it does not undo the move. Nothing moves | |||
| 196 | + | // without a selected preview, from a stash card, or when the bind has | |||
| 197 | + | // nowhere to go. | |||
| 198 | + | func (m *Monitor) overviewMoveTo(a Action) { | |||
| 199 | + | cur, id := m.Current(), m.overviewTarget() | |||
| 200 | + | i, column, ok := m.moveDest(a) | |||
| 201 | + | if m.card() != 0 || id == 0 || !ok || m.Workspaces[i] == cur { | |||
| 202 | + | return | |||
| 203 | + | } | |||
| 204 | + | cur.FocusID(id) | |||
| 205 | + | m.MoveToWorkspace(i, column) | |||
| 206 | + | // The row that received the window keeps it focused on close. | |||
| 207 | + | if to, _ := m.find(id); to != nil && to != cur { | |||
| 208 | + | delete(m.ov.rows, to) | |||
| 209 | + | } | |||
| 210 | + | m.selectRow() | |||
| 211 | + | } | |||
| 212 | + | ||||
| 178 | // closeOverview closes the overview on the selection: a card shows the | 213 | // closeOverview closes the overview on the selection: a card shows the | ||
| 179 | // stash on it. A fullscreen request another stashed window made while | 214 | // stash on it. A fullscreen request another stashed window made while | ||
| 180 | // the stash was hidden is dropped: the picked card stays in front. | 215 | // the stash was hidden is dropped: the picked card stays in front. | ||
| @@ -189,50 +224,25 @@ func (m *Monitor) closeOverview() { | |||||
| 189 | } else if !w.pinned() && len(w.stack()) > 0 { | 224 | } else if !w.pinned() && len(w.stack()) > 0 { | ||
| 190 | w.apply(m.stackFront(w), m.overviewTarget()) | 225 | w.apply(m.stackFront(w), m.overviewTarget()) | ||
| 191 | } | 226 | } | ||
| 227 | + | for r, s := range m.ov.rows { | |||
| 228 | + | if r != w && m.has(r) { | |||
| 229 | + | restoreRow(r, s) | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 192 | m.ov = overviewState{} | 232 | m.ov = overviewState{} | ||
| 193 | } | 233 | } | ||
| 194 | 234 | ||||
| 195 | // CancelOverview closes the overview and returns to the workspace and | 235 | // CancelOverview closes the overview and returns to the workspace and | ||
| 196 | - | // window it opened on, if they are still there. | 236 | + | // window it opened on, if they are still there. Every row it showed gets |
| 237 | + | // its focus back. | |||
| 197 | func (m *Monitor) CancelOverview() { | 238 | func (m *Monitor) CancelOverview() { | ||
| 198 | - | from, id := m.ov.from, m.ov.fromID | 239 | + | if from := m.ov.from; from != nil && m.has(from) { |
| 199 | - | if from != nil && m.has(from) { | |||
| 200 | m.show(from) | 240 | m.show(from) | ||
| 201 | - | if id != 0 { | 241 | + | } |
| 202 | - | from.FocusID(id) | 242 | + | for r, s := range m.ov.rows { |
| 203 | - | } else { | 243 | + | if m.has(r) { |
| 204 | - | from.floatFocus, from.stashFocus = false, false | 244 | + | restoreRow(r, s) |
| 205 | - | } | |||
| 206 | - | // FocusID on a float raises it; restore the initial order and below | |||
| 207 | - | // flags for floats still present, leaving new floats in their order. | |||
| 208 | - | initial := m.ov.floats | |||
| 209 | - | current := from.Floats | |||
| 210 | - | from.Floats = nil | |||
| 211 | - | for _, f := range initial { | |||
| 212 | - | for i, now := range current { | |||
| 213 | - | if now.ID == f.ID { | |||
| 214 | - | now.below = f.below | |||
| 215 | - | from.Floats = append(from.Floats, now) | |||
| 216 | - | current = append(current[:i], current[i+1:]...) | |||
| 217 | - | break | |||
| 218 | - | } | |||
| 219 | - | } | |||
| 220 | - | } | |||
| 221 | - | from.Floats = append(from.Floats, current...) | |||
| 222 | - | // Fixed overflow maximizes at most one column: only the one holding | |||
| 223 | - | // the anchor window, wherever active binds moved it. Scroll overflow | |||
| 224 | - | // keeps each column's own width. | |||
| 225 | - | if from.Overflow == OverflowFixed { | |||
| 226 | - | from.unmaximize() | |||
| 227 | - | for i := range from.Columns { | |||
| 228 | - | if m.ov.fullWidth != 0 && slices.Contains(from.Columns[i].Windows, m.ov.fullWidth) { | |||
| 229 | - | from.maximize(i) | |||
| 230 | - | break | |||
| 231 | - | } | |||
| 232 | - | } | |||
| 233 | } | 245 | } | ||
| 234 | - | from.maximized = slices.Clone(m.ov.maximized) | |||
| 235 | - | from.scroll() | |||
| 236 | } | 246 | } | ||
| 237 | m.back = m.ov.back | 247 | m.back = m.ov.back | ||
| 238 | if m.back != nil && !m.has(m.back) { | 248 | if m.back != nil && !m.has(m.back) { | ||
| @@ -241,6 +251,45 @@ func (m *Monitor) CancelOverview() { | |||||
| 241 | m.ov = overviewState{} | 251 | m.ov = overviewState{} | ||
| 242 | } | 252 | } | ||
| 243 | 253 | ||||
| 254 | + | // restoreRow puts w back as snapshot s found it. | |||
| 255 | + | func restoreRow(w *Workspace, s rowSnapshot) { | |||
| 256 | + | if s.focus != 0 { | |||
| 257 | + | w.FocusID(s.focus) | |||
| 258 | + | } else { | |||
| 259 | + | w.floatFocus, w.stashFocus = false, false | |||
| 260 | + | } | |||
| 261 | + | // FocusID on a float raises it; restore the initial order and below | |||
| 262 | + | // flags for floats still present, leaving new floats in their order. | |||
| 263 | + | current := w.Floats | |||
| 264 | + | w.Floats = nil | |||
| 265 | + | for _, f := range s.floats { | |||
| 266 | + | for i, now := range current { | |||
| 267 | + | if now.ID == f.ID { | |||
| 268 | + | now.below = f.below | |||
| 269 | + | w.Floats = append(w.Floats, now) | |||
| 270 | + | current = append(current[:i], current[i+1:]...) | |||
| 271 | + | break | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | } | |||
| 275 | + | w.Floats = append(w.Floats, current...) | |||
| 276 | + | // Fixed overflow maximizes at most one column: only the one holding | |||
| 277 | + | // the anchor window, wherever active binds moved it. Scroll overflow | |||
| 278 | + | // keeps each column's own width. | |||
| 279 | + | if w.Overflow == OverflowFixed { | |||
| 280 | + | w.unmaximize() | |||
| 281 | + | for i := range w.Columns { | |||
| 282 | + | if s.fullWidth != 0 && slices.Contains(w.Columns[i].Windows, s.fullWidth) { | |||
| 283 | + | w.maximize(i) | |||
| 284 | + | break | |||
| 285 | + | } | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | // Windows that left the row since leave its maximize history too. | |||
| 289 | + | w.maximized = slices.DeleteFunc(slices.Clone(s.maximized), func(id WindowID) bool { return !w.has(id) }) | |||
| 290 | + | w.scroll() | |||
| 291 | + | } | |||
| 292 | + | ||||
| 244 | // overviewWorkspaces inserts named rows below their invocation workspace, | 293 | // overviewWorkspaces inserts named rows below their invocation workspace, | ||
| 245 | // in configured order within each group. Unattached rows follow the last | 294 | // in configured order within each group. Unattached rows follow the last | ||
| 246 | // occupied numbered row (or the first). Empty invocation anchors stay visible; | 295 | // occupied numbered row (or the first). Empty invocation anchors stay visible; | ||
| @@ -321,7 +370,7 @@ func (m *Monitor) OverviewMove(dx, dy int) { | |||||
| 321 | switch { | 370 | switch { | ||
| 322 | case at+dx >= 0 && at+dx < len(w.Stash): | 371 | case at+dx >= 0 && at+dx < len(w.Stash): | ||
| 323 | m.selectCard(w, at+dx) | 372 | m.selectCard(w, at+dx) | ||
| 324 | - | case dx > 0 && len(w.Columns) > 0: | 373 | + | case dx > 0 && len(w.stack()) > 0: |
| 325 | m.ov.card, m.ov.cardOf = 0, nil | 374 | m.ov.card, m.ov.cardOf = 0, nil | ||
| 326 | item := m.stackFront(w) | 375 | item := m.stackFront(w) | ||
| 327 | if item.kind == stackColumns { | 376 | if item.kind == stackColumns { | ||
| @@ -801,9 +850,22 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) { | |||||
| 801 | return true, c.publish(ctx) | 850 | return true, c.publish(ctx) | ||
| 802 | } | 851 | } | ||
| 803 | 852 | ||||
| 804 | - | // overviewSwipe moves the selection like the keys, through stacks and rows. | 853 | + | // overviewAction runs a bind while the overview is open: focus binds move |
| 805 | - | func (m *Monitor) overviewSwipe(a Action) { | 854 | + | // the selection, close-window and move-to-workspace binds act on the |
| 806 | - | m.overviewFocus(a) | 855 | + | // selected preview, other mutations are dropped. handled is false for binds |
| 856 | + | // that run as usual. | |||
| 857 | + | func (m *Monitor) overviewAction(a Action) (e Effect, handled bool) { | |||
| 858 | + | if !m.ov.open { | |||
| 859 | + | return Effect{}, false | |||
| 860 | + | } | |||
| 861 | + | if a == ActionCloseWindow { | |||
| 862 | + | return Effect{Close: m.overviewTarget()}, true | |||
| 863 | + | } | |||
| 864 | + | if movesToWorkspace(a) { | |||
| 865 | + | m.overviewMoveTo(a) | |||
| 866 | + | return Effect{}, true | |||
| 867 | + | } | |||
| 868 | + | return Effect{}, m.overviewFocus(a) || overviewBlocks(a) | |||
| 807 | } | 869 | } | ||
| 808 | 870 | ||||
| 809 | // overviewFocus runs a focus bind as an overview move, so the focus binds | 871 | // overviewFocus runs a focus bind as an overview move, so the focus binds | ||
M internal/core/overview_behavior_test.go +64 -3
| @@ -15,15 +15,15 @@ func TestOverviewVerticalBoundariesAndInputs(t *testing.T) { | |||
| 15 | 15 | if m.stackFront(w) != (stackItem{stackFloat, 9}) { | |
| 16 | 16 | t.Fatal("screen front") | |
| 17 | 17 | } | |
| 18 | - | m.overviewSwipe(ActionFocusWorkspaceUp) | |
| 18 | + | m.overviewFocus(ActionFocusWorkspaceUp) | |
| 19 | 19 | if m.stackFront(w).kind != stackColumns || m.Active != 0 { | |
| 20 | 20 | t.Fatal("swipe did not visit card") | |
| 21 | 21 | } | |
| 22 | - | m.overviewSwipe(ActionFocusWorkspaceUp) | |
| 22 | + | m.overviewFocus(ActionFocusWorkspaceUp) | |
| 23 | 23 | if m.Active != 0 { | |
| 24 | 24 | t.Fatal("up at top crossed workspace boundary") | |
| 25 | 25 | } | |
| 26 | - | m.overviewSwipe(ActionFocusWorkspaceDown) | |
| 26 | + | m.overviewFocus(ActionFocusWorkspaceDown) | |
| 27 | 27 | if m.stackFront(w).kind != stackFloat || m.Active != 0 { | |
| 28 | 28 | t.Fatal("down did not return to front") | |
| 29 | 29 | } | |
| @@ -128,3 +128,64 @@ func TestOverviewStackMRUAndSize(t *testing.T) { | |||
| 128 | 128 | } | |
| 129 | 129 | m.CancelOverview() | |
| 130 | 130 | } | |
| 131 | + | ||
| 132 | + | // browsedMonitor has window 1 on workspace 1 and columns 2..5 on workspace | |
| 133 | + | // 2, focused on 5 with one column per screen, and shows workspace 1. | |
| 134 | + | func browsedMonitor(ov Overflow) (*Monitor, *Workspace) { | |
| 135 | + | m := newMonitor("", "") | |
| 136 | + | m.SetOutput(300, 200) | |
| 137 | + | m.SetMaxColumns(1) | |
| 138 | + | m.AddWindow(1) | |
| 139 | + | m.Focus(1) | |
| 140 | + | for id := WindowID(2); id <= 5; id++ { | |
| 141 | + | m.AddWindow(id) | |
| 142 | + | } | |
| 143 | + | ws2 := m.Workspaces[1] | |
| 144 | + | ws2.Overflow = ov | |
| 145 | + | ws2.FocusID(5) | |
| 146 | + | ws2.scroll() | |
| 147 | + | m.Focus(0) | |
| 148 | + | return m, ws2 | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // Escape gives every row the overview showed its focus and scroll back, | |
| 152 | + | // as first shown, even after leaving and revisiting it. | |
| 153 | + | func TestOverviewBrowsedRowsRestored(t *testing.T) { | |
| 154 | + | paths := map[string][][2]int{ | |
| 155 | + | "browse": {{0, 1}, {-1, 0}, {-1, 0}}, | |
| 156 | + | "revisit": {{0, 1}, {-1, 0}, {0, -1}, {0, 1}, {-1, 0}}, | |
| 157 | + | } | |
| 158 | + | for _, ov := range []Overflow{OverflowScroll, OverflowFixed} { | |
| 159 | + | for name, path := range paths { | |
| 160 | + | m, ws2 := browsedMonitor(ov) | |
| 161 | + | view := ws2.ViewX | |
| 162 | + | m.ToggleOverview() | |
| 163 | + | for _, d := range path { | |
| 164 | + | m.OverviewMove(d[0], d[1]) | |
| 165 | + | } | |
| 166 | + | m.CancelOverview() | |
| 167 | + | if id, _ := ws2.Focused(); id != 5 || ws2.ViewX != view || m.Current() != m.Workspaces[0] { | |
| 168 | + | t.Fatalf("%v %s: focus %d view %v (want %v) current %v", ov, name, id, ws2.ViewX, view, m.Active) | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestOverviewReturnRestoresBrowsedRows(t *testing.T) { | |
| 175 | + | m, ws2 := browsedMonitor(OverflowScroll) | |
| 176 | + | m.Focus(2) | |
| 177 | + | m.AddWindow(6) | |
| 178 | + | m.Focus(0) | |
| 179 | + | m.ToggleOverview() | |
| 180 | + | m.OverviewMove(0, 1) | |
| 181 | + | m.OverviewMove(-1, 0) | |
| 182 | + | m.OverviewMove(-1, 0) | |
| 183 | + | m.OverviewMove(0, 1) | |
| 184 | + | if m.Current() != m.Workspaces[2] { | |
| 185 | + | t.Fatal("not on workspace 3") | |
| 186 | + | } | |
| 187 | + | m.ToggleOverview() | |
| 188 | + | if id, _ := ws2.Focused(); id != 5 || m.Current() != m.Workspaces[2] { | |
| 189 | + | t.Fatalf("focus %d current %v", id, m.Active) | |
| 190 | + | } | |
| 191 | + | } | |
| @@ -15,15 +15,15 @@ func TestOverviewVerticalBoundariesAndInputs(t *testing.T) { | |||||
| 15 | if m.stackFront(w) != (stackItem{stackFloat, 9}) { | 15 | if m.stackFront(w) != (stackItem{stackFloat, 9}) { | ||
| 16 | t.Fatal("screen front") | 16 | t.Fatal("screen front") | ||
| 17 | } | 17 | } | ||
| 18 | - | m.overviewSwipe(ActionFocusWorkspaceUp) | 18 | + | m.overviewFocus(ActionFocusWorkspaceUp) |
| 19 | if m.stackFront(w).kind != stackColumns || m.Active != 0 { | 19 | if m.stackFront(w).kind != stackColumns || m.Active != 0 { | ||
| 20 | t.Fatal("swipe did not visit card") | 20 | t.Fatal("swipe did not visit card") | ||
| 21 | } | 21 | } | ||
| 22 | - | m.overviewSwipe(ActionFocusWorkspaceUp) | 22 | + | m.overviewFocus(ActionFocusWorkspaceUp) |
| 23 | if m.Active != 0 { | 23 | if m.Active != 0 { | ||
| 24 | t.Fatal("up at top crossed workspace boundary") | 24 | t.Fatal("up at top crossed workspace boundary") | ||
| 25 | } | 25 | } | ||
| 26 | - | m.overviewSwipe(ActionFocusWorkspaceDown) | 26 | + | m.overviewFocus(ActionFocusWorkspaceDown) |
| 27 | if m.stackFront(w).kind != stackFloat || m.Active != 0 { | 27 | if m.stackFront(w).kind != stackFloat || m.Active != 0 { | ||
| 28 | t.Fatal("down did not return to front") | 28 | t.Fatal("down did not return to front") | ||
| 29 | } | 29 | } | ||
| @@ -128,3 +128,64 @@ func TestOverviewStackMRUAndSize(t *testing.T) { | |||||
| 128 | } | 128 | } | ||
| 129 | m.CancelOverview() | 129 | m.CancelOverview() | ||
| 130 | } | 130 | } | ||
| 131 | + | ||||
| 132 | + | // browsedMonitor has window 1 on workspace 1 and columns 2..5 on workspace | |||
| 133 | + | // 2, focused on 5 with one column per screen, and shows workspace 1. | |||
| 134 | + | func browsedMonitor(ov Overflow) (*Monitor, *Workspace) { | |||
| 135 | + | m := newMonitor("", "") | |||
| 136 | + | m.SetOutput(300, 200) | |||
| 137 | + | m.SetMaxColumns(1) | |||
| 138 | + | m.AddWindow(1) | |||
| 139 | + | m.Focus(1) | |||
| 140 | + | for id := WindowID(2); id <= 5; id++ { | |||
| 141 | + | m.AddWindow(id) | |||
| 142 | + | } | |||
| 143 | + | ws2 := m.Workspaces[1] | |||
| 144 | + | ws2.Overflow = ov | |||
| 145 | + | ws2.FocusID(5) | |||
| 146 | + | ws2.scroll() | |||
| 147 | + | m.Focus(0) | |||
| 148 | + | return m, ws2 | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | // Escape gives every row the overview showed its focus and scroll back, | |||
| 152 | + | // as first shown, even after leaving and revisiting it. | |||
| 153 | + | func TestOverviewBrowsedRowsRestored(t *testing.T) { | |||
| 154 | + | paths := map[string][][2]int{ | |||
| 155 | + | "browse": {{0, 1}, {-1, 0}, {-1, 0}}, | |||
| 156 | + | "revisit": {{0, 1}, {-1, 0}, {0, -1}, {0, 1}, {-1, 0}}, | |||
| 157 | + | } | |||
| 158 | + | for _, ov := range []Overflow{OverflowScroll, OverflowFixed} { | |||
| 159 | + | for name, path := range paths { | |||
| 160 | + | m, ws2 := browsedMonitor(ov) | |||
| 161 | + | view := ws2.ViewX | |||
| 162 | + | m.ToggleOverview() | |||
| 163 | + | for _, d := range path { | |||
| 164 | + | m.OverviewMove(d[0], d[1]) | |||
| 165 | + | } | |||
| 166 | + | m.CancelOverview() | |||
| 167 | + | if id, _ := ws2.Focused(); id != 5 || ws2.ViewX != view || m.Current() != m.Workspaces[0] { | |||
| 168 | + | t.Fatalf("%v %s: focus %d view %v (want %v) current %v", ov, name, id, ws2.ViewX, view, m.Active) | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func TestOverviewReturnRestoresBrowsedRows(t *testing.T) { | |||
| 175 | + | m, ws2 := browsedMonitor(OverflowScroll) | |||
| 176 | + | m.Focus(2) | |||
| 177 | + | m.AddWindow(6) | |||
| 178 | + | m.Focus(0) | |||
| 179 | + | m.ToggleOverview() | |||
| 180 | + | m.OverviewMove(0, 1) | |||
| 181 | + | m.OverviewMove(-1, 0) | |||
| 182 | + | m.OverviewMove(-1, 0) | |||
| 183 | + | m.OverviewMove(0, 1) | |||
| 184 | + | if m.Current() != m.Workspaces[2] { | |||
| 185 | + | t.Fatal("not on workspace 3") | |||
| 186 | + | } | |||
| 187 | + | m.ToggleOverview() | |||
| 188 | + | if id, _ := ws2.Focused(); id != 5 || m.Current() != m.Workspaces[2] { | |||
| 189 | + | t.Fatalf("focus %d current %v", id, m.Active) | |||
| 190 | + | } | |||
| 191 | + | } | |||
A internal/core/overview_core_test.go +168 -0
| @@ -0,0 +1,168 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // outputScene returns the scene of the named output in set. | |
| 11 | + | func outputScene(t *testing.T, set []ports.Scene, name string) ports.Scene { | |
| 12 | + | t.Helper() | |
| 13 | + | for _, s := range set { | |
| 14 | + | if s.Output == name { | |
| 15 | + | return s | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | t.Fatalf("no scene for %s", name) | |
| 19 | + | return ports.Scene{} | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // scenePreview returns window id's overview preview in s. | |
| 23 | + | func scenePreview(s ports.Scene, id ports.WindowID) (ports.SceneWindow, bool) { | |
| 24 | + | for _, w := range s.Windows { | |
| 25 | + | if w.ID == id && w.Preview > 0 { | |
| 26 | + | return w, true | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | return ports.SceneWindow{}, false | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func hasPreview(s ports.Scene) bool { | |
| 33 | + | for _, w := range s.Windows { | |
| 34 | + | if w.Preview > 0 { | |
| 35 | + | return true | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | return false | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // noButtonSent fails if core forwarded a click to a client. | |
| 42 | + | func noButtonSent(t *testing.T, ch chan ports.ClientCommand) { | |
| 43 | + | t.Helper() | |
| 44 | + | for len(ch) > 0 { | |
| 45 | + | if v, ok := (<-ch).(ports.PointerButtonTo); ok { | |
| 46 | + | t.Fatalf("click reached a client: %+v", v) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestOverviewCoreClickPicksOnPointerOutput(t *testing.T) { | |
| 52 | + | r := startMulti(t, nil, left, right) | |
| 53 | + | r.mapWindow(t, 1) | |
| 54 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 55 | + | r.mapWindow(t, 2) | |
| 56 | + | r.mapWindow(t, 3) | |
| 57 | + | set := r.key(t, "o", ports.ModAlt) | |
| 58 | + | p, ok := scenePreview(outputScene(t, set, "DP-2"), 2) | |
| 59 | + | if !ok { | |
| 60 | + | t.Fatal("no preview of window 2 on DP-2") | |
| 61 | + | } | |
| 62 | + | for len(r.commands) > 0 { | |
| 63 | + | <-r.commands | |
| 64 | + | } | |
| 65 | + | r.input <- ports.PointerMotion{X: float64(left.Width + p.Rect.X + p.Rect.W/2), Y: float64(p.Rect.Y + p.Rect.H/2)} | |
| 66 | + | r.input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 67 | + | deadline := time.After(time.Second) | |
| 68 | + | for picked := false; !picked; { | |
| 69 | + | select { | |
| 70 | + | case set := <-r.scenes: | |
| 71 | + | s := outputScene(t, set, "DP-2") | |
| 72 | + | for _, w := range s.Windows { | |
| 73 | + | if w.ID == 2 && w.Preview == 0 && w.Focused && !w.Hidden { | |
| 74 | + | picked = true | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | case <-deadline: | |
| 78 | + | t.Fatal("window 2 not picked") | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | noButtonSent(t, r.commands) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestOverviewCoreClickOutsidePreviewKeepsOverview(t *testing.T) { | |
| 85 | + | r := startMulti(t, nil, right) | |
| 86 | + | r.mapWindow(t, 1) | |
| 87 | + | r.mapWindow(t, 2) | |
| 88 | + | set := r.key(t, "o", ports.ModAlt) | |
| 89 | + | for _, w := range set[0].Windows { | |
| 90 | + | if w.Preview > 0 && w.Rect.X <= 1 && w.Rect.Y <= 1 && w.Rect.X+w.Rect.W > 1 && w.Rect.Y+w.Rect.H > 1 { | |
| 91 | + | t.Fatalf("(1, 1) is inside preview %+v", w) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | for len(r.commands) > 0 { | |
| 95 | + | <-r.commands | |
| 96 | + | } | |
| 97 | + | r.input <- ports.PointerMotion{X: 1, Y: 1} | |
| 98 | + | r.input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 99 | + | r.input <- ports.PointerButton{Button: 0x110} | |
| 100 | + | // h moves the selection in the overview; it would be a plain key | |
| 101 | + | // otherwise, with no scene. | |
| 102 | + | r.input <- ports.KeyEvent{Keysym: "h", Pressed: true} | |
| 103 | + | select { | |
| 104 | + | case s := <-r.scenes: | |
| 105 | + | if !hasPreview(s[0]) { | |
| 106 | + | t.Fatal("overview closed by a click outside the previews") | |
| 107 | + | } | |
| 108 | + | case <-time.After(time.Second): | |
| 109 | + | t.Fatal("overview closed by a click outside the previews: h published nothing") | |
| 110 | + | } | |
| 111 | + | noButtonSent(t, r.commands) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestOverviewCoreEscapeRestoresFocus(t *testing.T) { | |
| 115 | + | r := startMulti(t, nil, right) | |
| 116 | + | r.mapWindow(t, 1) | |
| 117 | + | r.mapWindow(t, 2) | |
| 118 | + | r.mapWindow(t, 3) | |
| 119 | + | r.key(t, "o", ports.ModAlt) | |
| 120 | + | r.key(t, "h", 0) | |
| 121 | + | set := r.key(t, "h", 0) | |
| 122 | + | if p, ok := scenePreview(set[0], 1); !ok || !p.Focused { | |
| 123 | + | t.Fatalf("preview 1 not selected: %+v", set[0].Windows) | |
| 124 | + | } | |
| 125 | + | set = r.key(t, "Escape", 0) | |
| 126 | + | if hasPreview(set[0]) { | |
| 127 | + | t.Fatal("overview still open") | |
| 128 | + | } | |
| 129 | + | for _, w := range set[0].Windows { | |
| 130 | + | if w.Focused != (w.ID == 3) { | |
| 131 | + | t.Fatalf("focus: %+v", w) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // The default move bind moves the selected preview and keeps the overview | |
| 137 | + | // open, through the same path as any key. | |
| 138 | + | func TestOverviewCoreMoveBind(t *testing.T) { | |
| 139 | + | r := startMulti(t, nil, right) | |
| 140 | + | r.mapWindow(t, 1) | |
| 141 | + | r.mapWindow(t, 2) | |
| 142 | + | r.mapWindow(t, 3) | |
| 143 | + | r.key(t, "o", ports.ModAlt) | |
| 144 | + | r.key(t, "h", 0) | |
| 145 | + | if set := r.key(t, "Next", ports.ModAlt|ports.ModShift); !hasPreview(set[0]) { | |
| 146 | + | t.Fatal("overview closed") | |
| 147 | + | } | |
| 148 | + | r.key(t, "Escape", 0) | |
| 149 | + | // The state channel keeps the latest snapshot: wait for the one after | |
| 150 | + | // Escape, back on window 3 of workspace 1. The overview showed window | |
| 151 | + | // 2 in the row below; closed, it is off screen on workspace 2. | |
| 152 | + | deadline := time.After(time.Second) | |
| 153 | + | for { | |
| 154 | + | select { | |
| 155 | + | case st := <-r.state: | |
| 156 | + | if st.Window == nil || st.Window.ID != 3 || st.Outputs[0].Active != 1 { | |
| 157 | + | continue | |
| 158 | + | } | |
| 159 | + | for _, w := range st.Windows { | |
| 160 | + | if w.ID == 2 && w.Workspace == 2 && !w.Visible { | |
| 161 | + | return | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | case <-deadline: | |
| 165 | + | t.Fatal("window 2 not on workspace 2 after escape") | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | } | |
| @@ -0,0 +1,168 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // outputScene returns the scene of the named output in set. | |||
| 11 | + | func outputScene(t *testing.T, set []ports.Scene, name string) ports.Scene { | |||
| 12 | + | t.Helper() | |||
| 13 | + | for _, s := range set { | |||
| 14 | + | if s.Output == name { | |||
| 15 | + | return s | |||
| 16 | + | } | |||
| 17 | + | } | |||
| 18 | + | t.Fatalf("no scene for %s", name) | |||
| 19 | + | return ports.Scene{} | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // scenePreview returns window id's overview preview in s. | |||
| 23 | + | func scenePreview(s ports.Scene, id ports.WindowID) (ports.SceneWindow, bool) { | |||
| 24 | + | for _, w := range s.Windows { | |||
| 25 | + | if w.ID == id && w.Preview > 0 { | |||
| 26 | + | return w, true | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | return ports.SceneWindow{}, false | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func hasPreview(s ports.Scene) bool { | |||
| 33 | + | for _, w := range s.Windows { | |||
| 34 | + | if w.Preview > 0 { | |||
| 35 | + | return true | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | return false | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // noButtonSent fails if core forwarded a click to a client. | |||
| 42 | + | func noButtonSent(t *testing.T, ch chan ports.ClientCommand) { | |||
| 43 | + | t.Helper() | |||
| 44 | + | for len(ch) > 0 { | |||
| 45 | + | if v, ok := (<-ch).(ports.PointerButtonTo); ok { | |||
| 46 | + | t.Fatalf("click reached a client: %+v", v) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestOverviewCoreClickPicksOnPointerOutput(t *testing.T) { | |||
| 52 | + | r := startMulti(t, nil, left, right) | |||
| 53 | + | r.mapWindow(t, 1) | |||
| 54 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 55 | + | r.mapWindow(t, 2) | |||
| 56 | + | r.mapWindow(t, 3) | |||
| 57 | + | set := r.key(t, "o", ports.ModAlt) | |||
| 58 | + | p, ok := scenePreview(outputScene(t, set, "DP-2"), 2) | |||
| 59 | + | if !ok { | |||
| 60 | + | t.Fatal("no preview of window 2 on DP-2") | |||
| 61 | + | } | |||
| 62 | + | for len(r.commands) > 0 { | |||
| 63 | + | <-r.commands | |||
| 64 | + | } | |||
| 65 | + | r.input <- ports.PointerMotion{X: float64(left.Width + p.Rect.X + p.Rect.W/2), Y: float64(p.Rect.Y + p.Rect.H/2)} | |||
| 66 | + | r.input <- ports.PointerButton{Button: 0x110, Pressed: true} | |||
| 67 | + | deadline := time.After(time.Second) | |||
| 68 | + | for picked := false; !picked; { | |||
| 69 | + | select { | |||
| 70 | + | case set := <-r.scenes: | |||
| 71 | + | s := outputScene(t, set, "DP-2") | |||
| 72 | + | for _, w := range s.Windows { | |||
| 73 | + | if w.ID == 2 && w.Preview == 0 && w.Focused && !w.Hidden { | |||
| 74 | + | picked = true | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | case <-deadline: | |||
| 78 | + | t.Fatal("window 2 not picked") | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | noButtonSent(t, r.commands) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestOverviewCoreClickOutsidePreviewKeepsOverview(t *testing.T) { | |||
| 85 | + | r := startMulti(t, nil, right) | |||
| 86 | + | r.mapWindow(t, 1) | |||
| 87 | + | r.mapWindow(t, 2) | |||
| 88 | + | set := r.key(t, "o", ports.ModAlt) | |||
| 89 | + | for _, w := range set[0].Windows { | |||
| 90 | + | if w.Preview > 0 && w.Rect.X <= 1 && w.Rect.Y <= 1 && w.Rect.X+w.Rect.W > 1 && w.Rect.Y+w.Rect.H > 1 { | |||
| 91 | + | t.Fatalf("(1, 1) is inside preview %+v", w) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | for len(r.commands) > 0 { | |||
| 95 | + | <-r.commands | |||
| 96 | + | } | |||
| 97 | + | r.input <- ports.PointerMotion{X: 1, Y: 1} | |||
| 98 | + | r.input <- ports.PointerButton{Button: 0x110, Pressed: true} | |||
| 99 | + | r.input <- ports.PointerButton{Button: 0x110} | |||
| 100 | + | // h moves the selection in the overview; it would be a plain key | |||
| 101 | + | // otherwise, with no scene. | |||
| 102 | + | r.input <- ports.KeyEvent{Keysym: "h", Pressed: true} | |||
| 103 | + | select { | |||
| 104 | + | case s := <-r.scenes: | |||
| 105 | + | if !hasPreview(s[0]) { | |||
| 106 | + | t.Fatal("overview closed by a click outside the previews") | |||
| 107 | + | } | |||
| 108 | + | case <-time.After(time.Second): | |||
| 109 | + | t.Fatal("overview closed by a click outside the previews: h published nothing") | |||
| 110 | + | } | |||
| 111 | + | noButtonSent(t, r.commands) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestOverviewCoreEscapeRestoresFocus(t *testing.T) { | |||
| 115 | + | r := startMulti(t, nil, right) | |||
| 116 | + | r.mapWindow(t, 1) | |||
| 117 | + | r.mapWindow(t, 2) | |||
| 118 | + | r.mapWindow(t, 3) | |||
| 119 | + | r.key(t, "o", ports.ModAlt) | |||
| 120 | + | r.key(t, "h", 0) | |||
| 121 | + | set := r.key(t, "h", 0) | |||
| 122 | + | if p, ok := scenePreview(set[0], 1); !ok || !p.Focused { | |||
| 123 | + | t.Fatalf("preview 1 not selected: %+v", set[0].Windows) | |||
| 124 | + | } | |||
| 125 | + | set = r.key(t, "Escape", 0) | |||
| 126 | + | if hasPreview(set[0]) { | |||
| 127 | + | t.Fatal("overview still open") | |||
| 128 | + | } | |||
| 129 | + | for _, w := range set[0].Windows { | |||
| 130 | + | if w.Focused != (w.ID == 3) { | |||
| 131 | + | t.Fatalf("focus: %+v", w) | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // The default move bind moves the selected preview and keeps the overview | |||
| 137 | + | // open, through the same path as any key. | |||
| 138 | + | func TestOverviewCoreMoveBind(t *testing.T) { | |||
| 139 | + | r := startMulti(t, nil, right) | |||
| 140 | + | r.mapWindow(t, 1) | |||
| 141 | + | r.mapWindow(t, 2) | |||
| 142 | + | r.mapWindow(t, 3) | |||
| 143 | + | r.key(t, "o", ports.ModAlt) | |||
| 144 | + | r.key(t, "h", 0) | |||
| 145 | + | if set := r.key(t, "Next", ports.ModAlt|ports.ModShift); !hasPreview(set[0]) { | |||
| 146 | + | t.Fatal("overview closed") | |||
| 147 | + | } | |||
| 148 | + | r.key(t, "Escape", 0) | |||
| 149 | + | // The state channel keeps the latest snapshot: wait for the one after | |||
| 150 | + | // Escape, back on window 3 of workspace 1. The overview showed window | |||
| 151 | + | // 2 in the row below; closed, it is off screen on workspace 2. | |||
| 152 | + | deadline := time.After(time.Second) | |||
| 153 | + | for { | |||
| 154 | + | select { | |||
| 155 | + | case st := <-r.state: | |||
| 156 | + | if st.Window == nil || st.Window.ID != 3 || st.Outputs[0].Active != 1 { | |||
| 157 | + | continue | |||
| 158 | + | } | |||
| 159 | + | for _, w := range st.Windows { | |||
| 160 | + | if w.ID == 2 && w.Workspace == 2 && !w.Visible { | |||
| 161 | + | return | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | case <-deadline: | |||
| 165 | + | t.Fatal("window 2 not on workspace 2 after escape") | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | } | |||
A internal/core/overview_move_test.go +237 -0
| @@ -0,0 +1,237 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // Move-to-workspace binds move the selected preview, not the focus the | |
| 9 | + | // overview opened on, and keep the overview open. | |
| 10 | + | func TestOverviewMove(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | action Action | |
| 14 | + | follow bool | |
| 15 | + | // row is the workspace index current after the move. | |
| 16 | + | row int | |
| 17 | + | }{ | |
| 18 | + | {"column down", ActionMoveColumnToWorkspaceDown, false, 0}, | |
| 19 | + | {"window down", ActionMoveWindowToWorkspaceDown, false, 0}, | |
| 20 | + | {"column numbered", "move-column-to-workspace 2", false, 0}, | |
| 21 | + | {"window numbered", "move-window-to-workspace 2", false, 0}, | |
| 22 | + | {"follows", ActionMoveColumnToWorkspaceDown, true, 1}, | |
| 23 | + | } | |
| 24 | + | for _, tt := range tests { | |
| 25 | + | t.Run(tt.name, func(t *testing.T) { | |
| 26 | + | m := overviewMonitor() | |
| 27 | + | m.SetFollowMove(tt.follow) | |
| 28 | + | m.Workspaces[0].FocusID(3) | |
| 29 | + | m.ToggleOverview() | |
| 30 | + | m.OverviewMove(-1, 0) | |
| 31 | + | if m.overviewTarget() != 2 { | |
| 32 | + | t.Fatalf("selection %d, want 2", m.overviewTarget()) | |
| 33 | + | } | |
| 34 | + | m.Apply(tt.action) | |
| 35 | + | if !slices.Equal(m.Workspaces[0].windows(), []WindowID{1, 3}) || !slices.Contains(m.Workspaces[1].windows(), 2) { | |
| 36 | + | t.Fatalf("window 2 not moved: %v", windows(m)) | |
| 37 | + | } | |
| 38 | + | if !m.ov.open || m.Current() != m.Workspaces[tt.row] { | |
| 39 | + | t.Fatalf("open %v row %d", m.ov.open, m.Active) | |
| 40 | + | } | |
| 41 | + | if tt.follow && m.overviewTarget() != 2 { | |
| 42 | + | t.Fatalf("selection %d did not follow", m.overviewTarget()) | |
| 43 | + | } | |
| 44 | + | m.CancelOverview() | |
| 45 | + | if !slices.Contains(m.Workspaces[1].windows(), 2) { | |
| 46 | + | t.Fatal("escape undid the move") | |
| 47 | + | } | |
| 48 | + | if id, _ := m.Workspaces[0].Focused(); m.Current() != m.Workspaces[0] || id != 3 { | |
| 49 | + | t.Fatalf("workspace %d focus %d", m.Active, id) | |
| 50 | + | } | |
| 51 | + | }) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // The window variant moves only the selected window of a stacked column. | |
| 56 | + | func TestOverviewMoveWindowFromStack(t *testing.T) { | |
| 57 | + | m := overviewMonitor() | |
| 58 | + | w := m.Current() | |
| 59 | + | w.FocusID(2) | |
| 60 | + | w.ConsumeOrExpel(-1) | |
| 61 | + | if len(w.Columns) != 2 { | |
| 62 | + | t.Fatalf("columns %v", windows(m)) | |
| 63 | + | } | |
| 64 | + | w.FocusID(3) | |
| 65 | + | m.ToggleOverview() | |
| 66 | + | m.OverviewMove(-1, 0) | |
| 67 | + | if m.overviewTarget() != 2 { | |
| 68 | + | t.Fatalf("selection %d", m.overviewTarget()) | |
| 69 | + | } | |
| 70 | + | m.Apply(ActionMoveWindowToWorkspaceDown) | |
| 71 | + | if !slices.Equal(w.windows(), []WindowID{1, 3}) || !slices.Contains(m.Workspaces[1].windows(), 2) { | |
| 72 | + | t.Fatalf("windows %v", windows(m)) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // The selected column moves even when a covering float is the row's focus. | |
| 77 | + | func TestOverviewMoveSelectedUnderFloat(t *testing.T) { | |
| 78 | + | m := stackMonitor() | |
| 79 | + | m.ToggleOverview() | |
| 80 | + | m.OverviewMove(0, -1) | |
| 81 | + | id := m.overviewTarget() | |
| 82 | + | if id == 0 || id == 9 { | |
| 83 | + | t.Fatalf("selection %d", id) | |
| 84 | + | } | |
| 85 | + | m.Apply(ActionMoveColumnToWorkspaceDown) | |
| 86 | + | if !slices.Contains(m.Workspaces[1].windows(), id) || m.Workspaces[1].isFloat(9) { | |
| 87 | + | t.Fatalf("moved the wrong window: %v floats %v", windows(m), m.Workspaces[0].Floats) | |
| 88 | + | } | |
| 89 | + | // The provisional stack front went with the moved column. | |
| 90 | + | if m.ov.row != nil || m.ov.selected != 0 { | |
| 91 | + | t.Fatalf("stale selection: row %v selected %d", m.ov.row != nil, m.ov.selected) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // A bind with nothing to move or nowhere to go leaves the overview as is. | |
| 96 | + | func TestOverviewMoveNoop(t *testing.T) { | |
| 97 | + | tests := []struct { | |
| 98 | + | name string | |
| 99 | + | setup func() *Monitor | |
| 100 | + | action Action | |
| 101 | + | }{ | |
| 102 | + | {"stash card", func() *Monitor { | |
| 103 | + | m := pileMonitor() | |
| 104 | + | m.ToggleOverview() | |
| 105 | + | m.OverviewMove(-1, 0) | |
| 106 | + | if m.cardAt(m.Current()) < 0 { | |
| 107 | + | t.Fatal("no stash card selected") | |
| 108 | + | } | |
| 109 | + | return m | |
| 110 | + | }, ActionMoveColumnToWorkspaceDown}, | |
| 111 | + | {"hidden small float", func() *Monitor { | |
| 112 | + | m := newMonitor("", "") | |
| 113 | + | m.SetOutput(300, 200) | |
| 114 | + | m.AddFloating(9, 50, 50) | |
| 115 | + | m.ToggleOverview() | |
| 116 | + | return m | |
| 117 | + | }, ActionMoveWindowToWorkspaceDown}, | |
| 118 | + | {"up from first", func() *Monitor { | |
| 119 | + | m := overviewMonitor() | |
| 120 | + | m.ToggleOverview() | |
| 121 | + | m.OverviewMove(-1, 0) | |
| 122 | + | return m | |
| 123 | + | }, ActionMoveColumnToWorkspaceUp}, | |
| 124 | + | {"to its own row", func() *Monitor { | |
| 125 | + | m := overviewMonitor() | |
| 126 | + | m.ToggleOverview() | |
| 127 | + | m.OverviewMove(-1, 0) | |
| 128 | + | return m | |
| 129 | + | }, "move-column-to-workspace 1"}, | |
| 130 | + | {"down from named", func() *Monitor { | |
| 131 | + | m := namedOverviewMonitor() | |
| 132 | + | m.ToggleNamed("game") | |
| 133 | + | m.ToggleOverview() | |
| 134 | + | return m | |
| 135 | + | }, ActionMoveColumnToWorkspaceDown}, | |
| 136 | + | {"fixed maximize kept", func() *Monitor { | |
| 137 | + | m := overviewMonitor() | |
| 138 | + | w := m.Current() | |
| 139 | + | w.Overflow = OverflowFixed | |
| 140 | + | w.FocusID(3) | |
| 141 | + | w.ToggleFullWidth() | |
| 142 | + | m.ToggleOverview() | |
| 143 | + | m.OverviewMove(0, -1) | |
| 144 | + | return m | |
| 145 | + | }, ActionMoveColumnToWorkspaceUp}, | |
| 146 | + | } | |
| 147 | + | for _, tt := range tests { | |
| 148 | + | t.Run(tt.name, func(t *testing.T) { | |
| 149 | + | m := tt.setup() | |
| 150 | + | before, ws := m.Layout(), windows(m) | |
| 151 | + | m.Apply(tt.action) | |
| 152 | + | if !slices.Equal(before, m.Layout()) || !slices.EqualFunc(ws, windows(m), slices.Equal) { | |
| 153 | + | t.Fatalf("changed: %v -> %v", ws, windows(m)) | |
| 154 | + | } | |
| 155 | + | }) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // A browsed destination row keeps the moved window focused on close. | |
| 160 | + | func TestOverviewMoveDestinationKeepsFocus(t *testing.T) { | |
| 161 | + | m := overviewMonitor() | |
| 162 | + | m.ToggleOverview() | |
| 163 | + | m.OverviewMove(0, 1) | |
| 164 | + | m.OverviewMove(0, -1) | |
| 165 | + | m.OverviewMove(-1, 0) | |
| 166 | + | m.Apply(ActionMoveColumnToWorkspaceDown) | |
| 167 | + | m.ToggleOverview() | |
| 168 | + | if id, _ := m.Workspaces[1].Focused(); id != 2 { | |
| 169 | + | t.Fatalf("destination focus %d, want 2", id) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // Closing after a move drops the moved window from the restored maximize | |
| 174 | + | // history. | |
| 175 | + | func TestOverviewMoveDropsMaximizeHistory(t *testing.T) { | |
| 176 | + | m := overviewMonitor() | |
| 177 | + | w := m.Current() | |
| 178 | + | w.Overflow = OverflowFixed | |
| 179 | + | w.FocusID(3) | |
| 180 | + | w.ToggleFullWidth() | |
| 181 | + | m.ToggleOverview() | |
| 182 | + | m.Apply(ActionMoveColumnToWorkspaceDown) | |
| 183 | + | if w.has(3) { | |
| 184 | + | t.Fatal("3 not moved") | |
| 185 | + | } | |
| 186 | + | m.CancelOverview() | |
| 187 | + | if slices.Contains(w.maximized, 3) { | |
| 188 | + | t.Fatalf("maximized %v keeps a moved window", w.maximized) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // A fixed fullscreen row moves its window like the bind outside the | |
| 193 | + | // overview: fullscreen ends, and Escape leaves the row on a window still | |
| 194 | + | // there. | |
| 195 | + | func TestOverviewMoveFromFullscreenRow(t *testing.T) { | |
| 196 | + | for _, follow := range []bool{false, true} { | |
| 197 | + | m := newMonitor("", "") | |
| 198 | + | m.SetOutput(300, 200) | |
| 199 | + | m.SetMaxColumns(4) | |
| 200 | + | for id := WindowID(1); id <= 4; id++ { | |
| 201 | + | m.AddWindow(id) | |
| 202 | + | } | |
| 203 | + | w := m.Current() | |
| 204 | + | w.Overflow = OverflowFixed | |
| 205 | + | w.FocusID(2) | |
| 206 | + | m.ToggleFullscreen() | |
| 207 | + | m.SetFollowMove(follow) | |
| 208 | + | m.ToggleOverview() | |
| 209 | + | if m.overviewTarget() != 2 { | |
| 210 | + | t.Fatalf("follow %v: selection %d", follow, m.overviewTarget()) | |
| 211 | + | } | |
| 212 | + | m.Apply(ActionMoveColumnToWorkspaceDown) | |
| 213 | + | if !slices.Equal(w.windows(), []WindowID{1, 3, 4}) || !slices.Equal(m.Workspaces[1].windows(), []WindowID{2}) { | |
| 214 | + | t.Fatalf("follow %v: windows %v", follow, windows(m)) | |
| 215 | + | } | |
| 216 | + | if w.fullscreen != 0 || m.Workspaces[1].fullscreen != 0 { | |
| 217 | + | t.Fatalf("follow %v: fullscreen kept", follow) | |
| 218 | + | } | |
| 219 | + | m.CancelOverview() | |
| 220 | + | if id, ok := w.Focused(); !ok || !w.has(id) || m.Current() != w { | |
| 221 | + | t.Fatalf("follow %v: focus %d on workspace %d", follow, id, m.Active) | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // A move from a browsed row back to the opening row keeps the moved window | |
| 227 | + | // focused there on Escape, like any destination. | |
| 228 | + | func TestOverviewMoveBackToOpeningRow(t *testing.T) { | |
| 229 | + | m := overviewMonitor() | |
| 230 | + | m.ToggleOverview() | |
| 231 | + | m.OverviewMove(0, 1) | |
| 232 | + | m.Apply("move-column-to-workspace 1") | |
| 233 | + | m.CancelOverview() | |
| 234 | + | if id, _ := m.Current().Focused(); m.Current() != m.Workspaces[0] || id != 4 { | |
| 235 | + | t.Fatalf("workspace %d focus %d, want 4 on 1 (%v)", m.Active, id, windows(m)) | |
| 236 | + | } | |
| 237 | + | } | |
| @@ -0,0 +1,237 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "slices" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // Move-to-workspace binds move the selected preview, not the focus the | |||
| 9 | + | // overview opened on, and keep the overview open. | |||
| 10 | + | func TestOverviewMove(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | action Action | |||
| 14 | + | follow bool | |||
| 15 | + | // row is the workspace index current after the move. | |||
| 16 | + | row int | |||
| 17 | + | }{ | |||
| 18 | + | {"column down", ActionMoveColumnToWorkspaceDown, false, 0}, | |||
| 19 | + | {"window down", ActionMoveWindowToWorkspaceDown, false, 0}, | |||
| 20 | + | {"column numbered", "move-column-to-workspace 2", false, 0}, | |||
| 21 | + | {"window numbered", "move-window-to-workspace 2", false, 0}, | |||
| 22 | + | {"follows", ActionMoveColumnToWorkspaceDown, true, 1}, | |||
| 23 | + | } | |||
| 24 | + | for _, tt := range tests { | |||
| 25 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 26 | + | m := overviewMonitor() | |||
| 27 | + | m.SetFollowMove(tt.follow) | |||
| 28 | + | m.Workspaces[0].FocusID(3) | |||
| 29 | + | m.ToggleOverview() | |||
| 30 | + | m.OverviewMove(-1, 0) | |||
| 31 | + | if m.overviewTarget() != 2 { | |||
| 32 | + | t.Fatalf("selection %d, want 2", m.overviewTarget()) | |||
| 33 | + | } | |||
| 34 | + | m.Apply(tt.action) | |||
| 35 | + | if !slices.Equal(m.Workspaces[0].windows(), []WindowID{1, 3}) || !slices.Contains(m.Workspaces[1].windows(), 2) { | |||
| 36 | + | t.Fatalf("window 2 not moved: %v", windows(m)) | |||
| 37 | + | } | |||
| 38 | + | if !m.ov.open || m.Current() != m.Workspaces[tt.row] { | |||
| 39 | + | t.Fatalf("open %v row %d", m.ov.open, m.Active) | |||
| 40 | + | } | |||
| 41 | + | if tt.follow && m.overviewTarget() != 2 { | |||
| 42 | + | t.Fatalf("selection %d did not follow", m.overviewTarget()) | |||
| 43 | + | } | |||
| 44 | + | m.CancelOverview() | |||
| 45 | + | if !slices.Contains(m.Workspaces[1].windows(), 2) { | |||
| 46 | + | t.Fatal("escape undid the move") | |||
| 47 | + | } | |||
| 48 | + | if id, _ := m.Workspaces[0].Focused(); m.Current() != m.Workspaces[0] || id != 3 { | |||
| 49 | + | t.Fatalf("workspace %d focus %d", m.Active, id) | |||
| 50 | + | } | |||
| 51 | + | }) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // The window variant moves only the selected window of a stacked column. | |||
| 56 | + | func TestOverviewMoveWindowFromStack(t *testing.T) { | |||
| 57 | + | m := overviewMonitor() | |||
| 58 | + | w := m.Current() | |||
| 59 | + | w.FocusID(2) | |||
| 60 | + | w.ConsumeOrExpel(-1) | |||
| 61 | + | if len(w.Columns) != 2 { | |||
| 62 | + | t.Fatalf("columns %v", windows(m)) | |||
| 63 | + | } | |||
| 64 | + | w.FocusID(3) | |||
| 65 | + | m.ToggleOverview() | |||
| 66 | + | m.OverviewMove(-1, 0) | |||
| 67 | + | if m.overviewTarget() != 2 { | |||
| 68 | + | t.Fatalf("selection %d", m.overviewTarget()) | |||
| 69 | + | } | |||
| 70 | + | m.Apply(ActionMoveWindowToWorkspaceDown) | |||
| 71 | + | if !slices.Equal(w.windows(), []WindowID{1, 3}) || !slices.Contains(m.Workspaces[1].windows(), 2) { | |||
| 72 | + | t.Fatalf("windows %v", windows(m)) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // The selected column moves even when a covering float is the row's focus. | |||
| 77 | + | func TestOverviewMoveSelectedUnderFloat(t *testing.T) { | |||
| 78 | + | m := stackMonitor() | |||
| 79 | + | m.ToggleOverview() | |||
| 80 | + | m.OverviewMove(0, -1) | |||
| 81 | + | id := m.overviewTarget() | |||
| 82 | + | if id == 0 || id == 9 { | |||
| 83 | + | t.Fatalf("selection %d", id) | |||
| 84 | + | } | |||
| 85 | + | m.Apply(ActionMoveColumnToWorkspaceDown) | |||
| 86 | + | if !slices.Contains(m.Workspaces[1].windows(), id) || m.Workspaces[1].isFloat(9) { | |||
| 87 | + | t.Fatalf("moved the wrong window: %v floats %v", windows(m), m.Workspaces[0].Floats) | |||
| 88 | + | } | |||
| 89 | + | // The provisional stack front went with the moved column. | |||
| 90 | + | if m.ov.row != nil || m.ov.selected != 0 { | |||
| 91 | + | t.Fatalf("stale selection: row %v selected %d", m.ov.row != nil, m.ov.selected) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // A bind with nothing to move or nowhere to go leaves the overview as is. | |||
| 96 | + | func TestOverviewMoveNoop(t *testing.T) { | |||
| 97 | + | tests := []struct { | |||
| 98 | + | name string | |||
| 99 | + | setup func() *Monitor | |||
| 100 | + | action Action | |||
| 101 | + | }{ | |||
| 102 | + | {"stash card", func() *Monitor { | |||
| 103 | + | m := pileMonitor() | |||
| 104 | + | m.ToggleOverview() | |||
| 105 | + | m.OverviewMove(-1, 0) | |||
| 106 | + | if m.cardAt(m.Current()) < 0 { | |||
| 107 | + | t.Fatal("no stash card selected") | |||
| 108 | + | } | |||
| 109 | + | return m | |||
| 110 | + | }, ActionMoveColumnToWorkspaceDown}, | |||
| 111 | + | {"hidden small float", func() *Monitor { | |||
| 112 | + | m := newMonitor("", "") | |||
| 113 | + | m.SetOutput(300, 200) | |||
| 114 | + | m.AddFloating(9, 50, 50) | |||
| 115 | + | m.ToggleOverview() | |||
| 116 | + | return m | |||
| 117 | + | }, ActionMoveWindowToWorkspaceDown}, | |||
| 118 | + | {"up from first", func() *Monitor { | |||
| 119 | + | m := overviewMonitor() | |||
| 120 | + | m.ToggleOverview() | |||
| 121 | + | m.OverviewMove(-1, 0) | |||
| 122 | + | return m | |||
| 123 | + | }, ActionMoveColumnToWorkspaceUp}, | |||
| 124 | + | {"to its own row", func() *Monitor { | |||
| 125 | + | m := overviewMonitor() | |||
| 126 | + | m.ToggleOverview() | |||
| 127 | + | m.OverviewMove(-1, 0) | |||
| 128 | + | return m | |||
| 129 | + | }, "move-column-to-workspace 1"}, | |||
| 130 | + | {"down from named", func() *Monitor { | |||
| 131 | + | m := namedOverviewMonitor() | |||
| 132 | + | m.ToggleNamed("game") | |||
| 133 | + | m.ToggleOverview() | |||
| 134 | + | return m | |||
| 135 | + | }, ActionMoveColumnToWorkspaceDown}, | |||
| 136 | + | {"fixed maximize kept", func() *Monitor { | |||
| 137 | + | m := overviewMonitor() | |||
| 138 | + | w := m.Current() | |||
| 139 | + | w.Overflow = OverflowFixed | |||
| 140 | + | w.FocusID(3) | |||
| 141 | + | w.ToggleFullWidth() | |||
| 142 | + | m.ToggleOverview() | |||
| 143 | + | m.OverviewMove(0, -1) | |||
| 144 | + | return m | |||
| 145 | + | }, ActionMoveColumnToWorkspaceUp}, | |||
| 146 | + | } | |||
| 147 | + | for _, tt := range tests { | |||
| 148 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 149 | + | m := tt.setup() | |||
| 150 | + | before, ws := m.Layout(), windows(m) | |||
| 151 | + | m.Apply(tt.action) | |||
| 152 | + | if !slices.Equal(before, m.Layout()) || !slices.EqualFunc(ws, windows(m), slices.Equal) { | |||
| 153 | + | t.Fatalf("changed: %v -> %v", ws, windows(m)) | |||
| 154 | + | } | |||
| 155 | + | }) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // A browsed destination row keeps the moved window focused on close. | |||
| 160 | + | func TestOverviewMoveDestinationKeepsFocus(t *testing.T) { | |||
| 161 | + | m := overviewMonitor() | |||
| 162 | + | m.ToggleOverview() | |||
| 163 | + | m.OverviewMove(0, 1) | |||
| 164 | + | m.OverviewMove(0, -1) | |||
| 165 | + | m.OverviewMove(-1, 0) | |||
| 166 | + | m.Apply(ActionMoveColumnToWorkspaceDown) | |||
| 167 | + | m.ToggleOverview() | |||
| 168 | + | if id, _ := m.Workspaces[1].Focused(); id != 2 { | |||
| 169 | + | t.Fatalf("destination focus %d, want 2", id) | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // Closing after a move drops the moved window from the restored maximize | |||
| 174 | + | // history. | |||
| 175 | + | func TestOverviewMoveDropsMaximizeHistory(t *testing.T) { | |||
| 176 | + | m := overviewMonitor() | |||
| 177 | + | w := m.Current() | |||
| 178 | + | w.Overflow = OverflowFixed | |||
| 179 | + | w.FocusID(3) | |||
| 180 | + | w.ToggleFullWidth() | |||
| 181 | + | m.ToggleOverview() | |||
| 182 | + | m.Apply(ActionMoveColumnToWorkspaceDown) | |||
| 183 | + | if w.has(3) { | |||
| 184 | + | t.Fatal("3 not moved") | |||
| 185 | + | } | |||
| 186 | + | m.CancelOverview() | |||
| 187 | + | if slices.Contains(w.maximized, 3) { | |||
| 188 | + | t.Fatalf("maximized %v keeps a moved window", w.maximized) | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // A fixed fullscreen row moves its window like the bind outside the | |||
| 193 | + | // overview: fullscreen ends, and Escape leaves the row on a window still | |||
| 194 | + | // there. | |||
| 195 | + | func TestOverviewMoveFromFullscreenRow(t *testing.T) { | |||
| 196 | + | for _, follow := range []bool{false, true} { | |||
| 197 | + | m := newMonitor("", "") | |||
| 198 | + | m.SetOutput(300, 200) | |||
| 199 | + | m.SetMaxColumns(4) | |||
| 200 | + | for id := WindowID(1); id <= 4; id++ { | |||
| 201 | + | m.AddWindow(id) | |||
| 202 | + | } | |||
| 203 | + | w := m.Current() | |||
| 204 | + | w.Overflow = OverflowFixed | |||
| 205 | + | w.FocusID(2) | |||
| 206 | + | m.ToggleFullscreen() | |||
| 207 | + | m.SetFollowMove(follow) | |||
| 208 | + | m.ToggleOverview() | |||
| 209 | + | if m.overviewTarget() != 2 { | |||
| 210 | + | t.Fatalf("follow %v: selection %d", follow, m.overviewTarget()) | |||
| 211 | + | } | |||
| 212 | + | m.Apply(ActionMoveColumnToWorkspaceDown) | |||
| 213 | + | if !slices.Equal(w.windows(), []WindowID{1, 3, 4}) || !slices.Equal(m.Workspaces[1].windows(), []WindowID{2}) { | |||
| 214 | + | t.Fatalf("follow %v: windows %v", follow, windows(m)) | |||
| 215 | + | } | |||
| 216 | + | if w.fullscreen != 0 || m.Workspaces[1].fullscreen != 0 { | |||
| 217 | + | t.Fatalf("follow %v: fullscreen kept", follow) | |||
| 218 | + | } | |||
| 219 | + | m.CancelOverview() | |||
| 220 | + | if id, ok := w.Focused(); !ok || !w.has(id) || m.Current() != w { | |||
| 221 | + | t.Fatalf("follow %v: focus %d on workspace %d", follow, id, m.Active) | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // A move from a browsed row back to the opening row keeps the moved window | |||
| 227 | + | // focused there on Escape, like any destination. | |||
| 228 | + | func TestOverviewMoveBackToOpeningRow(t *testing.T) { | |||
| 229 | + | m := overviewMonitor() | |||
| 230 | + | m.ToggleOverview() | |||
| 231 | + | m.OverviewMove(0, 1) | |||
| 232 | + | m.Apply("move-column-to-workspace 1") | |||
| 233 | + | m.CancelOverview() | |||
| 234 | + | if id, _ := m.Current().Focused(); m.Current() != m.Workspaces[0] || id != 4 { | |||
| 235 | + | t.Fatalf("workspace %d focus %d, want 4 on 1 (%v)", m.Active, id, windows(m)) | |||
| 236 | + | } | |||
| 237 | + | } | |||
M internal/core/overview_named_test.go +1 -1
| @@ -410,7 +410,7 @@ func TestOverviewNamedDividerPublished(t *testing.T) { | |||
| 410 | 410 | func TestOverviewNamedScrollAndSwipe(t *testing.T) { | |
| 411 | 411 | m := namedOverviewMonitor() | |
| 412 | 412 | m.ToggleOverview() | |
| 413 | - | m.overviewSwipe(ActionFocusWorkspaceDown) | |
| 413 | + | m.overviewFocus(ActionFocusWorkspaceDown) | |
| 414 | 414 | if m.Current().Name != "dev" { | |
| 415 | 415 | t.Fatal("swipe did not cross groups") | |
| 416 | 416 | } | |
| @@ -410,7 +410,7 @@ func TestOverviewNamedDividerPublished(t *testing.T) { | |||||
| 410 | func TestOverviewNamedScrollAndSwipe(t *testing.T) { | 410 | func TestOverviewNamedScrollAndSwipe(t *testing.T) { | ||
| 411 | m := namedOverviewMonitor() | 411 | m := namedOverviewMonitor() | ||
| 412 | m.ToggleOverview() | 412 | m.ToggleOverview() | ||
| 413 | - | m.overviewSwipe(ActionFocusWorkspaceDown) | 413 | + | m.overviewFocus(ActionFocusWorkspaceDown) |
| 414 | if m.Current().Name != "dev" { | 414 | if m.Current().Name != "dev" { | ||
| 415 | t.Fatal("swipe did not cross groups") | 415 | t.Fatal("swipe did not cross groups") | ||
| 416 | } | 416 | } | ||
M internal/core/overview_regression_test.go +1 -4
| @@ -125,11 +125,8 @@ func TestOverviewWindowMutationsDisabled(t *testing.T) { | |||
| 125 | 125 | actions := []Action{ | |
| 126 | 126 | ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, | |
| 127 | 127 | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | |
| 128 | - | ActionToggleStashVisible, ActionMoveColumnToWorkspaceUp, | |
| 129 | - | ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, | |
| 130 | - | ActionMoveWindowToWorkspaceDown, ActionConsumeOrExpelLeft, | |
| 128 | + | ActionToggleStashVisible, ActionConsumeOrExpelLeft, | |
| 131 | 129 | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, | |
| 132 | - | "move-column-to-workspace 2", "move-window-to-workspace 2", | |
| 133 | 130 | } | |
| 134 | 131 | for _, a := range actions { | |
| 135 | 132 | t.Run(string(a), func(t *testing.T) { | |
| @@ -125,11 +125,8 @@ func TestOverviewWindowMutationsDisabled(t *testing.T) { | |||||
| 125 | actions := []Action{ | 125 | actions := []Action{ | ||
| 126 | ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, | 126 | ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, | ||
| 127 | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | 127 | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | ||
| 128 | - | ActionToggleStashVisible, ActionMoveColumnToWorkspaceUp, | 128 | + | ActionToggleStashVisible, ActionConsumeOrExpelLeft, |
| 129 | - | ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, | |||
| 130 | - | ActionMoveWindowToWorkspaceDown, ActionConsumeOrExpelLeft, | |||
| 131 | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, | 129 | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, | ||
| 132 | - | "move-column-to-workspace 2", "move-window-to-workspace 2", | |||
| 133 | } | 130 | } | ||
| 134 | for _, a := range actions { | 131 | for _, a := range actions { | ||
| 135 | t.Run(string(a), func(t *testing.T) { | 132 | t.Run(string(a), func(t *testing.T) { | ||
M internal/core/overview_stack.go +1 -1
| @@ -146,7 +146,7 @@ func (m *Monitor) hiddenColumn(w *Workspace) int { | |||
| 146 | 146 | return i | |
| 147 | 147 | } | |
| 148 | 148 | } | |
| 149 | - | if i := w.columnOf(m.ov.fromID); w == m.ov.from && i >= 0 && i != w.Focus { | |
| 149 | + | if i := w.columnOf(m.ov.rows[m.ov.from].focus); w == m.ov.from && i >= 0 && i != w.Focus { | |
| 150 | 150 | return i | |
| 151 | 151 | } | |
| 152 | 152 | for i := range w.Columns { | |
| @@ -146,7 +146,7 @@ func (m *Monitor) hiddenColumn(w *Workspace) int { | |||||
| 146 | return i | 146 | return i | ||
| 147 | } | 147 | } | ||
| 148 | } | 148 | } | ||
| 149 | - | if i := w.columnOf(m.ov.fromID); w == m.ov.from && i >= 0 && i != w.Focus { | 149 | + | if i := w.columnOf(m.ov.rows[m.ov.from].focus); w == m.ov.from && i >= 0 && i != w.Focus { |
| 150 | return i | 150 | return i | ||
| 151 | } | 151 | } | ||
| 152 | for i := range w.Columns { | 152 | for i := range w.Columns { | ||
M internal/core/overview_stack_test.go +21 -0
| @@ -277,3 +277,24 @@ func TestOverviewStackNoFloatVeil(t *testing.T) { | |||
| 277 | 277 | t.Fatalf("veil %v in the overview", d) | |
| 278 | 278 | } | |
| 279 | 279 | } | |
| 280 | + | ||
| 281 | + | // l past the last stash card returns to the front card, even with no columns. | |
| 282 | + | func TestOverviewPileRightToFloatCard(t *testing.T) { | |
| 283 | + | m := monitor() | |
| 284 | + | m.SetOutput(300, 200) | |
| 285 | + | m.SetBorder(2) | |
| 286 | + | w := m.Current() | |
| 287 | + | m.AddWindow(1) | |
| 288 | + | w.ToggleWindowStash() | |
| 289 | + | w.ToggleStashVisible() | |
| 290 | + | w.AddFloating(9, 300, 200) | |
| 291 | + | m.ToggleOverview() | |
| 292 | + | m.OverviewMove(-1, 0) | |
| 293 | + | if m.card() != 1 { | |
| 294 | + | t.Fatalf("h did not enter the pile: card %d", m.card()) | |
| 295 | + | } | |
| 296 | + | m.OverviewMove(1, 0) | |
| 297 | + | if m.card() != 0 || m.overviewTarget() != 9 { | |
| 298 | + | t.Fatalf("l stayed in the pile: card %d target %d", m.card(), m.overviewTarget()) | |
| 299 | + | } | |
| 300 | + | } | |
| @@ -277,3 +277,24 @@ func TestOverviewStackNoFloatVeil(t *testing.T) { | |||||
| 277 | t.Fatalf("veil %v in the overview", d) | 277 | t.Fatalf("veil %v in the overview", d) | ||
| 278 | } | 278 | } | ||
| 279 | } | 279 | } | ||
| 280 | + | ||||
| 281 | + | // l past the last stash card returns to the front card, even with no columns. | |||
| 282 | + | func TestOverviewPileRightToFloatCard(t *testing.T) { | |||
| 283 | + | m := monitor() | |||
| 284 | + | m.SetOutput(300, 200) | |||
| 285 | + | m.SetBorder(2) | |||
| 286 | + | w := m.Current() | |||
| 287 | + | m.AddWindow(1) | |||
| 288 | + | w.ToggleWindowStash() | |||
| 289 | + | w.ToggleStashVisible() | |||
| 290 | + | w.AddFloating(9, 300, 200) | |||
| 291 | + | m.ToggleOverview() | |||
| 292 | + | m.OverviewMove(-1, 0) | |||
| 293 | + | if m.card() != 1 { | |||
| 294 | + | t.Fatalf("h did not enter the pile: card %d", m.card()) | |||
| 295 | + | } | |||
| 296 | + | m.OverviewMove(1, 0) | |||
| 297 | + | if m.card() != 0 || m.overviewTarget() != 9 { | |||
| 298 | + | t.Fatalf("l stayed in the pile: card %d target %d", m.card(), m.overviewTarget()) | |||
| 299 | + | } | |||
| 300 | + | } | |||