detached d6a3812

Merge pull request 'feat: Cmd+Tab column switcher' (#28) from feat/column-switcher into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/28
commit
d6a38127be82c657e1b7ab5e27128be09a3b8732
author
bnema · · 2026-10-08 19:18 UTC
merge of
890e769 + 03e5d77 · diff against the first parent
signature
unsigned
22 files · +2060 -56
M Makefile +1 -1
@@ -69,7 +69,7 @@ adapter-imports:
6969 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
7070 # Fast, mandatory guards for tiled content publications and SHM copies.
7171 perf-check:
72- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
72+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
7373 check: vet test arch fakes-check log-check perf-check
7474
7575 # On-demand SDR/HDR colour check with examples/testpattern on a headless
@@ -69,7 +69,7 @@ adapter-imports:
69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
70 # Fast, mandatory guards for tiled content publications and SHM copies.70 # Fast, mandatory guards for tiled content publications and SHM copies.
71 perf-check:71 perf-check:
72- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=172+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
73 check: vet test arch fakes-check log-check perf-check73 check: vet test arch fakes-check log-check perf-check
74 74
75 # On-demand SDR/HDR colour check with examples/testpattern on a headless75 # On-demand SDR/HDR colour check with examples/testpattern on a headless
M README.md +2 -0
@@ -280,6 +280,8 @@ bind.cmd+shift+f = toggle-fullscreen
280280 bind.cmd+s = toggle-stash-visible
281281 bind.cmd+shift+s = toggle-window-stash
282282 bind.cmd+o = toggle-overview
283+bind.cmd+tab = switch-column-next
284+bind.cmd+shift+tab = switch-column-prev
283285
284286 # Workspaces
285287 bind.cmd+pageup = focus-workspace-prev
@@ -280,6 +280,8 @@ bind.cmd+shift+f = toggle-fullscreen
280 bind.cmd+s = toggle-stash-visible280 bind.cmd+s = toggle-stash-visible
281 bind.cmd+shift+s = toggle-window-stash281 bind.cmd+shift+s = toggle-window-stash
282 bind.cmd+o = toggle-overview282 bind.cmd+o = toggle-overview
283+bind.cmd+tab = switch-column-next
284+bind.cmd+shift+tab = switch-column-prev
283 285
284 # Workspaces286 # Workspaces
285 bind.cmd+pageup = focus-workspace-prev287 bind.cmd+pageup = focus-workspace-prev
M docs/config.md +7 -3
@@ -44,10 +44,10 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
4444 | `mouse.accel-profile` | `adaptive` | `adaptive`: faster moves go further; `flat`: constant speed |
4545 | `mouse.left-handed` | `off` | Swaps the left and right mouse buttons |
4646 | `cursor.hide-after` | `5s` | Hides the pointer cursor after this long without motion; the next motion shows it. 100ms to 1h, or `off` |
47-| `animations` | `off` | **Experimental.** `on` animates changes; `off` makes every change instant and the three-finger swipe no longer follows the fingers: swipe landings, scrolling, workspace switches, window moves and resizes, drag drops, workspaces sent to another monitor, the overview, windows opening and closing, the stash showing and hiding, the focus pulse |
47+| `animations` | `off` | **Experimental.** `on` animates changes; `off` makes every change instant and the three-finger swipe no longer follows the fingers: swipe landings, scrolling, workspace switches, window moves and resizes, drag drops, workspaces sent to another monitor, the overview and the column switcher cards, windows opening and closing, the stash showing and hiding, the focus pulse |
4848 | `animations.speed` | `normal` | Speed of every spring (not the focus pulse): `slow`, `normal` or `fast` (a view move lands in about 260, 180 or 130 ms), or a number relative to `normal`, from `0.1` (10× slower) to `10` (10× faster) |
4949 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
50-| `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone outside panels (e.g. a maximized column), the overview and a window focused as it opens. Off while `animations = off` |
50+| `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone outside panels (e.g. a maximized column), the overview, the column switcher cards and a window focused as it opens. Off while `animations = off` |
5151 | `focus.effect` | `screen` | What the focus indicator draws: `screen` brightens the window slightly, visible on black too. HDR (PQ) client content is not brightened on HDR outputs |
5252 | `focus.strength` | `0.04` | Peak of the focus effect, `0.01` to `0.2`: black reaches about 10/255 at `0.04` |
5353 | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; use the overview to show an occupied one, or a `workspace <name>` bind to show it and toggle back |
@@ -250,6 +250,8 @@ Cascade supports dropping onto tiles, but not gap insertion or edge scrolling du
250250 | `toggle-floating` | Make the focused tile a [free floating window](#free-floating-windows) at its current size, centred; on a floating window, send it back to its former place in the columns (a native dialog becomes a new column). Does nothing on a stashed or fullscreen window |
251251 | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Over a fullscreen window, shows the stash on top with the focus; the window stays fullscreen behind (composed, so no direct scanout while the stash shows) and is back when the stash hides or the focus leaves it. `toggle-window-stash` or `toggle-fullscreen` on a stashed window there leaves that fullscreen for it. New windows never join or show over it; the fullscreen window's own dialogs show above it |
252252 | `toggle-overview` | Open the [overview](#overview), or close it on the selected window |
253+| `switch-column-next` | Switch to the column used before this one on the workspace. Hold Cmd and press again to go further back; after a short hold a strip of cards shows the columns, most recent first, and releasing Cmd focuses the selected one. Escape cancels; clicking a card picks it, and a click elsewhere cancels and is not passed to windows. A click before the cards show cancels and acts as usual. A maximized column hands its maximization to the target. A workspace capture session pauses while the strip shows, as in the overview |
254+| `switch-column-prev` | The same, in the opposite direction |
253255 | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column |
254256 | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time |
255257 | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays |
@@ -312,7 +314,7 @@ Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the
312314
313315 A four-finger swipe up opens the overview, or hides the [stash](#stash) when it is shown, and a swipe down closes the overview on the selection (with the overview closed, a swipe down shows or hides the stash), whatever `touchpad.natural-scroll` says. Two-finger scrolling belongs to applications and never navigates the overview; previews do not accept application input. In a vertical overview, three fingers move the selection during the gesture, following `touchpad.natural-scroll`: horizontally, a short distance selects the next app and further movement repeats at a longer distance; vertically, one step per gesture selects a stack card or changes workspace. Inside a selected stash pile, vertical movement browses its cards and horizontal movement enters or leaves the pile. Lifting resets the gesture without an extra step; cancellation keeps selections already made. A Cascade overview follows the fingers instead (see [Cascade](#cascade)). The mouse wheel moves one step per notch, following `mouse.natural-scroll`. Three-finger navigation, the wheel and keyboard navigation share the same pile boundaries.
314316
315-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.
317+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), moving a workspace to another monitor and `switch-column-next` / `switch-column-prev` are disabled while the overview is open. Workspace and monitor navigation, launch and quit binds remain active.
316318
317319 ### Default binds
318320
@@ -348,6 +350,8 @@ With `keyboard.cmd = alt`, the `cmd+alt+…` binds have the same keys as `cmd+
348350 | `cmd+shift+s` | `toggle-window-stash` |
349351 | `cmd+shift+space` | `toggle-floating` |
350352 | `cmd+o` | `toggle-overview` |
353+| `cmd+tab` | `switch-column-next` |
354+| `cmd+shift+tab` | `switch-column-prev` |
351355 | `cmd+q` | `close-window` |
352356 | `ctrl+alt+backspace` | `quit` |
353357 | `ctrl+cmd+space` | `spawn fuzzel` |
@@ -44,10 +44,10 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
44 | `mouse.accel-profile` | `adaptive` | `adaptive`: faster moves go further; `flat`: constant speed |44 | `mouse.accel-profile` | `adaptive` | `adaptive`: faster moves go further; `flat`: constant speed |
45 | `mouse.left-handed` | `off` | Swaps the left and right mouse buttons |45 | `mouse.left-handed` | `off` | Swaps the left and right mouse buttons |
46 | `cursor.hide-after` | `5s` | Hides the pointer cursor after this long without motion; the next motion shows it. 100ms to 1h, or `off` |46 | `cursor.hide-after` | `5s` | Hides the pointer cursor after this long without motion; the next motion shows it. 100ms to 1h, or `off` |
47-| `animations` | `off` | **Experimental.** `on` animates changes; `off` makes every change instant and the three-finger swipe no longer follows the fingers: swipe landings, scrolling, workspace switches, window moves and resizes, drag drops, workspaces sent to another monitor, the overview, windows opening and closing, the stash showing and hiding, the focus pulse |47+| `animations` | `off` | **Experimental.** `on` animates changes; `off` makes every change instant and the three-finger swipe no longer follows the fingers: swipe landings, scrolling, workspace switches, window moves and resizes, drag drops, workspaces sent to another monitor, the overview and the column switcher cards, windows opening and closing, the stash showing and hiding, the focus pulse |
48 | `animations.speed` | `normal` | Speed of every spring (not the focus pulse): `slow`, `normal` or `fast` (a view move lands in about 260, 180 or 130 ms), or a number relative to `normal`, from `0.1` (10× slower) to `10` (10× faster) |48 | `animations.speed` | `normal` | Speed of every spring (not the focus pulse): `slow`, `normal` or `fast` (a view move lands in about 260, 180 or 130 ms), or a number relative to `normal`, from `0.1` (10× slower) to `10` (10× faster) |
49 | `focus.follow-move` | `off` | Follow a column moved to another workspace |49 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
50-| `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone outside panels (e.g. a maximized column), the overview and a window focused as it opens. Off while `animations = off` |50+| `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, a window alone outside panels (e.g. a maximized column), the overview, the column switcher cards and a window focused as it opens. Off while `animations = off` |
51 | `focus.effect` | `screen` | What the focus indicator draws: `screen` brightens the window slightly, visible on black too. HDR (PQ) client content is not brightened on HDR outputs |51 | `focus.effect` | `screen` | What the focus indicator draws: `screen` brightens the window slightly, visible on black too. HDR (PQ) client content is not brightened on HDR outputs |
52 | `focus.strength` | `0.04` | Peak of the focus effect, `0.01` to `0.2`: black reaches about 10/255 at `0.04` |52 | `focus.strength` | `0.04` | Peak of the focus effect, `0.01` to `0.2`: black reaches about 10/255 at `0.04` |
53 | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; use the overview to show an occupied one, or a `workspace <name>` bind to show it and toggle back |53 | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; use the overview to show an occupied one, or a `workspace <name>` bind to show it and toggle back |
@@ -250,6 +250,8 @@ Cascade supports dropping onto tiles, but not gap insertion or edge scrolling du
250 | `toggle-floating` | Make the focused tile a [free floating window](#free-floating-windows) at its current size, centred; on a floating window, send it back to its former place in the columns (a native dialog becomes a new column). Does nothing on a stashed or fullscreen window |250 | `toggle-floating` | Make the focused tile a [free floating window](#free-floating-windows) at its current size, centred; on a floating window, send it back to its former place in the columns (a native dialog becomes a new column). Does nothing on a stashed or fullscreen window |
251 | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Over a fullscreen window, shows the stash on top with the focus; the window stays fullscreen behind (composed, so no direct scanout while the stash shows) and is back when the stash hides or the focus leaves it. `toggle-window-stash` or `toggle-fullscreen` on a stashed window there leaves that fullscreen for it. New windows never join or show over it; the fullscreen window's own dialogs show above it |251 | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Over a fullscreen window, shows the stash on top with the focus; the window stays fullscreen behind (composed, so no direct scanout while the stash shows) and is back when the stash hides or the focus leaves it. `toggle-window-stash` or `toggle-fullscreen` on a stashed window there leaves that fullscreen for it. New windows never join or show over it; the fullscreen window's own dialogs show above it |
252 | `toggle-overview` | Open the [overview](#overview), or close it on the selected window |252 | `toggle-overview` | Open the [overview](#overview), or close it on the selected window |
253+| `switch-column-next` | Switch to the column used before this one on the workspace. Hold Cmd and press again to go further back; after a short hold a strip of cards shows the columns, most recent first, and releasing Cmd focuses the selected one. Escape cancels; clicking a card picks it, and a click elsewhere cancels and is not passed to windows. A click before the cards show cancels and acts as usual. A maximized column hands its maximization to the target. A workspace capture session pauses while the strip shows, as in the overview |
254+| `switch-column-prev` | The same, in the opposite direction |
253 | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column |255 | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column |
254 | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time |256 | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time |
255 | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays |257 | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays |
@@ -312,7 +314,7 @@ Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the
312 314
313 A four-finger swipe up opens the overview, or hides the [stash](#stash) when it is shown, and a swipe down closes the overview on the selection (with the overview closed, a swipe down shows or hides the stash), whatever `touchpad.natural-scroll` says. Two-finger scrolling belongs to applications and never navigates the overview; previews do not accept application input. In a vertical overview, three fingers move the selection during the gesture, following `touchpad.natural-scroll`: horizontally, a short distance selects the next app and further movement repeats at a longer distance; vertically, one step per gesture selects a stack card or changes workspace. Inside a selected stash pile, vertical movement browses its cards and horizontal movement enters or leaves the pile. Lifting resets the gesture without an extra step; cancellation keeps selections already made. A Cascade overview follows the fingers instead (see [Cascade](#cascade)). The mouse wheel moves one step per notch, following `mouse.natural-scroll`. Three-finger navigation, the wheel and keyboard navigation share the same pile boundaries.315 A four-finger swipe up opens the overview, or hides the [stash](#stash) when it is shown, and a swipe down closes the overview on the selection (with the overview closed, a swipe down shows or hides the stash), whatever `touchpad.natural-scroll` says. Two-finger scrolling belongs to applications and never navigates the overview; previews do not accept application input. In a vertical overview, three fingers move the selection during the gesture, following `touchpad.natural-scroll`: horizontally, a short distance selects the next app and further movement repeats at a longer distance; vertically, one step per gesture selects a stack card or changes workspace. Inside a selected stash pile, vertical movement browses its cards and horizontal movement enters or leaves the pile. Lifting resets the gesture without an extra step; cancellation keeps selections already made. A Cascade overview follows the fingers instead (see [Cascade](#cascade)). The mouse wheel moves one step per notch, following `mouse.natural-scroll`. Three-finger navigation, the wheel and keyboard navigation share the same pile boundaries.
314 316
315-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.317+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), moving a workspace to another monitor and `switch-column-next` / `switch-column-prev` are disabled while the overview is open. Workspace and monitor navigation, launch and quit binds remain active.
316 318
317 ### Default binds319 ### Default binds
318 320
@@ -348,6 +350,8 @@ With `keyboard.cmd = alt`, the `cmd+alt+…` binds have the same keys as `cmd+
348 | `cmd+shift+s` | `toggle-window-stash` |350 | `cmd+shift+s` | `toggle-window-stash` |
349 | `cmd+shift+space` | `toggle-floating` |351 | `cmd+shift+space` | `toggle-floating` |
350 | `cmd+o` | `toggle-overview` |352 | `cmd+o` | `toggle-overview` |
353+| `cmd+tab` | `switch-column-next` |
354+| `cmd+shift+tab` | `switch-column-prev` |
351 | `cmd+q` | `close-window` |355 | `cmd+q` | `close-window` |
352 | `ctrl+alt+backspace` | `quit` |356 | `ctrl+alt+backspace` | `quit` |
353 | `ctrl+cmd+space` | `spawn fuzzel` |357 | `ctrl+cmd+space` | `spawn fuzzel` |
M internal/adapters/config/config.go +4 -0
@@ -44,6 +44,8 @@ var defaultBinds = []struct{ combo, action string }{
4444 {"cmd+s", "toggle-stash-visible"},
4545 {"cmd+shift+s", "toggle-window-stash"},
4646 {"cmd+o", "toggle-overview"},
47+ {"cmd+tab", "switch-column-next"},
48+ {"cmd+shift+tab", "switch-column-prev"},
4749 {"cmd+q", "close-window"},
4850 {"ctrl+alt+backspace", "quit"},
4951 {"ctrl+cmd+space", "spawn fuzzel"},
@@ -100,6 +102,8 @@ var actions = map[string]bool{
100102 "toggle-window-stash": true,
101103 "toggle-stash-visible": true,
102104 "toggle-overview": true,
105+ "switch-column-next": true,
106+ "switch-column-prev": true,
103107 "close-window": true,
104108 "quit": true,
105109 "move-window-up": true,
@@ -44,6 +44,8 @@ var defaultBinds = []struct{ combo, action string }{
44 {"cmd+s", "toggle-stash-visible"},44 {"cmd+s", "toggle-stash-visible"},
45 {"cmd+shift+s", "toggle-window-stash"},45 {"cmd+shift+s", "toggle-window-stash"},
46 {"cmd+o", "toggle-overview"},46 {"cmd+o", "toggle-overview"},
47+ {"cmd+tab", "switch-column-next"},
48+ {"cmd+shift+tab", "switch-column-prev"},
47 {"cmd+q", "close-window"},49 {"cmd+q", "close-window"},
48 {"ctrl+alt+backspace", "quit"},50 {"ctrl+alt+backspace", "quit"},
49 {"ctrl+cmd+space", "spawn fuzzel"},51 {"ctrl+cmd+space", "spawn fuzzel"},
@@ -100,6 +102,8 @@ var actions = map[string]bool{
100 "toggle-window-stash": true,102 "toggle-window-stash": true,
101 "toggle-stash-visible": true,103 "toggle-stash-visible": true,
102 "toggle-overview": true,104 "toggle-overview": true,
105+ "switch-column-next": true,
106+ "switch-column-prev": true,
103 "close-window": true,107 "close-window": true,
104 "quit": true,108 "quit": true,
105 "move-window-up": true,109 "move-window-up": true,
M internal/adapters/config/config_test.go +14 -1
@@ -25,7 +25,7 @@ func parseString(t *testing.T, s string) (ports.Config, []Warning) {
2525 func TestDefaultsAndLoad(t *testing.T) {
2626 t.Setenv("TERMINAL", "")
2727 d := Defaults()
28- if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 77 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 {
28+ if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 79 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 {
2929 t.Fatalf("defaults: %+v", d)
3030 }
3131 if d.Binds["Cmd+s"] != "toggle-stash-visible" || d.Binds["Cmd+Shift+s"] != "toggle-window-stash" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {
@@ -400,6 +400,19 @@ func TestBindResolvedCollision(t *testing.T) {
400400 }
401401 }
402402
403+func TestColumnSwitcherBinds(t *testing.T) {
404+ d := Defaults()
405+ if d.Binds["Cmd+Tab"] != "switch-column-next" || d.Binds["Cmd+Shift+Tab"] != "switch-column-prev" {
406+ t.Fatal(d.Binds)
407+ }
408+ for _, action := range []string{"switch-column-next", "switch-column-prev"} {
409+ c, w := parseString(t, "bind.cmd+x = "+action+"\n")
410+ if c.Binds["Cmd+x"] != action || len(w) != 0 {
411+ t.Fatalf("%s: binds %v warnings %v", action, c.Binds, w)
412+ }
413+ }
414+}
415+
403416 func TestInvalidDuplicateKeepsEarlierValue(t *testing.T) {
404417 c, w := parseString(t, "background = #000000\nbackground = red\nbackground = #111111\n")
405418 if c.Background.Color != "#111111" || len(w) != 2 || w[0].Line != 2 || w[1].Line != 3 || !strings.Contains(w[1].Msg, "overrides line 1") {
@@ -25,7 +25,7 @@ func parseString(t *testing.T, s string) (ports.Config, []Warning) {
25 func TestDefaultsAndLoad(t *testing.T) {25 func TestDefaultsAndLoad(t *testing.T) {
26 t.Setenv("TERMINAL", "")26 t.Setenv("TERMINAL", "")
27 d := Defaults()27 d := Defaults()
28- if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 77 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 {28+ if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 79 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 {
29 t.Fatalf("defaults: %+v", d)29 t.Fatalf("defaults: %+v", d)
30 }30 }
31 if d.Binds["Cmd+s"] != "toggle-stash-visible" || d.Binds["Cmd+Shift+s"] != "toggle-window-stash" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {31 if d.Binds["Cmd+s"] != "toggle-stash-visible" || d.Binds["Cmd+Shift+s"] != "toggle-window-stash" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {
@@ -400,6 +400,19 @@ func TestBindResolvedCollision(t *testing.T) {
400 }400 }
401 }401 }
402 402
403+func TestColumnSwitcherBinds(t *testing.T) {
404+ d := Defaults()
405+ if d.Binds["Cmd+Tab"] != "switch-column-next" || d.Binds["Cmd+Shift+Tab"] != "switch-column-prev" {
406+ t.Fatal(d.Binds)
407+ }
408+ for _, action := range []string{"switch-column-next", "switch-column-prev"} {
409+ c, w := parseString(t, "bind.cmd+x = "+action+"\n")
410+ if c.Binds["Cmd+x"] != action || len(w) != 0 {
411+ t.Fatalf("%s: binds %v warnings %v", action, c.Binds, w)
412+ }
413+ }
414+}
415+
403 func TestInvalidDuplicateKeepsEarlierValue(t *testing.T) {416 func TestInvalidDuplicateKeepsEarlierValue(t *testing.T) {
404 c, w := parseString(t, "background = #000000\nbackground = red\nbackground = #111111\n")417 c, w := parseString(t, "background = #000000\nbackground = red\nbackground = #111111\n")
405 if c.Background.Color != "#111111" || len(w) != 2 || w[0].Line != 2 || w[1].Line != 3 || !strings.Contains(w[1].Msg, "overrides line 1") {418 if c.Background.Color != "#111111" || len(w) != 2 || w[0].Line != 2 || w[1].Line != 3 || !strings.Contains(w[1].Msg, "overrides line 1") {
M internal/core/action.go +4 -0
@@ -58,6 +58,10 @@ const (
5858 ActionMoveWorkspaceNext Action = "move-workspace-next"
5959 // Turn the focused tile into a free floating window, or back.
6060 ActionToggleFloating Action = "toggle-floating"
61+ // Switch to the column used before this one, further back while the
62+ // command key is held (switcher.go).
63+ ActionSwitchColumnNext Action = "switch-column-next"
64+ ActionSwitchColumnPrev Action = "switch-column-prev"
6165 )
6266
6367 // monitorDirection returns the direction of the neighbor screen targeted by
@@ -58,6 +58,10 @@ const (
58 ActionMoveWorkspaceNext Action = "move-workspace-next"58 ActionMoveWorkspaceNext Action = "move-workspace-next"
59 // Turn the focused tile into a free floating window, or back.59 // Turn the focused tile into a free floating window, or back.
60 ActionToggleFloating Action = "toggle-floating"60 ActionToggleFloating Action = "toggle-floating"
61+ // Switch to the column used before this one, further back while the
62+ // command key is held (switcher.go).
63+ ActionSwitchColumnNext Action = "switch-column-next"
64+ ActionSwitchColumnPrev Action = "switch-column-prev"
61 )65 )
62 66
63 // monitorDirection returns the direction of the neighbor screen targeted by67 // monitorDirection returns the direction of the neighbor screen targeted by
M internal/core/capindicator_test.go +7 -1
@@ -20,6 +20,12 @@ type indicatorClock struct {
2020 }
2121
2222 func indicatorCore(t *testing.T) (*Core, *indicatorClock) {
23+ t.Helper()
24+ return indicatorCoreWith(t, make(chan ports.ClientCommand, 64))
25+}
26+
27+// indicatorCoreWith is indicatorCore sending its commands to commands.
28+func indicatorCoreWith(t *testing.T, commands chan ports.ClientCommand) (*Core, *indicatorClock) {
2329 t.Helper()
2430 ic := &indicatorClock{now: time.Unix(1000, 0)}
2531 clock := portsmocks.NewMockClock(t)
@@ -39,7 +45,7 @@ func indicatorCore(t *testing.T) (*Core, *indicatorClock) {
3945 cfg.Keyboard.CmdKey = "super"
4046 cfg.Layout.MaxColumns = 2
4147 cfg.Border.Width = 0
42- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)}, Options{Clock: clock})
48+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands}, Options{Clock: clock})
4349 if err != nil {
4450 t.Fatal(err)
4551 }
@@ -20,6 +20,12 @@ type indicatorClock struct {
20 }20 }
21 21
22 func indicatorCore(t *testing.T) (*Core, *indicatorClock) {22 func indicatorCore(t *testing.T) (*Core, *indicatorClock) {
23+ t.Helper()
24+ return indicatorCoreWith(t, make(chan ports.ClientCommand, 64))
25+}
26+
27+// indicatorCoreWith is indicatorCore sending its commands to commands.
28+func indicatorCoreWith(t *testing.T, commands chan ports.ClientCommand) (*Core, *indicatorClock) {
23 t.Helper()29 t.Helper()
24 ic := &indicatorClock{now: time.Unix(1000, 0)}30 ic := &indicatorClock{now: time.Unix(1000, 0)}
25 clock := portsmocks.NewMockClock(t)31 clock := portsmocks.NewMockClock(t)
@@ -39,7 +45,7 @@ func indicatorCore(t *testing.T) (*Core, *indicatorClock) {
39 cfg.Keyboard.CmdKey = "super"45 cfg.Keyboard.CmdKey = "super"
40 cfg.Layout.MaxColumns = 246 cfg.Layout.MaxColumns = 2
41 cfg.Border.Width = 047 cfg.Border.Width = 0
42- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)}, Options{Clock: clock})48+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands}, Options{Clock: clock})
43 if err != nil {49 if err != nil {
44 t.Fatal(err)50 t.Fatal(err)
45 }51 }
M internal/core/capture.go +2 -2
@@ -153,7 +153,7 @@ func (c *Core) capResolve(t capTarget) (capResolved, ports.CaptureReason) {
153153 return r, ports.CaptureReasonWorkspaceGone
154154 }
155155 r.sc, r.ws, r.rect = sc, w, w.Output
156- r.hidden = sc.mon.Current() != w || sc.mon.ov.open
156+ r.hidden = sc.mon.Current() != w || sc.mon.previewing()
157157 return r, ports.CaptureReasonNone
158158 }
159159 i := c.screenIndex(t.output)
@@ -467,7 +467,7 @@ func (c *Core) captureSceneFor(sc *screen, v *capView) *ports.SceneCapture {
467467 return nil
468468 }
469469 want := ports.SceneCapture{}
470- if !sc.mon.ov.open {
470+ if !sc.mon.previewing() {
471471 want.Shown = sc.mon.Current().ID
472472 }
473473 if e := v.exclusion; e != nil {
@@ -153,7 +153,7 @@ func (c *Core) capResolve(t capTarget) (capResolved, ports.CaptureReason) {
153 return r, ports.CaptureReasonWorkspaceGone153 return r, ports.CaptureReasonWorkspaceGone
154 }154 }
155 r.sc, r.ws, r.rect = sc, w, w.Output155 r.sc, r.ws, r.rect = sc, w, w.Output
156- r.hidden = sc.mon.Current() != w || sc.mon.ov.open156+ r.hidden = sc.mon.Current() != w || sc.mon.previewing()
157 return r, ports.CaptureReasonNone157 return r, ports.CaptureReasonNone
158 }158 }
159 i := c.screenIndex(t.output)159 i := c.screenIndex(t.output)
@@ -467,7 +467,7 @@ func (c *Core) captureSceneFor(sc *screen, v *capView) *ports.SceneCapture {
467 return nil467 return nil
468 }468 }
469 want := ports.SceneCapture{}469 want := ports.SceneCapture{}
470- if !sc.mon.ov.open {470+ if !sc.mon.previewing() {
471 want.Shown = sc.mon.Current().ID471 want.Shown = sc.mon.Current().ID
472 }472 }
473 if e := v.exclusion; e != nil {473 if e := v.exclusion; e != nil {
M internal/core/core.go +27 -3
@@ -152,6 +152,13 @@ type Core struct {
152152 capt captureState
153153 // pulse marks a window that just got the focus (pulse.go).
154154 pulse focusPulse
155+ // switcher is the column switcher in progress: the screen it is on and
156+ // the timer that shows its cards (switcher.go).
157+ switcher struct {
158+ sc *screen
159+ timerC <-chan time.Time
160+ timerStop func() bool
161+ }
155162 }
156163
157164 // publishScratch is where publishState and publishWorkspaces build their
@@ -263,7 +270,7 @@ func (c *Core) apply(cfg ports.Config) error {
263270 }
264271 switch Action(a) {
265272 case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveColumnToWorkspacePrev, ActionMoveColumnToWorkspaceNext, ActionMoveWindowToWorkspacePrev, ActionMoveWindowToWorkspaceNext, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight,
266- ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating:
273+ ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext, ActionSwitchColumnPrev:
267274 default:
268275 return fmt.Errorf("invalid action %q", a)
269276 }
@@ -591,11 +598,19 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
591598 if c.security.Protected {
592599 return c.publishProtected(ctx)
593600 }
601+ if c.switching() && c.switcherStale() {
602+ // Another workspace came on screen under the switcher: it is over.
603+ c.cancelSwitcher()
604+ }
594605 for _, sc := range c.screens {
595606 // The overview's selection is provisional: it focuses nothing yet.
596607 if !sc.mon.ov.open {
597608 sc.mon.Current().settleLeft()
598609 }
610+ // A switcher's selection is provisional too.
611+ if !sc.mon.previewing() {
612+ sc.mon.Current().noteFocus()
613+ }
599614 }
600615 c.refreshShown()
601616 c.captureExpire()
@@ -971,9 +986,9 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
971986 if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 {
972987 return id, px, py
973988 }
974- if sc.mon.ov.open {
989+ if sc.mon.previewing() {
975990 // Previews and the layers under them take no input: a click
976- // picks a preview (overviewClick).
991+ // picks a preview (overviewClick, switcherClick).
977992 return 0, 0, 0
978993 }
979994 if !sc.mon.frameHas(lx, ly) {
@@ -1013,6 +1028,7 @@ func (c *Core) Run(ctx context.Context) error {
10131028 defer c.stopFrame()
10141029 defer c.stopCaptureTimer()
10151030 defer c.stopPulseTimer()
1031+ defer c.cancelSwitcher()
10161032 // Startup commands run once per session. Full launcher queues retain a
10171033 // bounded remainder, selectable alongside owner events without polling.
10181034 c.startup = make([][]string, len(c.cfg.Startup))
@@ -1085,6 +1101,14 @@ func (c *Core) Run(ctx context.Context) error {
10851101 return nil
10861102 }
10871103 continue
1104+ case <-c.switcher.timerC:
1105+ if c.securityCheckpoint(ctx) != nil {
1106+ return nil
1107+ }
1108+ if c.switcherTick() && (c.publish(ctx) != nil || c.rehit(ctx) != nil) {
1109+ return nil
1110+ }
1111+ continue
10881112 case <-c.capt.timerC:
10891113 c.capt.timerC, c.capt.timerStop = nil, nil
10901114 if c.captureFlashTick() && c.publish(ctx) != nil {
@@ -152,6 +152,13 @@ type Core struct {
152 capt captureState152 capt captureState
153 // pulse marks a window that just got the focus (pulse.go).153 // pulse marks a window that just got the focus (pulse.go).
154 pulse focusPulse154 pulse focusPulse
155+ // switcher is the column switcher in progress: the screen it is on and
156+ // the timer that shows its cards (switcher.go).
157+ switcher struct {
158+ sc *screen
159+ timerC <-chan time.Time
160+ timerStop func() bool
161+ }
155 }162 }
156 163
157 // publishScratch is where publishState and publishWorkspaces build their164 // publishScratch is where publishState and publishWorkspaces build their
@@ -263,7 +270,7 @@ func (c *Core) apply(cfg ports.Config) error {
263 }270 }
264 switch Action(a) {271 switch Action(a) {
265 case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveColumnToWorkspacePrev, ActionMoveColumnToWorkspaceNext, ActionMoveWindowToWorkspacePrev, ActionMoveWindowToWorkspaceNext, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight,272 case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveColumnToWorkspacePrev, ActionMoveColumnToWorkspaceNext, ActionMoveWindowToWorkspacePrev, ActionMoveWindowToWorkspaceNext, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight,
266- ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating:273+ ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext, ActionSwitchColumnPrev:
267 default:274 default:
268 return fmt.Errorf("invalid action %q", a)275 return fmt.Errorf("invalid action %q", a)
269 }276 }
@@ -591,11 +598,19 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
591 if c.security.Protected {598 if c.security.Protected {
592 return c.publishProtected(ctx)599 return c.publishProtected(ctx)
593 }600 }
601+ if c.switching() && c.switcherStale() {
602+ // Another workspace came on screen under the switcher: it is over.
603+ c.cancelSwitcher()
604+ }
594 for _, sc := range c.screens {605 for _, sc := range c.screens {
595 // The overview's selection is provisional: it focuses nothing yet.606 // The overview's selection is provisional: it focuses nothing yet.
596 if !sc.mon.ov.open {607 if !sc.mon.ov.open {
597 sc.mon.Current().settleLeft()608 sc.mon.Current().settleLeft()
598 }609 }
610+ // A switcher's selection is provisional too.
611+ if !sc.mon.previewing() {
612+ sc.mon.Current().noteFocus()
613+ }
599 }614 }
600 c.refreshShown()615 c.refreshShown()
601 c.captureExpire()616 c.captureExpire()
@@ -971,9 +986,9 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
971 if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 {986 if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 {
972 return id, px, py987 return id, px, py
973 }988 }
974- if sc.mon.ov.open {989+ if sc.mon.previewing() {
975 // Previews and the layers under them take no input: a click990 // Previews and the layers under them take no input: a click
976- // picks a preview (overviewClick).991+ // picks a preview (overviewClick, switcherClick).
977 return 0, 0, 0992 return 0, 0, 0
978 }993 }
979 if !sc.mon.frameHas(lx, ly) {994 if !sc.mon.frameHas(lx, ly) {
@@ -1013,6 +1028,7 @@ func (c *Core) Run(ctx context.Context) error {
1013 defer c.stopFrame()1028 defer c.stopFrame()
1014 defer c.stopCaptureTimer()1029 defer c.stopCaptureTimer()
1015 defer c.stopPulseTimer()1030 defer c.stopPulseTimer()
1031+ defer c.cancelSwitcher()
1016 // Startup commands run once per session. Full launcher queues retain a1032 // Startup commands run once per session. Full launcher queues retain a
1017 // bounded remainder, selectable alongside owner events without polling.1033 // bounded remainder, selectable alongside owner events without polling.
1018 c.startup = make([][]string, len(c.cfg.Startup))1034 c.startup = make([][]string, len(c.cfg.Startup))
@@ -1085,6 +1101,14 @@ func (c *Core) Run(ctx context.Context) error {
1085 return nil1101 return nil
1086 }1102 }
1087 continue1103 continue
1104+ case <-c.switcher.timerC:
1105+ if c.securityCheckpoint(ctx) != nil {
1106+ return nil
1107+ }
1108+ if c.switcherTick() && (c.publish(ctx) != nil || c.rehit(ctx) != nil) {
1109+ return nil
1110+ }
1111+ continue
1088 case <-c.capt.timerC:1112 case <-c.capt.timerC:
1089 c.capt.timerC, c.capt.timerStop = nil, nil1113 c.capt.timerC, c.capt.timerStop = nil, nil
1090 if c.captureFlashTick() && c.publish(ctx) != nil {1114 if c.captureFlashTick() && c.publish(ctx) != nil {
M internal/core/drag.go +2 -2
@@ -78,7 +78,7 @@ type dragState struct {
7878 // not fullscreen, outside the overview.
7979 func (c *Core) draggable(id WindowID) (*screen, *Workspace, bool) {
8080 s, w := c.screenOf(id)
81- if s == nil || w != s.mon.Current() || s.mon.ov.open || id == w.fullscreen || w.cover() != 0 {
81+ if s == nil || w != s.mon.Current() || s.mon.previewing() || id == w.fullscreen || w.cover() != 0 {
8282 return nil, nil, false
8383 }
8484 if w.floatIndex(id) < 0 && w.columnOf(id) < 0 {
@@ -288,7 +288,7 @@ func (c *Core) dropAt(id WindowID, x, y float64) dropTarget {
288288 m := sc.mon
289289 lx, ly := int(x)-sc.x, int(y)-sc.y
290290 w := m.Current()
291- if m.ov.open || w.pinned() || !m.frameHas(float64(lx), float64(ly)) {
291+ if m.previewing() || w.pinned() || !m.frameHas(float64(lx), float64(ly)) {
292292 return dropTarget{}
293293 }
294294 t := w.dropIn(id, lx, ly, max(4*c.cfg.Border.Width, 4))
@@ -78,7 +78,7 @@ type dragState struct {
78 // not fullscreen, outside the overview.78 // not fullscreen, outside the overview.
79 func (c *Core) draggable(id WindowID) (*screen, *Workspace, bool) {79 func (c *Core) draggable(id WindowID) (*screen, *Workspace, bool) {
80 s, w := c.screenOf(id)80 s, w := c.screenOf(id)
81- if s == nil || w != s.mon.Current() || s.mon.ov.open || id == w.fullscreen || w.cover() != 0 {81+ if s == nil || w != s.mon.Current() || s.mon.previewing() || id == w.fullscreen || w.cover() != 0 {
82 return nil, nil, false82 return nil, nil, false
83 }83 }
84 if w.floatIndex(id) < 0 && w.columnOf(id) < 0 {84 if w.floatIndex(id) < 0 && w.columnOf(id) < 0 {
@@ -288,7 +288,7 @@ func (c *Core) dropAt(id WindowID, x, y float64) dropTarget {
288 m := sc.mon288 m := sc.mon
289 lx, ly := int(x)-sc.x, int(y)-sc.y289 lx, ly := int(x)-sc.x, int(y)-sc.y
290 w := m.Current()290 w := m.Current()
291- if m.ov.open || w.pinned() || !m.frameHas(float64(lx), float64(ly)) {291+ if m.previewing() || w.pinned() || !m.frameHas(float64(lx), float64(ly)) {
292 return dropTarget{}292 return dropTarget{}
293 }293 }
294 t := w.dropIn(id, lx, ly, max(4*c.cfg.Border.Width, 4))294 t := w.dropIn(id, lx, ly, max(4*c.cfg.Border.Width, 4))
M internal/core/gesture.go +4 -0
@@ -138,6 +138,10 @@ func (c *Core) swipeSign() float64 {
138138 // running (its end was lost) ends cancelled first: its slide settles back.
139139 func (c *Core) swipeBegin(b ports.SwipeBegin) bool {
140140 changed := false
141+ if c.switching() {
142+ c.cancelSwitcher()
143+ changed = true
144+ }
141145 if c.swipe != nil {
142146 c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: b.Time})
143147 changed = true
@@ -138,6 +138,10 @@ func (c *Core) swipeSign() float64 {
138 // running (its end was lost) ends cancelled first: its slide settles back.138 // running (its end was lost) ends cancelled first: its slide settles back.
139 func (c *Core) swipeBegin(b ports.SwipeBegin) bool {139 func (c *Core) swipeBegin(b ports.SwipeBegin) bool {
140 changed := false140 changed := false
141+ if c.switching() {
142+ c.cancelSwitcher()
143+ changed = true
144+ }
141 if c.swipe != nil {145 if c.swipe != nil {
142 c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: b.Time})146 c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: b.Time})
143 changed = true147 changed = true
M internal/core/input.go +24 -0
@@ -128,6 +128,11 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
128128 }
129129 return nil
130130 }
131+ if v.Pressed && c.switching() {
132+ if handled, err := c.switcherClick(ctx, v.Button); err != nil || handled {
133+ return err
134+ }
135+ }
131136 if started, err := c.startDrag(ctx, v); err != nil || started {
132137 return err
133138 }
@@ -224,6 +229,18 @@ func (c *Core) pointerAxis(ctx context.Context, v ports.PointerAxis) error {
224229
225230 func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
226231 c.mods = key.Mods
232+ // Releasing the command key commits the switcher; the release still
233+ // goes on to the bind lookup and the clients.
234+ if c.switching() && c.mods&c.cmdMod == 0 {
235+ if err := c.releaseSwitcher(ctx); err != nil {
236+ return err
237+ }
238+ }
239+ if c.switching() && key.Pressed && key.Keysym == "Escape" {
240+ // Escape cancels the switcher; its release goes nowhere either.
241+ c.pressed[heldKey(key)] = true
242+ return c.abortSwitcher(ctx)
243+ }
227244 if c.drag != nil && key.Pressed && key.Keysym == "Escape" {
228245 // Escape cancels the drag; its release goes nowhere either.
229246 c.cancelDrag()
@@ -308,10 +325,17 @@ func (c *Core) runBind(ctx context.Context, action Action) error {
308325 if action == ActionScaleUp || action == ActionScaleDown {
309326 return c.scaleBind(ctx, action)
310327 }
328+ if dir := switchDir(action); dir != 0 {
329+ return c.switchBind(ctx, dir)
330+ }
311331 before := c.cur().mon.Current()
312332 swiped := c.swipedWorkspace()
313333 now := c.now()
334+ // Snapshot before the switcher closes: its cards fly back to the layout.
314335 shots := c.snapshot(now)
336+ if c.switching() {
337+ c.cancelSwitcher()
338+ }
315339 c.keyboard.takeBack() // a bind acts on the windows
316340 effect := c.applyAction(action)
317341 if effect.Quit {
@@ -128,6 +128,11 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
128 }128 }
129 return nil129 return nil
130 }130 }
131+ if v.Pressed && c.switching() {
132+ if handled, err := c.switcherClick(ctx, v.Button); err != nil || handled {
133+ return err
134+ }
135+ }
131 if started, err := c.startDrag(ctx, v); err != nil || started {136 if started, err := c.startDrag(ctx, v); err != nil || started {
132 return err137 return err
133 }138 }
@@ -224,6 +229,18 @@ func (c *Core) pointerAxis(ctx context.Context, v ports.PointerAxis) error {
224 229
225 func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {230 func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
226 c.mods = key.Mods231 c.mods = key.Mods
232+ // Releasing the command key commits the switcher; the release still
233+ // goes on to the bind lookup and the clients.
234+ if c.switching() && c.mods&c.cmdMod == 0 {
235+ if err := c.releaseSwitcher(ctx); err != nil {
236+ return err
237+ }
238+ }
239+ if c.switching() && key.Pressed && key.Keysym == "Escape" {
240+ // Escape cancels the switcher; its release goes nowhere either.
241+ c.pressed[heldKey(key)] = true
242+ return c.abortSwitcher(ctx)
243+ }
227 if c.drag != nil && key.Pressed && key.Keysym == "Escape" {244 if c.drag != nil && key.Pressed && key.Keysym == "Escape" {
228 // Escape cancels the drag; its release goes nowhere either.245 // Escape cancels the drag; its release goes nowhere either.
229 c.cancelDrag()246 c.cancelDrag()
@@ -308,10 +325,17 @@ func (c *Core) runBind(ctx context.Context, action Action) error {
308 if action == ActionScaleUp || action == ActionScaleDown {325 if action == ActionScaleUp || action == ActionScaleDown {
309 return c.scaleBind(ctx, action)326 return c.scaleBind(ctx, action)
310 }327 }
328+ if dir := switchDir(action); dir != 0 {
329+ return c.switchBind(ctx, dir)
330+ }
311 before := c.cur().mon.Current()331 before := c.cur().mon.Current()
312 swiped := c.swipedWorkspace()332 swiped := c.swipedWorkspace()
313 now := c.now()333 now := c.now()
334+ // Snapshot before the switcher closes: its cards fly back to the layout.
314 shots := c.snapshot(now)335 shots := c.snapshot(now)
336+ if c.switching() {
337+ c.cancelSwitcher()
338+ }
315 c.keyboard.takeBack() // a bind acts on the windows339 c.keyboard.takeBack() // a bind acts on the windows
316 effect := c.applyAction(action)340 effect := c.applyAction(action)
317 if effect.Quit {341 if effect.Quit {
M internal/core/layers.go +2 -2
@@ -88,9 +88,9 @@ func (c *Core) layerOf(id WindowID) (*screen, ports.SceneLayer, bool) {
8888
8989 // hasFullscreen reports whether a fullscreen window covers the screen. In
9090 // scroll overflow, one scrolled off by the user no longer does; nor does
91-// one shown as an overview preview.
91+// one shown as a preview (the overview or the column switcher).
9292 func hasFullscreen(sc *screen) bool {
93- return !sc.mon.ov.open && sc.mon.Current().cover() != 0
93+ return !sc.mon.previewing() && sc.mon.Current().cover() != 0
9494 }
9595
9696 // shown reports whether a layer surface is drawn. A fullscreen window is
@@ -88,9 +88,9 @@ func (c *Core) layerOf(id WindowID) (*screen, ports.SceneLayer, bool) {
88 88
89 // hasFullscreen reports whether a fullscreen window covers the screen. In89 // hasFullscreen reports whether a fullscreen window covers the screen. In
90 // scroll overflow, one scrolled off by the user no longer does; nor does90 // scroll overflow, one scrolled off by the user no longer does; nor does
91-// one shown as an overview preview.91+// one shown as a preview (the overview or the column switcher).
92 func hasFullscreen(sc *screen) bool {92 func hasFullscreen(sc *screen) bool {
93- return !sc.mon.ov.open && sc.mon.Current().cover() != 093+ return !sc.mon.previewing() && sc.mon.Current().cover() != 0
94 }94 }
95 95
96 // shown reports whether a layer surface is drawn. A fullscreen window is96 // shown reports whether a layer surface is drawn. A fullscreen window is
M internal/core/layout.go +6 -2
@@ -170,8 +170,11 @@ type Workspace struct {
170170 // maximized remembers recently maximized columns by window ID, newest
171171 // first; stack() filters removed windows at read time.
172172 maximized []WindowID
173- Focus int
174- View int
173+ // recent holds one window per column the user focused, newest first
174+ // (switcher.go); columns that went are dropped when read.
175+ recent []WindowID
176+ Focus int
177+ View int
175178 // View is the settled offset along the layout's content axis.
176179 // view is its presentation offset, in logical pixels.
177180 view slide
@@ -641,6 +644,7 @@ func (w *Workspace) unslot(n int) {
641644
642645 func (w *Workspace) RemoveWindow(id WindowID) {
643646 w.maximized = slices.DeleteFunc(w.maximized, func(v WindowID) bool { return v == id })
647+ w.recent = slices.DeleteFunc(w.recent, func(v WindowID) bool { return v == id })
644648 if w.fullscreen == id {
645649 w.fullscreen = 0
646650 }
@@ -170,8 +170,11 @@ type Workspace struct {
170 // maximized remembers recently maximized columns by window ID, newest170 // maximized remembers recently maximized columns by window ID, newest
171 // first; stack() filters removed windows at read time.171 // first; stack() filters removed windows at read time.
172 maximized []WindowID172 maximized []WindowID
173- Focus int173+ // recent holds one window per column the user focused, newest first
174- View int174+ // (switcher.go); columns that went are dropped when read.
175+ recent []WindowID
176+ Focus int
177+ View int
175 // View is the settled offset along the layout's content axis.178 // View is the settled offset along the layout's content axis.
176 // view is its presentation offset, in logical pixels.179 // view is its presentation offset, in logical pixels.
177 view slide180 view slide
@@ -641,6 +644,7 @@ func (w *Workspace) unslot(n int) {
641 644
642 func (w *Workspace) RemoveWindow(id WindowID) {645 func (w *Workspace) RemoveWindow(id WindowID) {
643 w.maximized = slices.DeleteFunc(w.maximized, func(v WindowID) bool { return v == id })646 w.maximized = slices.DeleteFunc(w.maximized, func(v WindowID) bool { return v == id })
647+ w.recent = slices.DeleteFunc(w.recent, func(v WindowID) bool { return v == id })
644 if w.fullscreen == id {648 if w.fullscreen == id {
645 w.fullscreen = 0649 w.fullscreen = 0
646 }650 }
M internal/core/monitor.go +10 -2
@@ -45,6 +45,8 @@ type Monitor struct {
4545 switchList []*Workspace
4646 // ov holds the overview selection and its Escape snapshot.
4747 ov overviewState
48+ // sw is the column switcher (switcher.go).
49+ sw switcherState
4850 // overviewOpens counts the overview's openings: a swipe sliding when
4951 // one happens is dropped (gesture.go).
5052 overviewOpens int
@@ -327,10 +329,12 @@ func (m *Monitor) AddFloating(id WindowID, width, height int) {
327329 // RemoveWindow drops the window wherever it is; focus stays on the active
328330 // workspace.
329331 func (m *Monitor) RemoveWindow(id WindowID) {
332+ heir := m.sw.heir(id)
330333 if w, _ := m.find(id); w != nil {
331334 w.RemoveWindow(id)
332335 m.normalize()
333336 }
337+ m.sw.remove(id, heir)
334338 }
335339
336340 // SetFullscreen applies a client request, in place. A client request never
@@ -423,6 +427,9 @@ func (m *Monitor) Layout() []Placement { return m.layoutInto(nil) }
423427 // so a reused dst's previous result is overwritten and only a caller done
424428 // with it may pass it. It is never the monitor's own scratch.
425429 func (m *Monitor) layoutInto(dst []Placement) []Placement {
430+ if m.switcherShown() {
431+ return m.switcherLayout(dst[:0])
432+ }
426433 if m.ov.open {
427434 return m.overviewLayout(dst[:0])
428435 }
@@ -442,10 +449,11 @@ func (m *Monitor) layoutInto(dst []Placement) []Placement {
442449 func (m *Monitor) Output() Rect { return m.template.Output }
443450
444451 // Frame is where the workspace on screen draws and takes input, in monitor
445-// coordinates: its viewport, or the whole monitor in the overview. Slides
452+// coordinates: its viewport, or the whole monitor in a preview (the
453+// overview or the column switcher). Slides
446454 // involving a sized workspace remain settled inside the current frame.
447455 func (m *Monitor) Frame() Rect {
448- if m.ov.open {
456+ if m.previewing() {
449457 return m.Output()
450458 }
451459 return m.Current().Output
@@ -45,6 +45,8 @@ type Monitor struct {
45 switchList []*Workspace45 switchList []*Workspace
46 // ov holds the overview selection and its Escape snapshot.46 // ov holds the overview selection and its Escape snapshot.
47 ov overviewState47 ov overviewState
48+ // sw is the column switcher (switcher.go).
49+ sw switcherState
48 // overviewOpens counts the overview's openings: a swipe sliding when50 // overviewOpens counts the overview's openings: a swipe sliding when
49 // one happens is dropped (gesture.go).51 // one happens is dropped (gesture.go).
50 overviewOpens int52 overviewOpens int
@@ -327,10 +329,12 @@ func (m *Monitor) AddFloating(id WindowID, width, height int) {
327 // RemoveWindow drops the window wherever it is; focus stays on the active329 // RemoveWindow drops the window wherever it is; focus stays on the active
328 // workspace.330 // workspace.
329 func (m *Monitor) RemoveWindow(id WindowID) {331 func (m *Monitor) RemoveWindow(id WindowID) {
332+ heir := m.sw.heir(id)
330 if w, _ := m.find(id); w != nil {333 if w, _ := m.find(id); w != nil {
331 w.RemoveWindow(id)334 w.RemoveWindow(id)
332 m.normalize()335 m.normalize()
333 }336 }
337+ m.sw.remove(id, heir)
334 }338 }
335 339
336 // SetFullscreen applies a client request, in place. A client request never340 // SetFullscreen applies a client request, in place. A client request never
@@ -423,6 +427,9 @@ func (m *Monitor) Layout() []Placement { return m.layoutInto(nil) }
423 // so a reused dst's previous result is overwritten and only a caller done427 // so a reused dst's previous result is overwritten and only a caller done
424 // with it may pass it. It is never the monitor's own scratch.428 // with it may pass it. It is never the monitor's own scratch.
425 func (m *Monitor) layoutInto(dst []Placement) []Placement {429 func (m *Monitor) layoutInto(dst []Placement) []Placement {
430+ if m.switcherShown() {
431+ return m.switcherLayout(dst[:0])
432+ }
426 if m.ov.open {433 if m.ov.open {
427 return m.overviewLayout(dst[:0])434 return m.overviewLayout(dst[:0])
428 }435 }
@@ -442,10 +449,11 @@ func (m *Monitor) layoutInto(dst []Placement) []Placement {
442 func (m *Monitor) Output() Rect { return m.template.Output }449 func (m *Monitor) Output() Rect { return m.template.Output }
443 450
444 // Frame is where the workspace on screen draws and takes input, in monitor451 // Frame is where the workspace on screen draws and takes input, in monitor
445-// coordinates: its viewport, or the whole monitor in the overview. Slides452+// coordinates: its viewport, or the whole monitor in a preview (the
453+// overview or the column switcher). Slides
446 // involving a sized workspace remain settled inside the current frame.454 // involving a sized workspace remain settled inside the current frame.
447 func (m *Monitor) Frame() Rect {455 func (m *Monitor) Frame() Rect {
448- if m.ov.open {456+ if m.previewing() {
449 return m.Output()457 return m.Output()
450 }458 }
451 return m.Current().Output459 return m.Current().Output
M internal/core/publish_alloc_test.go +43 -0
@@ -247,6 +247,49 @@ func TestPublishOverviewAllocations(t *testing.T) {
247247 })
248248 }
249249 }
250+ // The column switcher's cards are previews too: a held switch shows
251+ // them, and a publish while their motions run costs a plain frame.
252+ t.Run("switcher", func(t *testing.T) {
253+ c := publishRig(t, 1)
254+ c.cmdMod = ports.ModSuper
255+ c.mods = ports.ModSuper
256+ sc := c.cur()
257+ m := sc.mon
258+ m.SetMaxColumns(3)
259+ sc.arrange()
260+ c.refreshShown()
261+ ctx := context.Background()
262+ if err := c.runBind(ctx, ActionSwitchColumnNext); err != nil {
263+ t.Fatal(err)
264+ }
265+ if !c.switching() || !c.switcherTick() || !m.switcherShown() {
266+ t.Fatalf("setup: switching %v, shown %v", c.switching(), m.switcherShown())
267+ }
268+ // The rig slows every spring: the card motions run for the whole measure.
269+ step := publishStep(t, c, nil)
270+ step()
271+ if len(sc.rects) == 0 || !c.animating() || len(c.overviewReal) == 0 {
272+ t.Fatalf("setup: %d rect motions, animating %v, real layouts %d", len(sc.rects), c.animating(), len(c.overviewReal))
273+ }
274+ publish := func() {
275+ if err := c.publish(ctx); err != nil {
276+ t.Fatal(err)
277+ }
278+ select {
279+ case <-c.ch.Scenes:
280+ default:
281+ t.Fatal("no scene published")
282+ }
283+ }
284+ n := testing.AllocsPerRun(50, publish)
285+ t.Logf("switcher allocs per publish = %v", n)
286+ if len(sc.rects) == 0 {
287+ t.Fatal("card motions settled during the measure")
288+ }
289+ if n > publishAllocBudget {
290+ t.Errorf("switcher publish allocs per frame = %v, budget %d", n, publishAllocBudget)
291+ }
292+ })
250293 }
251294
252295 // TestPublishLeavingAllocations pins the cost of a frame with leaving
@@ -247,6 +247,49 @@ func TestPublishOverviewAllocations(t *testing.T) {
247 })247 })
248 }248 }
249 }249 }
250+ // The column switcher's cards are previews too: a held switch shows
251+ // them, and a publish while their motions run costs a plain frame.
252+ t.Run("switcher", func(t *testing.T) {
253+ c := publishRig(t, 1)
254+ c.cmdMod = ports.ModSuper
255+ c.mods = ports.ModSuper
256+ sc := c.cur()
257+ m := sc.mon
258+ m.SetMaxColumns(3)
259+ sc.arrange()
260+ c.refreshShown()
261+ ctx := context.Background()
262+ if err := c.runBind(ctx, ActionSwitchColumnNext); err != nil {
263+ t.Fatal(err)
264+ }
265+ if !c.switching() || !c.switcherTick() || !m.switcherShown() {
266+ t.Fatalf("setup: switching %v, shown %v", c.switching(), m.switcherShown())
267+ }
268+ // The rig slows every spring: the card motions run for the whole measure.
269+ step := publishStep(t, c, nil)
270+ step()
271+ if len(sc.rects) == 0 || !c.animating() || len(c.overviewReal) == 0 {
272+ t.Fatalf("setup: %d rect motions, animating %v, real layouts %d", len(sc.rects), c.animating(), len(c.overviewReal))
273+ }
274+ publish := func() {
275+ if err := c.publish(ctx); err != nil {
276+ t.Fatal(err)
277+ }
278+ select {
279+ case <-c.ch.Scenes:
280+ default:
281+ t.Fatal("no scene published")
282+ }
283+ }
284+ n := testing.AllocsPerRun(50, publish)
285+ t.Logf("switcher allocs per publish = %v", n)
286+ if len(sc.rects) == 0 {
287+ t.Fatal("card motions settled during the measure")
288+ }
289+ if n > publishAllocBudget {
290+ t.Errorf("switcher publish allocs per frame = %v, budget %d", n, publishAllocBudget)
291+ }
292+ })
250 }293 }
251 294
252 // TestPublishLeavingAllocations pins the cost of a frame with leaving295 // TestPublishLeavingAllocations pins the cost of a frame with leaving
M internal/core/scene.go +7 -4
@@ -22,14 +22,14 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
2222 // windows are going, and alone sizes the configures.
2323 layout, settled := sc.shown, sc.settledLayout
2424 scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)), Layers: shownLayers(sc)}
25- if !sc.mon.ov.open {
25+ if !sc.mon.previewing() {
2626 // Tiles stop at the panels; the overview lays out previews over
2727 // the whole output.
2828 u := view.usable
2929 scene.TileInset = ports.Insets{Top: u.Y, Left: u.X, Right: o.W - u.X - u.W, Bottom: o.H - u.Y - u.H}
3030 }
3131 var real map[WindowID]Placement
32- if sc.mon.ov.open {
32+ if sc.mon.previewing() {
3333 // Local to this screen's build: reused, cleared each time.
3434 if c.overviewReal == nil {
3535 c.overviewReal = make(map[WindowID]Placement)
@@ -44,7 +44,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
4444 }
4545 }
4646 scene.Dim = floatDim(layout, view, c.cfg.Floating.Dim)
47- if sc.mon.ov.open {
47+ if sc.mon.previewing() {
4848 // Darken the wallpaper around the previews.
4949 scene.Dim = c.cfg.Floating.Dim
5050 scene.DimBehind = scene.Dim > 0
@@ -56,6 +56,9 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
5656 // Frame the selection and separate numbered and named row groups.
5757 _, scene.Separators = sc.mon.overviewRows()
5858 scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...)
59+ } else if sc.mon.switcherShown() {
60+ // The switcher's cards: only the selection is framed.
61+ scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2))
5962 } else {
6063 // Only the focused output lights the focused window's lines.
6164 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), view, i == c.focusScreen)
@@ -89,7 +92,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
8992 }
9093 continue
9194 }
92- if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open && hasRoom(view, p) {
95+ if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.previewing() && hasRoom(view, p) {
9396 drawable = true
9497 if p.ID == c.pulse.id {
9598 sw.FocusEffect = pulse
@@ -22,14 +22,14 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
22 // windows are going, and alone sizes the configures.22 // windows are going, and alone sizes the configures.
23 layout, settled := sc.shown, sc.settledLayout23 layout, settled := sc.shown, sc.settledLayout
24 scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)), Layers: shownLayers(sc)}24 scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)), Layers: shownLayers(sc)}
25- if !sc.mon.ov.open {25+ if !sc.mon.previewing() {
26 // Tiles stop at the panels; the overview lays out previews over26 // Tiles stop at the panels; the overview lays out previews over
27 // the whole output.27 // the whole output.
28 u := view.usable28 u := view.usable
29 scene.TileInset = ports.Insets{Top: u.Y, Left: u.X, Right: o.W - u.X - u.W, Bottom: o.H - u.Y - u.H}29 scene.TileInset = ports.Insets{Top: u.Y, Left: u.X, Right: o.W - u.X - u.W, Bottom: o.H - u.Y - u.H}
30 }30 }
31 var real map[WindowID]Placement31 var real map[WindowID]Placement
32- if sc.mon.ov.open {32+ if sc.mon.previewing() {
33 // Local to this screen's build: reused, cleared each time.33 // Local to this screen's build: reused, cleared each time.
34 if c.overviewReal == nil {34 if c.overviewReal == nil {
35 c.overviewReal = make(map[WindowID]Placement)35 c.overviewReal = make(map[WindowID]Placement)
@@ -44,7 +44,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
44 }44 }
45 }45 }
46 scene.Dim = floatDim(layout, view, c.cfg.Floating.Dim)46 scene.Dim = floatDim(layout, view, c.cfg.Floating.Dim)
47- if sc.mon.ov.open {47+ if sc.mon.previewing() {
48 // Darken the wallpaper around the previews.48 // Darken the wallpaper around the previews.
49 scene.Dim = c.cfg.Floating.Dim49 scene.Dim = c.cfg.Floating.Dim
50 scene.DimBehind = scene.Dim > 050 scene.DimBehind = scene.Dim > 0
@@ -56,6 +56,9 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
56 // Frame the selection and separate numbered and named row groups.56 // Frame the selection and separate numbered and named row groups.
57 _, scene.Separators = sc.mon.overviewRows()57 _, scene.Separators = sc.mon.overviewRows()
58 scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...)58 scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...)
59+ } else if sc.mon.switcherShown() {
60+ // The switcher's cards: only the selection is framed.
61+ scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2))
59 } else {62 } else {
60 // Only the focused output lights the focused window's lines.63 // Only the focused output lights the focused window's lines.
61 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), view, i == c.focusScreen)64 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), view, i == c.focusScreen)
@@ -89,7 +92,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
89 }92 }
90 continue93 continue
91 }94 }
92- if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open && hasRoom(view, p) {95+ if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.previewing() && hasRoom(view, p) {
93 drawable = true96 drawable = true
94 if p.ID == c.pulse.id {97 if p.ID == c.pulse.id {
95 sw.FocusEffect = pulse98 sw.FocusEffect = pulse
M internal/core/screens.go +6 -2
@@ -64,9 +64,10 @@ func (s *screen) settled() bool { return s.mon.settled() && len(s.rects) == 0 }
6464 func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil }
6565
6666 // rectsOwner is what the screen's rect motions belong to: its workspace on
67-// screen, or nil in the overview, where the rows change under the cards.
67+// screen, or nil in a preview (the overview or the column switcher), where the
68+// workspace changes under the cards.
6869 func (s *screen) rectsOwner() *Workspace {
69- if s.mon.ov.open {
70+ if s.mon.previewing() {
7071 return nil
7172 }
7273 return s.mon.Current()
@@ -334,6 +335,9 @@ func (c *Core) removeScreen(name string) {
334335 if i < 0 {
335336 return
336337 }
338+ if c.switcher.sc == c.screens[i] {
339+ c.cancelSwitcher()
340+ }
337341 // A capture session's off-screen workspace of this output goes with it.
338342 if c.configures.cw.sc == c.screens[i] {
339343 c.configures.cw.reset()
@@ -64,9 +64,10 @@ func (s *screen) settled() bool { return s.mon.settled() && len(s.rects) == 0 }
64 func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil }64 func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil }
65 65
66 // rectsOwner is what the screen's rect motions belong to: its workspace on66 // rectsOwner is what the screen's rect motions belong to: its workspace on
67-// screen, or nil in the overview, where the rows change under the cards.67+// screen, or nil in a preview (the overview or the column switcher), where the
68+// workspace changes under the cards.
68 func (s *screen) rectsOwner() *Workspace {69 func (s *screen) rectsOwner() *Workspace {
69- if s.mon.ov.open {70+ if s.mon.previewing() {
70 return nil71 return nil
71 }72 }
72 return s.mon.Current()73 return s.mon.Current()
@@ -334,6 +335,9 @@ func (c *Core) removeScreen(name string) {
334 if i < 0 {335 if i < 0 {
335 return336 return
336 }337 }
338+ if c.switcher.sc == c.screens[i] {
339+ c.cancelSwitcher()
340+ }
337 // A capture session's off-screen workspace of this output goes with it.341 // A capture session's off-screen workspace of this output goes with it.
338 if c.configures.cw.sc == c.screens[i] {342 if c.configures.cw.sc == c.screens[i] {
339 c.configures.cw.reset()343 c.configures.cw.reset()
M internal/core/security.go +1 -0
@@ -46,6 +46,7 @@ func (c *Core) syncSecurity() bool {
4646 c.gesture = clientGesture{}
4747 c.stopAnimations()
4848 c.stopPulse()
49+ c.cancelSwitcher()
4950 if state.Protected {
5051 c.dropCaptureSessions()
5152 }
@@ -46,6 +46,7 @@ func (c *Core) syncSecurity() bool {
46 c.gesture = clientGesture{}46 c.gesture = clientGesture{}
47 c.stopAnimations()47 c.stopAnimations()
48 c.stopPulse()48 c.stopPulse()
49+ c.cancelSwitcher()
49 if state.Protected {50 if state.Protected {
50 c.dropCaptureSessions()51 c.dropCaptureSessions()
51 }52 }
A internal/core/switcher.go +402 -0
@@ -0,0 +1,402 @@
1+package core
2+
3+import (
4+ "context"
5+ "math"
6+ "slices"
7+ "time"
8+)
9+
10+// The column switcher is an Alt+Tab for the tiled columns of the workspace on
11+// screen. Each workspace keeps the columns it focused, most recent first
12+// (recent); the switcher steps through them without moving the focus, and
13+// commits the selected one when the command key is released.
14+
15+// switcherRecentMax caps a workspace's recent list.
16+const switcherRecentMax = 64
17+
18+// switcherDelay is how long the command key must stay held before the cards
19+// show: a quick tap swaps at once and flashes nothing.
20+const switcherDelay = 150 * time.Millisecond
21+
22+// switcherState is the switcher of a monitor. A window stands for each
23+// column in order, the focused column first. Nothing moves until the commit;
24+// shown is set once the cards are drawn.
25+type switcherState struct {
26+ // ws is the workspace the switcher opened on; nil when closed.
27+ ws *Workspace
28+ shown bool
29+ order []WindowID
30+ at int
31+ // sizes are the client sizes the cards are scaled from.
32+ sizes map[WindowID]Rect
33+}
34+
35+// switcherShown reports whether the switcher's cards are drawn. They belong
36+// to the workspace that was on screen when they showed: if another one is on
37+// screen now (an activation, the taskbar, an output coming back) the cards
38+// are stale and the monitor draws its workspace again.
39+func (m *Monitor) switcherShown() bool { return m.sw.shown && m.sw.ws == m.Current() }
40+
41+// previewing reports whether the monitor draws previews instead of its
42+// workspace: the overview, or the switcher's cards.
43+func (m *Monitor) previewing() bool { return m.ov.open || m.switcherShown() }
44+
45+// noteFocus records the focused column as the most recently used. It does
46+// nothing while a float or the stash has the focus, and allocates nothing
47+// when the focused column is already the latest.
48+func (w *Workspace) noteFocus() {
49+ if w.floatFocus || w.stashFocused() || w.Focus < 0 || w.Focus >= len(w.Columns) {
50+ return
51+ }
52+ col := w.Columns[w.Focus]
53+ id := col.Windows[col.Focus]
54+ if len(w.recent) > 0 && w.columnOf(w.recent[0]) == w.Focus {
55+ w.recent[0] = id
56+ return
57+ }
58+ w.recent = slices.DeleteFunc(w.recent, func(v WindowID) bool { return v == id || w.columnOf(v) == w.Focus })
59+ w.recent = slices.Insert(w.recent, 0, id)
60+ if len(w.recent) > switcherRecentMax {
61+ w.recent = w.recent[:switcherRecentMax]
62+ }
63+}
64+
65+// switchOrder lists one window per column, the focused window of each: the
66+// focused column first, then the others by recent use, then those never
67+// used by position. It is nil with fewer than two columns.
68+func (w *Workspace) switchOrder() []WindowID {
69+ n := len(w.Columns)
70+ if n < 2 || w.Focus < 0 || w.Focus >= n {
71+ return nil
72+ }
73+ seen := make([]bool, n)
74+ order := make([]WindowID, 0, n)
75+ add := func(i int) {
76+ if seen[i] {
77+ return
78+ }
79+ seen[i] = true
80+ c := w.Columns[i]
81+ order = append(order, c.Windows[c.Focus])
82+ }
83+ add(w.Focus)
84+ for _, id := range w.recent {
85+ if i := w.columnOf(id); i >= 0 {
86+ add(i)
87+ }
88+ }
89+ for i := range n {
90+ add(i)
91+ }
92+ return order
93+}
94+
95+// heir is the window of id's column that is not id, or 0: call it before id
96+// is removed, then hand it to remove.
97+func (s *switcherState) heir(id WindowID) WindowID {
98+ if s.ws == nil || !slices.Contains(s.order, id) {
99+ return 0
100+ }
101+ if i := s.ws.columnOf(id); i >= 0 {
102+ for _, v := range s.ws.Columns[i].Windows {
103+ if v != id {
104+ return v
105+ }
106+ }
107+ }
108+ return 0
109+}
110+
111+// remove drops a closed window from the open switcher. When its column
112+// still has windows (heir, from before the removal) the card stands for the
113+// column's new focused window and the selection stays; otherwise the card
114+// goes, and the selection stays on the same card (or the one that took its
115+// place). With fewer than two columns left there is nothing to switch.
116+func (s *switcherState) remove(id, heir WindowID) {
117+ if s.ws == nil {
118+ return
119+ }
120+ i := slices.Index(s.order, id)
121+ if i < 0 {
122+ return
123+ }
124+ if j := s.ws.columnOf(heir); heir != 0 && j >= 0 {
125+ col := s.ws.Columns[j]
126+ next := col.Windows[col.Focus]
127+ if size, ok := s.sizes[id]; ok {
128+ if _, has := s.sizes[next]; !has {
129+ s.sizes[next] = size
130+ }
131+ }
132+ s.order[i] = next
133+ return
134+ }
135+ s.order = slices.Delete(s.order, i, i+1)
136+ if i < s.at {
137+ s.at--
138+ }
139+ s.at = max(min(s.at, len(s.order)-1), 0)
140+ if len(s.order) < 2 {
141+ *s = switcherState{}
142+ }
143+}
144+
145+// commitSwitcher closes the switcher and focuses the selected column. The
146+// column left hands its maximization to the target. It does nothing when the
147+// workspace is not on screen any more, the window went, or the selection is
148+// the tile already focused (a float or the stash holding the focus does not
149+// count: the tile takes it back). It reports whether the selection was still
150+// valid on the workspace on screen.
151+func (m *Monitor) commitSwitcher() bool {
152+ s := m.sw
153+ m.sw = switcherState{}
154+ if s.ws == nil || s.at < 0 || s.at >= len(s.order) {
155+ return false
156+ }
157+ id, w := s.order[s.at], s.ws
158+ if w != m.Current() {
159+ return false
160+ }
161+ i := w.columnOf(id)
162+ if i < 0 {
163+ return false
164+ }
165+ if i == w.Focus && !w.floatFocus && !w.stashFocused() {
166+ return true
167+ }
168+ if i != w.Focus && w.Focus >= 0 && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth {
169+ w.transferMaximization(i)
170+ }
171+ // Activate leaves another window's fullscreen and handles dialogs.
172+ w.Activate(id)
173+ return true
174+}
175+
176+// switchDir is the direction of a switch action, 0 for any other action.
177+func switchDir(a Action) int {
178+ switch a {
179+ case ActionSwitchColumnNext:
180+ return 1
181+ case ActionSwitchColumnPrev:
182+ return -1
183+ }
184+ return 0
185+}
186+
187+// switching reports whether a switcher is open.
188+func (c *Core) switching() bool {
189+ return c.switcher.sc != nil && c.switcher.sc.mon.sw.ws != nil
190+}
191+
192+// switcherStale reports whether the open switcher belongs to a workspace
193+// that is not on its screen any more.
194+func (c *Core) switcherStale() bool {
195+ m := c.switcher.sc.mon
196+ return m.sw.ws != nil && m.sw.ws != m.Current()
197+}
198+
199+func (c *Core) stopSwitcherTimer() {
200+ if c.switcher.timerStop != nil {
201+ c.switcher.timerStop()
202+ }
203+ c.switcher.timerC, c.switcher.timerStop = nil, nil
204+}
205+
206+// cancelSwitcher closes the switcher with the focus unchanged. It only
207+// resets state: callers publish.
208+func (c *Core) cancelSwitcher() {
209+ c.stopSwitcherTimer()
210+ if sc := c.switcher.sc; sc != nil {
211+ sc.mon.sw = switcherState{}
212+ }
213+ c.switcher.sc = nil
214+}
215+
216+// commitSwitcher commits the switcher on its own screen and ends it. The
217+// keyboard follows the committed window, even if the pointer moved the focus
218+// to another output meanwhile.
219+func (c *Core) commitSwitcher() {
220+ c.stopSwitcherTimer()
221+ if sc := c.switcher.sc; sc != nil {
222+ if sc.mon.commitSwitcher() {
223+ if i := slices.Index(c.screens, sc); i >= 0 {
224+ c.focusScreen = i
225+ }
226+ }
227+ }
228+ c.switcher.sc = nil
229+}
230+
231+// switchStep steps the switcher by dir (+1 next, -1 previous), opening it
232+// on the focused screen first. With the command key held the cards show after
233+// switcherDelay and the commit waits for the release; otherwise it commits
234+// at once.
235+func (c *Core) switchStep(dir int) {
236+ sc := c.cur()
237+ if c.switching() && (c.switcher.sc != sc || c.switcherStale()) {
238+ // The focus moved to another output, or another workspace came on
239+ // screen: start over.
240+ c.cancelSwitcher()
241+ }
242+ if sc.mon.ov.open || c.drag != nil {
243+ return
244+ }
245+ m := sc.mon
246+ opened := false
247+ if !c.switching() {
248+ c.cancelSwitcher()
249+ w := m.Current()
250+ order := w.switchOrder()
251+ if order == nil {
252+ return
253+ }
254+ sizes := make(map[WindowID]Rect, len(order))
255+ for _, id := range order {
256+ if v, ok := c.configures.sent[id]; ok && v.Width > 0 && v.Height > 0 {
257+ sizes[id] = Rect{W: v.Width, H: v.Height}
258+ }
259+ }
260+ at := 1
261+ if dir < 0 {
262+ at = len(order) - 1
263+ }
264+ m.sw = switcherState{ws: w, order: order, at: at, sizes: sizes}
265+ c.switcher.sc = sc
266+ opened = true
267+ } else {
268+ n := len(m.sw.order)
269+ m.sw.at = (m.sw.at + dir + n) % n
270+ }
271+ if held := c.cmdMod != 0 && c.mods&c.cmdMod != 0; !held {
272+ c.commitSwitcher()
273+ return
274+ }
275+ if opened {
276+ c.switcher.timerC, c.switcher.timerStop = newTimer(c.opts.Clock, switcherDelay)
277+ }
278+}
279+
280+// switcherRun runs f, which changes the switcher, between a snapshot and
281+// the transition of what the screens showed, then publishes and rehits so
282+// the pointer never points at a stale window.
283+func (c *Core) switcherRun(ctx context.Context, f func()) error {
284+ now := c.now()
285+ shots := c.snapshot(now)
286+ f()
287+ c.transition(shots, now)
288+ if err := c.publish(ctx); err != nil {
289+ return err
290+ }
291+ return c.rehit(ctx)
292+}
293+
294+// switchBind runs a switch-column bind.
295+func (c *Core) switchBind(ctx context.Context, dir int) error {
296+ return c.switcherRun(ctx, func() {
297+ c.keyboard.takeBack() // a bind acts on the windows
298+ c.switchStep(dir)
299+ })
300+}
301+
302+// releaseSwitcher commits the selection once the command key is up.
303+func (c *Core) releaseSwitcher(ctx context.Context) error {
304+ return c.switcherRun(ctx, c.commitSwitcher)
305+}
306+
307+// abortSwitcher cancels the switcher and publishes the workspace again.
308+func (c *Core) abortSwitcher(ctx context.Context) error {
309+ return c.switcherRun(ctx, c.cancelSwitcher)
310+}
311+
312+// switcherTick runs when the delay passed with the command key held: the
313+// cards show. It reports whether the scene changed.
314+func (c *Core) switcherTick() bool {
315+ c.switcher.timerC, c.switcher.timerStop = nil, nil
316+ sc := c.switcher.sc
317+ if !c.switching() {
318+ c.switcher.sc = nil
319+ return false
320+ }
321+ m := sc.mon
322+ if m.sw.ws != m.Current() {
323+ c.cancelSwitcher()
324+ return true
325+ }
326+ now := c.now()
327+ shots := c.snapshot(now)
328+ m.sw.shown = true
329+ c.transition(shots, now)
330+ return true
331+}
332+
333+// switcherClick takes a press while the switcher is open. Before the cards
334+// show it cancels and lets the press through (handled false). On the cards
335+// a press on one commits it, anywhere else cancels; the press and its
336+// release are consumed either way.
337+func (c *Core) switcherClick(ctx context.Context, button uint32) (handled bool, err error) {
338+ sc := c.switcher.sc
339+ if !c.switching() {
340+ return false, nil
341+ }
342+ if !sc.mon.sw.shown {
343+ c.cancelSwitcher()
344+ return false, nil
345+ }
346+ err = c.switcherRun(ctx, func() {
347+ id := overviewIn(sc.shownLayout(), c.cursorX-float64(sc.x), c.cursorY-float64(sc.y))
348+ if i := slices.Index(sc.mon.sw.order, id); id != 0 && i >= 0 {
349+ sc.mon.sw.at = i
350+ c.commitSwitcher()
351+ } else {
352+ c.cancelSwitcher()
353+ }
354+ c.buttons[button], c.swallow[button] = true, true
355+ })
356+ return true, err
357+}
358+
359+// switcherLayout lays out the shown switcher: one card per column in
360+// switchOrder, equal boxes in a row centered in the overview area, every
361+// window scaled into its box (never up). Like the overview, the selected
362+// card gets Focused (so its configure is Activated) while the keyboard stays
363+// on the real focus. Every other window is hidden.
364+func (m *Monitor) switcherLayout(dst []Placement) []Placement {
365+ sw := &m.sw
366+ w := sw.ws
367+ area := w.overviewArea()
368+ n := len(sw.order)
369+ for ws := range m.all() {
370+ start := len(dst)
371+ dst = ws.appendHidden(dst, true)
372+ kept := slices.DeleteFunc(dst[start:], func(p Placement) bool { return slices.Contains(sw.order, p.ID) })
373+ dst = dst[:start+len(kept)]
374+ }
375+ if n == 0 {
376+ return dst
377+ }
378+ gap := max(1, area.W*2/100)
379+ boxW := max(min(area.W/5, (area.W*9/10-(n-1)*gap)/n), 1)
380+ boxH := max(boxW*area.H/max(area.W, 1), 1)
381+ x0 := area.X + (area.W-(n*boxW+(n-1)*gap))/2
382+ y0 := area.Y + (area.H-boxH)/2
383+ for k, id := range sw.order {
384+ s := sw.sizes[id]
385+ if s.W <= 0 || s.H <= 0 {
386+ s = Rect{W: w.Usable.W, H: w.Usable.H}
387+ }
388+ z := 1.0
389+ if s.W > 0 && s.H > 0 {
390+ z = math.Min(1, math.Min(float64(boxW)/float64(s.W), float64(boxH)/float64(s.H)))
391+ }
392+ rw, rh := int(math.Round(float64(s.W)*z)), int(math.Round(float64(s.H)*z))
393+ bx := x0 + k*(boxW+gap)
394+ dst = append(dst, Placement{
395+ ID: id,
396+ Rect: Rect{X: bx + (boxW-rw)/2, Y: y0 + (boxH-rh)/2, W: rw, H: rh},
397+ Preview: z,
398+ Focused: k == sw.at,
399+ })
400+ }
401+ return dst
402+}
@@ -0,0 +1,402 @@
1+package core
2+
3+import (
4+ "context"
5+ "math"
6+ "slices"
7+ "time"
8+)
9+
10+// The column switcher is an Alt+Tab for the tiled columns of the workspace on
11+// screen. Each workspace keeps the columns it focused, most recent first
12+// (recent); the switcher steps through them without moving the focus, and
13+// commits the selected one when the command key is released.
14+
15+// switcherRecentMax caps a workspace's recent list.
16+const switcherRecentMax = 64
17+
18+// switcherDelay is how long the command key must stay held before the cards
19+// show: a quick tap swaps at once and flashes nothing.
20+const switcherDelay = 150 * time.Millisecond
21+
22+// switcherState is the switcher of a monitor. A window stands for each
23+// column in order, the focused column first. Nothing moves until the commit;
24+// shown is set once the cards are drawn.
25+type switcherState struct {
26+ // ws is the workspace the switcher opened on; nil when closed.
27+ ws *Workspace
28+ shown bool
29+ order []WindowID
30+ at int
31+ // sizes are the client sizes the cards are scaled from.
32+ sizes map[WindowID]Rect
33+}
34+
35+// switcherShown reports whether the switcher's cards are drawn. They belong
36+// to the workspace that was on screen when they showed: if another one is on
37+// screen now (an activation, the taskbar, an output coming back) the cards
38+// are stale and the monitor draws its workspace again.
39+func (m *Monitor) switcherShown() bool { return m.sw.shown && m.sw.ws == m.Current() }
40+
41+// previewing reports whether the monitor draws previews instead of its
42+// workspace: the overview, or the switcher's cards.
43+func (m *Monitor) previewing() bool { return m.ov.open || m.switcherShown() }
44+
45+// noteFocus records the focused column as the most recently used. It does
46+// nothing while a float or the stash has the focus, and allocates nothing
47+// when the focused column is already the latest.
48+func (w *Workspace) noteFocus() {
49+ if w.floatFocus || w.stashFocused() || w.Focus < 0 || w.Focus >= len(w.Columns) {
50+ return
51+ }
52+ col := w.Columns[w.Focus]
53+ id := col.Windows[col.Focus]
54+ if len(w.recent) > 0 && w.columnOf(w.recent[0]) == w.Focus {
55+ w.recent[0] = id
56+ return
57+ }
58+ w.recent = slices.DeleteFunc(w.recent, func(v WindowID) bool { return v == id || w.columnOf(v) == w.Focus })
59+ w.recent = slices.Insert(w.recent, 0, id)
60+ if len(w.recent) > switcherRecentMax {
61+ w.recent = w.recent[:switcherRecentMax]
62+ }
63+}
64+
65+// switchOrder lists one window per column, the focused window of each: the
66+// focused column first, then the others by recent use, then those never
67+// used by position. It is nil with fewer than two columns.
68+func (w *Workspace) switchOrder() []WindowID {
69+ n := len(w.Columns)
70+ if n < 2 || w.Focus < 0 || w.Focus >= n {
71+ return nil
72+ }
73+ seen := make([]bool, n)
74+ order := make([]WindowID, 0, n)
75+ add := func(i int) {
76+ if seen[i] {
77+ return
78+ }
79+ seen[i] = true
80+ c := w.Columns[i]
81+ order = append(order, c.Windows[c.Focus])
82+ }
83+ add(w.Focus)
84+ for _, id := range w.recent {
85+ if i := w.columnOf(id); i >= 0 {
86+ add(i)
87+ }
88+ }
89+ for i := range n {
90+ add(i)
91+ }
92+ return order
93+}
94+
95+// heir is the window of id's column that is not id, or 0: call it before id
96+// is removed, then hand it to remove.
97+func (s *switcherState) heir(id WindowID) WindowID {
98+ if s.ws == nil || !slices.Contains(s.order, id) {
99+ return 0
100+ }
101+ if i := s.ws.columnOf(id); i >= 0 {
102+ for _, v := range s.ws.Columns[i].Windows {
103+ if v != id {
104+ return v
105+ }
106+ }
107+ }
108+ return 0
109+}
110+
111+// remove drops a closed window from the open switcher. When its column
112+// still has windows (heir, from before the removal) the card stands for the
113+// column's new focused window and the selection stays; otherwise the card
114+// goes, and the selection stays on the same card (or the one that took its
115+// place). With fewer than two columns left there is nothing to switch.
116+func (s *switcherState) remove(id, heir WindowID) {
117+ if s.ws == nil {
118+ return
119+ }
120+ i := slices.Index(s.order, id)
121+ if i < 0 {
122+ return
123+ }
124+ if j := s.ws.columnOf(heir); heir != 0 && j >= 0 {
125+ col := s.ws.Columns[j]
126+ next := col.Windows[col.Focus]
127+ if size, ok := s.sizes[id]; ok {
128+ if _, has := s.sizes[next]; !has {
129+ s.sizes[next] = size
130+ }
131+ }
132+ s.order[i] = next
133+ return
134+ }
135+ s.order = slices.Delete(s.order, i, i+1)
136+ if i < s.at {
137+ s.at--
138+ }
139+ s.at = max(min(s.at, len(s.order)-1), 0)
140+ if len(s.order) < 2 {
141+ *s = switcherState{}
142+ }
143+}
144+
145+// commitSwitcher closes the switcher and focuses the selected column. The
146+// column left hands its maximization to the target. It does nothing when the
147+// workspace is not on screen any more, the window went, or the selection is
148+// the tile already focused (a float or the stash holding the focus does not
149+// count: the tile takes it back). It reports whether the selection was still
150+// valid on the workspace on screen.
151+func (m *Monitor) commitSwitcher() bool {
152+ s := m.sw
153+ m.sw = switcherState{}
154+ if s.ws == nil || s.at < 0 || s.at >= len(s.order) {
155+ return false
156+ }
157+ id, w := s.order[s.at], s.ws
158+ if w != m.Current() {
159+ return false
160+ }
161+ i := w.columnOf(id)
162+ if i < 0 {
163+ return false
164+ }
165+ if i == w.Focus && !w.floatFocus && !w.stashFocused() {
166+ return true
167+ }
168+ if i != w.Focus && w.Focus >= 0 && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth {
169+ w.transferMaximization(i)
170+ }
171+ // Activate leaves another window's fullscreen and handles dialogs.
172+ w.Activate(id)
173+ return true
174+}
175+
176+// switchDir is the direction of a switch action, 0 for any other action.
177+func switchDir(a Action) int {
178+ switch a {
179+ case ActionSwitchColumnNext:
180+ return 1
181+ case ActionSwitchColumnPrev:
182+ return -1
183+ }
184+ return 0
185+}
186+
187+// switching reports whether a switcher is open.
188+func (c *Core) switching() bool {
189+ return c.switcher.sc != nil && c.switcher.sc.mon.sw.ws != nil
190+}
191+
192+// switcherStale reports whether the open switcher belongs to a workspace
193+// that is not on its screen any more.
194+func (c *Core) switcherStale() bool {
195+ m := c.switcher.sc.mon
196+ return m.sw.ws != nil && m.sw.ws != m.Current()
197+}
198+
199+func (c *Core) stopSwitcherTimer() {
200+ if c.switcher.timerStop != nil {
201+ c.switcher.timerStop()
202+ }
203+ c.switcher.timerC, c.switcher.timerStop = nil, nil
204+}
205+
206+// cancelSwitcher closes the switcher with the focus unchanged. It only
207+// resets state: callers publish.
208+func (c *Core) cancelSwitcher() {
209+ c.stopSwitcherTimer()
210+ if sc := c.switcher.sc; sc != nil {
211+ sc.mon.sw = switcherState{}
212+ }
213+ c.switcher.sc = nil
214+}
215+
216+// commitSwitcher commits the switcher on its own screen and ends it. The
217+// keyboard follows the committed window, even if the pointer moved the focus
218+// to another output meanwhile.
219+func (c *Core) commitSwitcher() {
220+ c.stopSwitcherTimer()
221+ if sc := c.switcher.sc; sc != nil {
222+ if sc.mon.commitSwitcher() {
223+ if i := slices.Index(c.screens, sc); i >= 0 {
224+ c.focusScreen = i
225+ }
226+ }
227+ }
228+ c.switcher.sc = nil
229+}
230+
231+// switchStep steps the switcher by dir (+1 next, -1 previous), opening it
232+// on the focused screen first. With the command key held the cards show after
233+// switcherDelay and the commit waits for the release; otherwise it commits
234+// at once.
235+func (c *Core) switchStep(dir int) {
236+ sc := c.cur()
237+ if c.switching() && (c.switcher.sc != sc || c.switcherStale()) {
238+ // The focus moved to another output, or another workspace came on
239+ // screen: start over.
240+ c.cancelSwitcher()
241+ }
242+ if sc.mon.ov.open || c.drag != nil {
243+ return
244+ }
245+ m := sc.mon
246+ opened := false
247+ if !c.switching() {
248+ c.cancelSwitcher()
249+ w := m.Current()
250+ order := w.switchOrder()
251+ if order == nil {
252+ return
253+ }
254+ sizes := make(map[WindowID]Rect, len(order))
255+ for _, id := range order {
256+ if v, ok := c.configures.sent[id]; ok && v.Width > 0 && v.Height > 0 {
257+ sizes[id] = Rect{W: v.Width, H: v.Height}
258+ }
259+ }
260+ at := 1
261+ if dir < 0 {
262+ at = len(order) - 1
263+ }
264+ m.sw = switcherState{ws: w, order: order, at: at, sizes: sizes}
265+ c.switcher.sc = sc
266+ opened = true
267+ } else {
268+ n := len(m.sw.order)
269+ m.sw.at = (m.sw.at + dir + n) % n
270+ }
271+ if held := c.cmdMod != 0 && c.mods&c.cmdMod != 0; !held {
272+ c.commitSwitcher()
273+ return
274+ }
275+ if opened {
276+ c.switcher.timerC, c.switcher.timerStop = newTimer(c.opts.Clock, switcherDelay)
277+ }
278+}
279+
280+// switcherRun runs f, which changes the switcher, between a snapshot and
281+// the transition of what the screens showed, then publishes and rehits so
282+// the pointer never points at a stale window.
283+func (c *Core) switcherRun(ctx context.Context, f func()) error {
284+ now := c.now()
285+ shots := c.snapshot(now)
286+ f()
287+ c.transition(shots, now)
288+ if err := c.publish(ctx); err != nil {
289+ return err
290+ }
291+ return c.rehit(ctx)
292+}
293+
294+// switchBind runs a switch-column bind.
295+func (c *Core) switchBind(ctx context.Context, dir int) error {
296+ return c.switcherRun(ctx, func() {
297+ c.keyboard.takeBack() // a bind acts on the windows
298+ c.switchStep(dir)
299+ })
300+}
301+
302+// releaseSwitcher commits the selection once the command key is up.
303+func (c *Core) releaseSwitcher(ctx context.Context) error {
304+ return c.switcherRun(ctx, c.commitSwitcher)
305+}
306+
307+// abortSwitcher cancels the switcher and publishes the workspace again.
308+func (c *Core) abortSwitcher(ctx context.Context) error {
309+ return c.switcherRun(ctx, c.cancelSwitcher)
310+}
311+
312+// switcherTick runs when the delay passed with the command key held: the
313+// cards show. It reports whether the scene changed.
314+func (c *Core) switcherTick() bool {
315+ c.switcher.timerC, c.switcher.timerStop = nil, nil
316+ sc := c.switcher.sc
317+ if !c.switching() {
318+ c.switcher.sc = nil
319+ return false
320+ }
321+ m := sc.mon
322+ if m.sw.ws != m.Current() {
323+ c.cancelSwitcher()
324+ return true
325+ }
326+ now := c.now()
327+ shots := c.snapshot(now)
328+ m.sw.shown = true
329+ c.transition(shots, now)
330+ return true
331+}
332+
333+// switcherClick takes a press while the switcher is open. Before the cards
334+// show it cancels and lets the press through (handled false). On the cards
335+// a press on one commits it, anywhere else cancels; the press and its
336+// release are consumed either way.
337+func (c *Core) switcherClick(ctx context.Context, button uint32) (handled bool, err error) {
338+ sc := c.switcher.sc
339+ if !c.switching() {
340+ return false, nil
341+ }
342+ if !sc.mon.sw.shown {
343+ c.cancelSwitcher()
344+ return false, nil
345+ }
346+ err = c.switcherRun(ctx, func() {
347+ id := overviewIn(sc.shownLayout(), c.cursorX-float64(sc.x), c.cursorY-float64(sc.y))
348+ if i := slices.Index(sc.mon.sw.order, id); id != 0 && i >= 0 {
349+ sc.mon.sw.at = i
350+ c.commitSwitcher()
351+ } else {
352+ c.cancelSwitcher()
353+ }
354+ c.buttons[button], c.swallow[button] = true, true
355+ })
356+ return true, err
357+}
358+
359+// switcherLayout lays out the shown switcher: one card per column in
360+// switchOrder, equal boxes in a row centered in the overview area, every
361+// window scaled into its box (never up). Like the overview, the selected
362+// card gets Focused (so its configure is Activated) while the keyboard stays
363+// on the real focus. Every other window is hidden.
364+func (m *Monitor) switcherLayout(dst []Placement) []Placement {
365+ sw := &m.sw
366+ w := sw.ws
367+ area := w.overviewArea()
368+ n := len(sw.order)
369+ for ws := range m.all() {
370+ start := len(dst)
371+ dst = ws.appendHidden(dst, true)
372+ kept := slices.DeleteFunc(dst[start:], func(p Placement) bool { return slices.Contains(sw.order, p.ID) })
373+ dst = dst[:start+len(kept)]
374+ }
375+ if n == 0 {
376+ return dst
377+ }
378+ gap := max(1, area.W*2/100)
379+ boxW := max(min(area.W/5, (area.W*9/10-(n-1)*gap)/n), 1)
380+ boxH := max(boxW*area.H/max(area.W, 1), 1)
381+ x0 := area.X + (area.W-(n*boxW+(n-1)*gap))/2
382+ y0 := area.Y + (area.H-boxH)/2
383+ for k, id := range sw.order {
384+ s := sw.sizes[id]
385+ if s.W <= 0 || s.H <= 0 {
386+ s = Rect{W: w.Usable.W, H: w.Usable.H}
387+ }
388+ z := 1.0
389+ if s.W > 0 && s.H > 0 {
390+ z = math.Min(1, math.Min(float64(boxW)/float64(s.W), float64(boxH)/float64(s.H)))
391+ }
392+ rw, rh := int(math.Round(float64(s.W)*z)), int(math.Round(float64(s.H)*z))
393+ bx := x0 + k*(boxW+gap)
394+ dst = append(dst, Placement{
395+ ID: id,
396+ Rect: Rect{X: bx + (boxW-rw)/2, Y: y0 + (boxH-rh)/2, W: rw, H: rh},
397+ Preview: z,
398+ Focused: k == sw.at,
399+ })
400+ }
401+ return dst
402+}
A internal/core/switcher_test.go +1451 -0
@@ -0,0 +1,1451 @@
1+package core
2+
3+import (
4+ "context"
5+ "math"
6+ "slices"
7+ "testing"
8+ "time"
9+
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11+ "github.com/bnema/neferwl/internal/ports"
12+)
13+
14+// threeColumns is a workspace of three one-window columns, windows 1..3.
15+func threeColumns() *Workspace {
16+ w := workspace()
17+ w.SetMaxColumns(3)
18+ for id := WindowID(1); id <= 3; id++ {
19+ w.AddWindow(id)
20+ }
21+ return w
22+}
23+
24+func focusNote(w *Workspace, id WindowID) {
25+ w.FocusID(id)
26+ w.noteFocus()
27+}
28+
29+func TestMRUOrder(t *testing.T) {
30+ tests := []struct {
31+ name string
32+ run func(w *Workspace)
33+ want []WindowID
34+ }{
35+ {"follows focus", func(w *Workspace) {
36+ // Columns 0, 2, 1 in turn: the last one used goes first.
37+ focusNote(w, 1)
38+ focusNote(w, 3)
39+ focusNote(w, 2)
40+ }, []WindowID{2, 3, 1}},
41+ {"back to the oldest", func(w *Workspace) {
42+ focusNote(w, 1)
43+ focusNote(w, 3)
44+ focusNote(w, 2)
45+ focusNote(w, 1)
46+ }, []WindowID{1, 2, 3}},
47+ {"never focused follow by position", func(w *Workspace) {
48+ focusNote(w, 3)
49+ }, []WindowID{3, 1, 2}},
50+ {"closed windows are pruned", func(w *Workspace) {
51+ focusNote(w, 1)
52+ focusNote(w, 3)
53+ focusNote(w, 2)
54+ w.RemoveWindow(3)
55+ }, []WindowID{2, 1}},
56+ {"a moved column keeps its rank", func(w *Workspace) {
57+ focusNote(w, 1)
58+ focusNote(w, 3)
59+ focusNote(w, 2)
60+ w.MoveColumn(1)
61+ w.noteFocus()
62+ }, []WindowID{2, 3, 1}},
63+ }
64+ for _, tt := range tests {
65+ t.Run(tt.name, func(t *testing.T) {
66+ w := threeColumns()
67+ tt.run(w)
68+ if got := w.switchOrder(); !slices.Equal(got, tt.want) {
69+ t.Fatalf("order %v, want %v", got, tt.want)
70+ }
71+ // recent never keeps a closed window nor two of one column.
72+ seen := map[int]bool{}
73+ for _, id := range w.recent {
74+ i := w.columnOf(id)
75+ if i < 0 || seen[i] {
76+ t.Fatalf("recent %v keeps window %d (column %d)", w.recent, id, i)
77+ }
78+ seen[i] = true
79+ }
80+ })
81+ }
82+}
83+
84+func TestMRUOneWindowPerColumn(t *testing.T) {
85+ w := threeColumns()
86+ w.AddWindow(4)
87+ w.FocusColumn(-1)
88+ w.ConsumeOrExpel(-1)
89+ // Whatever the columns became, each appears once.
90+ for range 3 {
91+ w.noteFocus()
92+ w.FocusColumn(-1)
93+ }
94+ order := w.switchOrder()
95+ if len(order) != len(w.Columns) {
96+ t.Fatalf("order %v for %d columns", order, len(w.Columns))
97+ }
98+ seen := map[int]bool{}
99+ for _, id := range order {
100+ if seen[w.columnOf(id)] {
101+ t.Fatalf("order %v lists a column twice", order)
102+ }
103+ seen[w.columnOf(id)] = true
104+ }
105+}
106+
107+func TestMRUSwitchOrderNeedsTwoColumns(t *testing.T) {
108+ w := workspace()
109+ if w.switchOrder() != nil {
110+ t.Fatal("order of an empty workspace")
111+ }
112+ w.AddWindow(1)
113+ w.noteFocus()
114+ if w.switchOrder() != nil {
115+ t.Fatal("order of one column")
116+ }
117+}
118+
119+func TestMRUCapped(t *testing.T) {
120+ w := workspace()
121+ w.SetMaxColumns(3)
122+ for id := WindowID(1); id <= switcherRecentMax+10; id++ {
123+ w.AddWindow(id)
124+ w.noteFocus()
125+ }
126+ if len(w.recent) != switcherRecentMax {
127+ t.Fatalf("recent holds %d", len(w.recent))
128+ }
129+}
130+
131+func TestMRUNoteFocusDoesNotAllocateWhenSettled(t *testing.T) {
132+ w := threeColumns()
133+ focusNote(w, 2)
134+ if n := testing.AllocsPerRun(100, w.noteFocus); n != 0 {
135+ t.Fatalf("noteFocus allocates %v", n)
136+ }
137+ // A focus change inside the latest column updates its entry in place.
138+ w.AddWindow(4)
139+ w.ConsumeOrExpel(-1)
140+ w.FocusID(2)
141+ w.noteFocus()
142+ if len(w.recent) == 0 || w.recent[0] != 2 {
143+ t.Fatalf("recent %v, want window 2 first", w.recent)
144+ }
145+ n := len(w.recent)
146+ col := w.Columns[w.Focus]
147+ if len(col.Windows) < 2 {
148+ t.Fatalf("setup: column %v has one window", col.Windows)
149+ }
150+ other := col.Windows[0]
151+ if other == 2 {
152+ other = col.Windows[1]
153+ }
154+ w.FocusID(other)
155+ if got := testing.AllocsPerRun(100, w.noteFocus); got != 0 {
156+ t.Fatalf("noteFocus allocates %v after a same-column change", got)
157+ }
158+ if len(w.recent) != n || w.recent[0] != other {
159+ t.Fatalf("recent %v, want window %d first in place of 2 (%d entries)", w.recent, other, n)
160+ }
161+}
162+
163+// Browsing the overview focuses nothing: the selection moving to another
164+// column, then Escape, leave the recent order as it was.
165+func TestOverviewBrowsingKeepsMRU(t *testing.T) {
166+ c, _, _ := switcherCore(t, 3)
167+ m := c.cur().mon
168+ w := m.Current()
169+ focusNote(w, 1)
170+ focusNote(w, 3)
171+ focusNote(w, 2)
172+ indicatorScene(t, c)
173+ order, recent := w.switchOrder(), slices.Clone(w.recent)
174+ focus := switcherFocus(c)
175+ m.ToggleOverview()
176+ m.OverviewMove(1, 0)
177+ m.OverviewMove(1, 0)
178+ if switcherFocus(c) == focus {
179+ t.Fatal("setup: the overview selection is still on the focused window")
180+ }
181+ indicatorScene(t, c)
182+ // Cancelling would note the original column again: look while browsing.
183+ if !slices.Equal(w.recent, recent) {
184+ t.Fatalf("recent %v while browsing, was %v", w.recent, recent)
185+ }
186+ m.CancelOverview()
187+ indicatorScene(t, c)
188+ if got := w.switchOrder(); !slices.Equal(got, order) || !slices.Equal(w.recent, recent) {
189+ t.Fatalf("order %v recent %v, were %v %v", got, w.recent, order, recent)
190+ }
191+ if switcherFocus(c) != focus {
192+ t.Fatalf("focus %d, want %d", switcherFocus(c), focus)
193+ }
194+}
195+
196+// switcherBinds returns the binds of the switcher tests: the defaults, a
197+// fullscreen bind to interrupt a switch and F9, a switch with no Cmd.
198+func switcherBinds() map[string]string {
199+ return map[string]string{
200+ "Cmd+Tab": string(ActionSwitchColumnNext),
201+ "Cmd+Shift+Tab": string(ActionSwitchColumnPrev),
202+ "Cmd+f": string(ActionToggleFullscreen),
203+ "F9": string(ActionSwitchColumnNext),
204+ }
205+}
206+
207+const (
208+ keyTab = 15
209+ keySuper = 133
210+)
211+
212+// switcherCore is indicatorCore with the switcher binds, up to three
213+// columns on screen and n one-window columns (windows 1..n, n focused),
214+// published once so the configures carry the windows' sizes.
215+func switcherCore(t *testing.T, n int) (*Core, *indicatorClock, <-chan ports.ClientCommand) {
216+ t.Helper()
217+ commands := make(chan ports.ClientCommand, 64)
218+ c, ic := indicatorCoreWith(t, commands)
219+ cfg := c.cfg
220+ cfg.Layout.MaxColumns = 3
221+ cfg.Binds = switcherBinds()
222+ if err := c.apply(cfg); err != nil {
223+ t.Fatal(err)
224+ }
225+ for id := WindowID(1); id <= WindowID(n); id++ {
226+ c.cur().mon.AddWindow(id)
227+ }
228+ indicatorScene(t, c)
229+ return c, ic, commands
230+}
231+
232+// requireClosed fails unless no switcher is open: no monitor holds one, and
233+// the core has neither a switcher screen nor a timer.
234+func requireClosed(t *testing.T, c *Core) {
235+ t.Helper()
236+ for _, sc := range c.screens {
237+ if sc.mon.sw.ws != nil || sc.mon.sw.shown {
238+ t.Fatalf("screen %q still holds a switcher: %+v", sc.name(), sc.mon.sw)
239+ }
240+ }
241+ if c.switcher.sc != nil || c.switcher.timerC != nil || c.switcher.timerStop != nil {
242+ t.Fatalf("switcher screen %v, timer %v", c.switcher.sc != nil, c.switcher.timerC != nil)
243+ }
244+}
245+
246+func tab(mods ports.Mods, pressed bool) ports.KeyEvent {
247+ return ports.KeyEvent{Keysym: "Tab", Keycode: keyTab, Mods: mods, Pressed: pressed}
248+}
249+
250+func shiftTab(pressed bool) ports.KeyEvent {
251+ return ports.KeyEvent{Keysym: "ISO_Left_Tab", Base: "Tab", Keycode: keyTab, Mods: ports.ModSuper | ports.ModShift, Pressed: pressed}
252+}
253+
254+func superKey(mods ports.Mods, pressed bool) ports.KeyEvent {
255+ return ports.KeyEvent{Keysym: "Super_L", Keycode: keySuper, Mods: mods, Pressed: pressed}
256+}
257+
258+func bind(t *testing.T, c *Core, a Action) {
259+ t.Helper()
260+ if err := c.runBind(context.Background(), a); err != nil {
261+ t.Fatal(err)
262+ }
263+}
264+
265+func sendKeys(t *testing.T, c *Core, keys ...ports.KeyEvent) {
266+ t.Helper()
267+ for _, k := range keys {
268+ if err := c.keyEvent(context.Background(), k); err != nil {
269+ t.Fatal(err)
270+ }
271+ }
272+}
273+
274+// lastScene returns the latest published scene.
275+func lastScene(t *testing.T, c *Core) ports.Scene {
276+ t.Helper()
277+ var s []ports.Scene
278+ for len(c.ch.Scenes) > 0 {
279+ s = <-c.ch.Scenes
280+ }
281+ if s == nil {
282+ t.Fatal("no scene published")
283+ }
284+ return s[0]
285+}
286+
287+func switcherFocus(c *Core) WindowID {
288+ id, _ := c.cur().mon.Focused()
289+ return id
290+}
291+
292+// forwarded drains the commands and returns the forwarded keys.
293+func forwarded(ch <-chan ports.ClientCommand) []ports.ForwardKey {
294+ var out []ports.ForwardKey
295+ for len(ch) > 0 {
296+ if v, ok := (<-ch).(ports.ForwardKey); ok {
297+ out = append(out, v)
298+ }
299+ }
300+ return out
301+}
302+
303+func scenePreviews(s ports.Scene) []ports.SceneWindow {
304+ var out []ports.SceneWindow
305+ for _, w := range s.Windows {
306+ if w.Preview > 0 {
307+ out = append(out, w)
308+ }
309+ }
310+ return out
311+}
312+
313+// --- P2.1: commit ---
314+
315+func switcherMonitor(overflow Overflow, n int) (*Monitor, *Workspace) {
316+ m := NewMonitor()
317+ m.SetOutput(300, 200)
318+ m.SetMaxColumns(3)
319+ m.SetOverflow(overflow)
320+ for id := WindowID(1); id <= WindowID(n); id++ {
321+ m.AddWindow(id)
322+ }
323+ return m, m.Current()
324+}
325+
326+func openSwitcher(m *Monitor, at int) {
327+ w := m.Current()
328+ m.sw = switcherState{ws: w, order: w.switchOrder(), at: at}
329+}
330+
331+func TestSwitchCommitFocusesSelectedColumn(t *testing.T) {
332+ m, w := switcherMonitor(OverflowScroll, 3)
333+ openSwitcher(m, 1)
334+ want := m.sw.order[1]
335+ m.commitSwitcher()
336+ if id, _ := w.Focused(); id != want || m.sw.ws != nil {
337+ t.Fatalf("focus %d (want %d), switcher open %v", id, want, m.sw.ws != nil)
338+ }
339+}
340+
341+func TestSwitchCommitTransfersMaximization(t *testing.T) {
342+ for _, overflow := range []Overflow{OverflowCascade, OverflowFixed, OverflowScroll} {
343+ t.Run(string(overflow), func(t *testing.T) {
344+ m, w := switcherMonitor(overflow, 2)
345+ a, b := w.Focus, 1-w.Focus
346+ w.FocusID(w.Columns[a].Windows[0])
347+ w.ToggleFullWidth()
348+ w.noteFocus()
349+ if !w.Columns[a].FullWidth {
350+ t.Fatal("setup: source not maximized")
351+ }
352+ openSwitcher(m, 1)
353+ m.commitSwitcher()
354+ if w.Focus != b || !w.Columns[b].FullWidth || w.Columns[a].FullWidth {
355+ t.Fatalf("focus %d, FullWidth source %v target %v", w.Focus, w.Columns[a].FullWidth, w.Columns[b].FullWidth)
356+ }
357+ if w.policy().equalCells && !w.hiddenByMaximized(a) {
358+ t.Fatal("source not hidden by the maximized target")
359+ }
360+ })
361+ }
362+}
363+
364+func TestSwitchCommitLeavesPinnedFullscreen(t *testing.T) {
365+ m, w := switcherMonitor(OverflowFixed, 2)
366+ src := w.Columns[w.Focus].Windows[0]
367+ w.SetFullscreen(src, true)
368+ if w.fullscreen != src {
369+ t.Fatal("setup: no fullscreen")
370+ }
371+ openSwitcher(m, 1)
372+ target := m.sw.order[1]
373+ m.commitSwitcher()
374+ if w.fullscreen != 0 {
375+ t.Fatalf("fullscreen %d kept", w.fullscreen)
376+ }
377+ if id, _ := w.Focused(); id != target {
378+ t.Fatalf("focus %d, want %d", id, target)
379+ }
380+}
381+
382+func TestSwitchCommitIgnoresGoneWindowAndOtherWorkspace(t *testing.T) {
383+ m, w := switcherMonitor(OverflowScroll, 3)
384+ before, _ := w.Focused()
385+ openSwitcher(m, 1)
386+ m.sw.order[1] = 99 // not in the workspace any more
387+ m.commitSwitcher()
388+ if id, _ := w.Focused(); id != before || m.sw.ws != nil {
389+ t.Fatalf("focus %d, want %d; open %v", id, before, m.sw.ws != nil)
390+ }
391+ openSwitcher(m, 1)
392+ m.sw.ws = &Workspace{}
393+ m.commitSwitcher()
394+ if id, _ := w.Focused(); id != before || m.sw.ws != nil {
395+ t.Fatalf("focus %d for a foreign workspace, want %d", id, before)
396+ }
397+}
398+
399+// --- P2.2: actions and step ---
400+
401+func TestSwitchStepOpensOnPreviousColumnAndCycles(t *testing.T) {
402+ c, ic, _ := switcherCore(t, 3)
403+ c.mods = ports.ModSuper
404+ before := switcherFocus(c)
405+ if err := c.runBind(context.Background(), ActionSwitchColumnNext); err != nil {
406+ t.Fatal(err)
407+ }
408+ sw := &c.cur().mon.sw
409+ if sw.ws == nil || sw.at != 1 || switcherFocus(c) != before {
410+ t.Fatalf("open %v at %d focus %d (was %d)", sw.ws != nil, sw.at, switcherFocus(c), before)
411+ }
412+ if len(ic.timers) != 1 || ic.timers[0] != switcherDelay {
413+ t.Fatalf("timers %v, want one of %v", ic.timers, switcherDelay)
414+ }
415+ bind(t, c, ActionSwitchColumnNext)
416+ if sw.at != 2 {
417+ t.Fatalf("at %d", sw.at)
418+ }
419+ bind(t, c, ActionSwitchColumnNext)
420+ if sw.at != 0 {
421+ t.Fatalf("at %d, want wrap to 0", sw.at)
422+ }
423+ bind(t, c, ActionSwitchColumnPrev)
424+ if sw.at != 2 {
425+ t.Fatalf("at %d, want wrap to 2", sw.at)
426+ }
427+ bind(t, c, ActionSwitchColumnPrev)
428+ if sw.at != 1 {
429+ t.Fatalf("at %d", sw.at)
430+ }
431+ if len(ic.timers) != 1 {
432+ t.Fatalf("timers %v: stepping must not re-arm", ic.timers)
433+ }
434+}
435+
436+func TestSwitchWithoutCmdCommitsAtOnce(t *testing.T) {
437+ c, ic, _ := switcherCore(t, 3)
438+ start := switcherFocus(c)
439+ // F9 is bound to switch-column-next with no Cmd held.
440+ f9 := func(pressed bool) ports.KeyEvent {
441+ return ports.KeyEvent{Keysym: "F9", Keycode: 75, Pressed: pressed}
442+ }
443+ sendKeys(t, c, f9(true), f9(false))
444+ requireClosed(t, c)
445+ if len(ic.timers) != 0 {
446+ t.Fatalf("timers %v armed for a switch with no Cmd", ic.timers)
447+ }
448+ if switcherFocus(c) == start {
449+ t.Fatal("focus did not move")
450+ }
451+ sendKeys(t, c, f9(true), f9(false))
452+ if switcherFocus(c) != start {
453+ t.Fatalf("focus %d, want back on %d", switcherFocus(c), start)
454+ }
455+}
456+
457+func TestSwitchIgnoredWithOneColumnOrOverview(t *testing.T) {
458+ c, _, _ := switcherCore(t, 1)
459+ c.mods = ports.ModSuper
460+ bind(t, c, ActionSwitchColumnNext)
461+ requireClosed(t, c)
462+ c, _, _ = switcherCore(t, 3)
463+ c.cur().mon.ToggleOverview()
464+ before := switcherFocus(c)
465+ bind(t, c, ActionSwitchColumnNext)
466+ requireClosed(t, c)
467+ if switcherFocus(c) != before {
468+ t.Fatal("switcher acted in the overview")
469+ }
470+}
471+
472+// --- P2.3: release commit and timer ---
473+
474+func TestSwitchQuickTapSwapsWithoutCards(t *testing.T) {
475+ c, _, _ := switcherCore(t, 3)
476+ start := switcherFocus(c)
477+ for len(c.ch.Scenes) > 0 {
478+ <-c.ch.Scenes
479+ }
480+ tap := func() {
481+ t.Helper()
482+ sendKeys(t, c,
483+ superKey(ports.ModSuper, true),
484+ tab(ports.ModSuper, true),
485+ tab(ports.ModSuper, false),
486+ superKey(0, false),
487+ )
488+ for len(c.ch.Scenes) > 0 {
489+ if p := scenePreviews((<-c.ch.Scenes)[0]); len(p) > 0 {
490+ t.Fatalf("cards published during a quick tap: %+v", p)
491+ }
492+ }
493+ requireClosed(t, c)
494+ }
495+ tap()
496+ if switcherFocus(c) == start {
497+ t.Fatal("tap did not swap")
498+ }
499+ tap()
500+ if switcherFocus(c) != start {
501+ t.Fatalf("second tap focus %d, want back on %d", switcherFocus(c), start)
502+ }
503+}
504+
505+func TestSwitchHoldCommitsOnReleaseAndForwardsIt(t *testing.T) {
506+ c, _, cmds := switcherCore(t, 3)
507+ start := switcherFocus(c)
508+ order := c.cur().mon.Current().switchOrder()
509+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true), tab(ports.ModSuper, false))
510+ if switcherFocus(c) != start || c.cur().mon.sw.ws == nil {
511+ t.Fatal("focus moved or switcher closed before the release")
512+ }
513+ forwarded(cmds)
514+ sendKeys(t, c, superKey(0, false))
515+ requireClosed(t, c)
516+ if switcherFocus(c) != order[1] {
517+ t.Fatalf("focus %d, want %d", switcherFocus(c), order[1])
518+ }
519+ fk := forwarded(cmds)
520+ if len(fk) != 1 || fk[0].Key.Keysym != "Super_L" || fk[0].Key.Pressed || fk[0].ID != order[1] {
521+ t.Fatalf("release not forwarded to the new focus: %+v", fk)
522+ }
523+}
524+
525+// --- P3.1: keys and gestures ---
526+
527+func TestSwitchEscapeCancelsAndIsNotForwarded(t *testing.T) {
528+ c, _, cmds := switcherCore(t, 3)
529+ start := switcherFocus(c)
530+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true), tab(ports.ModSuper, false))
531+ forwarded(cmds)
532+ esc := ports.KeyEvent{Keysym: "Escape", Keycode: 1, Mods: ports.ModSuper, Pressed: true}
533+ sendKeys(t, c, esc)
534+ esc.Pressed = false
535+ sendKeys(t, c, esc, superKey(0, false))
536+ requireClosed(t, c)
537+ if switcherFocus(c) != start {
538+ t.Fatalf("focus %d, want %d", switcherFocus(c), start)
539+ }
540+ for _, k := range forwarded(cmds) {
541+ if k.Key.Keysym == "Escape" {
542+ t.Fatalf("Escape forwarded: %+v", k)
543+ }
544+ }
545+}
546+
547+func TestSwitchAnotherBindCancelsThenRuns(t *testing.T) {
548+ c, _, _ := switcherCore(t, 3)
549+ start := switcherFocus(c)
550+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
551+ sendKeys(t, c, ports.KeyEvent{Keysym: "f", Keycode: 33, Mods: ports.ModSuper, Pressed: true})
552+ requireClosed(t, c)
553+ if switcherFocus(c) != start {
554+ t.Fatalf("focus %d moved", switcherFocus(c))
555+ }
556+ if c.cur().mon.Current().fullscreen != start {
557+ t.Fatalf("bind not applied: fullscreen %d", c.cur().mon.Current().fullscreen)
558+ }
559+}
560+
561+func TestSwitchShiftTabGoesBackward(t *testing.T) {
562+ c, _, _ := switcherCore(t, 3)
563+ sendKeys(t, c, superKey(ports.ModSuper, true), shiftTab(true))
564+ sw := c.cur().mon.sw
565+ if sw.ws == nil || sw.at != len(sw.order)-1 {
566+ t.Fatalf("open %v at %d of %d", sw.ws != nil, sw.at, len(sw.order))
567+ }
568+ sendKeys(t, c, shiftTab(false), tab(ports.ModSuper, true))
569+ if c.cur().mon.sw.at != 0 {
570+ t.Fatalf("at %d, want 0 after next", c.cur().mon.sw.at)
571+ }
572+}
573+
574+func TestSwitchSwipeBeginCancels(t *testing.T) {
575+ c, _, _ := switcherCore(t, 3)
576+ start := switcherFocus(c)
577+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
578+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
579+ requireClosed(t, c)
580+ if switcherFocus(c) != start {
581+ t.Fatalf("focus %d, want %d", switcherFocus(c), start)
582+ }
583+}
584+
585+func TestSwitchSecurityChangeCancels(t *testing.T) {
586+ c, _, _ := switcherCore(t, 3)
587+ state := ports.SecurityState{Generation: 1}
588+ gate := portsmocks.NewMockSessionSecurity(t)
589+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }).Maybe()
590+ c.opts.Security = gate
591+ start := switcherFocus(c)
592+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
593+ state = ports.SecurityState{Generation: 2}
594+ if !c.syncSecurity() {
595+ t.Fatal("security change not seen")
596+ }
597+ requireClosed(t, c)
598+ if switcherFocus(c) != start {
599+ t.Fatalf("focus %d, want %d", switcherFocus(c), start)
600+ }
601+}
602+
603+func TestSwitchCmdKeyFollowsConfig(t *testing.T) {
604+ c, _, _ := switcherCore(t, 3)
605+ cfg := c.cfg
606+ cfg.Keyboard.CmdKey = "alt"
607+ if err := c.apply(cfg); err != nil {
608+ t.Fatal(err)
609+ }
610+ order := c.cur().mon.Current().switchOrder()
611+ sendKeys(t, c, tab(ports.ModAlt, true))
612+ if c.cur().mon.sw.ws == nil {
613+ t.Fatal("Alt+Tab did not open the switcher")
614+ }
615+ sendKeys(t, c, ports.KeyEvent{Keysym: "Alt_L", Keycode: 64, Pressed: false})
616+ requireClosed(t, c)
617+ if switcherFocus(c) != order[1] {
618+ t.Fatalf("focus %d, want %d", switcherFocus(c), order[1])
619+ }
620+}
621+
622+// --- P3.2: layout ---
623+
624+func TestSwitcherLayoutCards(t *testing.T) {
625+ c, _, _ := switcherCore(t, 3)
626+ c.cfg.Floating.Dim = 0.4
627+ // A wider focused column: the cards have different aspect ratios.
628+ c.cur().mon.Current().ResizeColumn(30)
629+ indicatorScene(t, c)
630+ area := c.cur().mon.Current().overviewArea()
631+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
632+ if s := indicatorScene(t, c); len(scenePreviews(s)) != 0 || c.cur().mon.sw.shown {
633+ t.Fatalf("cards before the delay: %+v", s.Windows)
634+ }
635+ order := c.cur().mon.sw.order
636+ if !c.switcherTick() || !c.cur().mon.sw.shown || c.switcher.timerC != nil {
637+ t.Fatal("the tick did not show the cards and clear the timer")
638+ }
639+ s := indicatorScene(t, c)
640+ cards := scenePreviews(s)
641+ if len(cards) != 3 {
642+ t.Fatalf("%d cards: %+v", len(cards), s.Windows)
643+ }
644+ focused := 0
645+ distinct := false
646+ var centers []int
647+ for k, card := range cards {
648+ if card.ID != order[k] {
649+ t.Fatalf("card %d is window %d, want %d (MRU order)", k, card.ID, order[k])
650+ }
651+ // The boxes are equal: the cards' centres are evenly spaced.
652+ centers = append(centers, card.Rect.X+card.Rect.W/2)
653+ // A card is its window's client size, scaled down uniformly.
654+ sent := c.configures.sent[card.ID]
655+ if sent.Width <= 0 || sent.Height <= 0 || card.Preview <= 0 || card.Preview >= 1 {
656+ t.Fatalf("card %d: client %dx%d, preview %v", card.ID, sent.Width, sent.Height, card.Preview)
657+ }
658+ if d := card.Rect.W - int(math.Round(float64(sent.Width)*card.Preview)); d < -1 || d > 1 {
659+ t.Fatalf("card %d is %dx%d for a %dx%d client at %v", card.ID, card.Rect.W, card.Rect.H, sent.Width, sent.Height, card.Preview)
660+ }
661+ if d := card.Rect.H - int(math.Round(float64(sent.Height)*card.Preview)); d < -1 || d > 1 {
662+ t.Fatalf("card %d is %dx%d for a %dx%d client at %v", card.ID, card.Rect.W, card.Rect.H, sent.Width, sent.Height, card.Preview)
663+ }
664+ if first := c.configures.sent[order[0]]; sent.Width != first.Width {
665+ distinct = true
666+ }
667+ if card.Focused {
668+ focused++
669+ if card.ID != order[c.cur().mon.sw.at] {
670+ t.Fatalf("focused card %d is not the selected %d", card.ID, order[c.cur().mon.sw.at])
671+ }
672+ }
673+ }
674+ if !distinct {
675+ t.Fatal("setup: every window has the same size, so the aspect ratios prove nothing")
676+ }
677+ if d := (centers[1] - centers[0]) - (centers[2] - centers[1]); d < -2 || d > 2 {
678+ t.Fatalf("boxes not equal: card centres %v", centers)
679+ }
680+ if d := centers[0] + centers[2] - 2*(area.X+area.W/2); d < -2 || d > 2 {
681+ t.Fatalf("row not centered in %+v: card centres %v", area, centers)
682+ }
683+ if focused != 1 {
684+ t.Fatalf("%d focused cards", focused)
685+ }
686+ if len(s.Separators) != 4 {
687+ t.Fatalf("separators %+v, want the 4 lines of the outline", s.Separators)
688+ }
689+ if s.Dim != 0.4 {
690+ t.Fatalf("dim %v", s.Dim)
691+ }
692+ if s.TileInset != (ports.Insets{}) {
693+ t.Fatalf("tile inset %+v", s.TileInset)
694+ }
695+ for _, w := range s.Windows {
696+ if w.Preview == 0 && !w.Hidden {
697+ t.Fatalf("window %d drawn under the cards", w.ID)
698+ }
699+ }
700+ // A commit gives the workspace back.
701+ sendKeys(t, c, superKey(0, false))
702+ if s := lastScene(t, c); len(scenePreviews(s)) != 0 {
703+ t.Fatalf("cards after the commit: %+v", s.Windows)
704+ }
705+}
706+
707+func TestSwitcherLayoutHidesOtherWindowsOfColumns(t *testing.T) {
708+ // Windows 10 and 11 are on a second workspace.
709+ c, _ := twoWorkspaces(t)
710+ m := c.cur().mon
711+ m.AddWindow(4)
712+ w := m.Current()
713+ w.ConsumeOrExpel(-1)
714+ m.AddFloating(9, 100, 80)
715+ if len(w.Columns) != 3 || w.columnWindows() != 4 || len(w.Floats) != 1 {
716+ t.Fatalf("setup: %d columns, %d windows, %d floats", len(w.Columns), w.columnWindows(), len(w.Floats))
717+ }
718+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
719+ c.switcherTick()
720+ s := indicatorScene(t, c)
721+ reps := map[WindowID]bool{}
722+ for _, id := range m.sw.order {
723+ reps[id] = true
724+ }
725+ seen := map[WindowID]bool{}
726+ for _, sw := range s.Windows {
727+ seen[sw.ID] = true
728+ if reps[sw.ID] {
729+ if sw.Preview == 0 || sw.Hidden {
730+ t.Fatalf("representative %d not a card: %+v", sw.ID, sw)
731+ }
732+ } else if !sw.Hidden {
733+ t.Fatalf("window %d not hidden: %+v", sw.ID, sw)
734+ }
735+ }
736+ // The other window of a column, the float and the other workspace's
737+ // windows are all in the scene, hidden.
738+ for _, id := range []WindowID{4, 9, 10, 11} {
739+ if !seen[id] {
740+ t.Fatalf("window %d missing from the scene (hidden windows must be listed): %+v", id, s.Windows)
741+ }
742+ }
743+}
744+
745+// While the cards show, the screen draws previews: the tiles do not stop at
746+// the panels, a card takes no pointer, nothing is
747+// dragged, and a window mapping or unmapping behind them does not animate.
748+func TestSwitcherShownIsAPreview(t *testing.T) {
749+ c, ic, _ := switcherCore(t, 3)
750+ c.cfg.Animations.On = true
751+ m := c.cur().mon
752+ // A bar reserves the top of the output: tiles stop below it.
753+ c.setLayers([]ports.LayerSurface{{ID: 90, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Width: 300, Height: 20, ExclusiveZone: 20}})
754+ if s := indicatorScene(t, c); s.TileInset.Top != 20 {
755+ t.Fatalf("setup: tile inset %+v, want the bar's 20", s.TileInset)
756+ }
757+ sizes := map[WindowID]ports.ConfigureWindow{}
758+ for id, v := range c.configures.sent {
759+ sizes[id] = v
760+ }
761+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
762+ c.switcherTick()
763+ indicatorScene(t, c)
764+ // The cards fly in, and the motions survive a publish.
765+ if len(c.cur().rects) == 0 {
766+ t.Fatal("no card motion after the show")
767+ }
768+ settle := func() {
769+ t.Helper()
770+ for range 200 {
771+ ic.now = ic.now.Add(16 * time.Millisecond)
772+ if err := c.step(context.Background(), nil); err != nil {
773+ t.Fatal(err)
774+ }
775+ }
776+ lastScene(t, c)
777+ if len(c.cur().rects) != 0 || c.animating() {
778+ t.Fatalf("motions did not settle: %d rects", len(c.cur().rects))
779+ }
780+ }
781+ settle()
782+ if !m.switcherShown() {
783+ t.Fatal("setup: cards not shown")
784+ }
785+ s := indicatorScene(t, c)
786+ if len(scenePreviews(s)) != 3 {
787+ t.Fatalf("%d cards", len(scenePreviews(s)))
788+ }
789+ // A card keeps its client's real size.
790+ for _, id := range m.sw.order {
791+ if got := c.configures.sent[id]; got.Width != sizes[id].Width || got.Height != sizes[id].Height {
792+ t.Fatalf("window %d configured %dx%d under its card, was %dx%d", id, got.Width, got.Height, sizes[id].Width, sizes[id].Height)
793+ }
794+ }
795+ if s.TileInset != (ports.Insets{}) {
796+ t.Fatalf("tile inset %+v while the cards show", s.TileInset)
797+ }
798+ // The pointer finds no window under a card.
799+ card := m.sw.order[1]
800+ x, y := cardCenter(t, c, card)
801+ if id, _, _ := c.hit(x, y); id != 0 {
802+ t.Fatalf("hit window %d under a card", id)
803+ }
804+ if _, _, ok := c.draggable(card); ok {
805+ t.Fatal("a card can be dragged")
806+ }
807+ if tgt := c.dropAt(card, x, y); tgt.kind != dropNone {
808+ t.Fatalf("a drop target %+v under the cards", tgt)
809+ }
810+ // A map and an unmap behind the cards do not animate.
811+ c.mapWindow(ports.WindowMapped{ID: 20})
812+ if len(c.cur().rects) != 0 {
813+ t.Fatalf("a map behind the cards started motions: %+v", c.cur().rects)
814+ }
815+ gone := m.sw.order[2]
816+ c.unmapWindow(ports.WindowUnmapped{ID: gone})
817+ if len(c.cur().rects) != 0 {
818+ t.Fatalf("an unmap behind the cards started motions: %+v", c.cur().rects)
819+ }
820+ if m.Current().columnOf(gone) >= 0 || len(m.sw.order) != 2 {
821+ t.Fatalf("window %d not removed from the cards: %v", gone, m.sw.order)
822+ }
823+ // The workspace comes back with the panel inset.
824+ sendKeys(t, c, superKey(0, false))
825+ settle()
826+ if s := indicatorScene(t, c); s.TileInset.Top != 20 || len(scenePreviews(s)) != 0 {
827+ t.Fatalf("after the commit: tile inset %+v, %d cards", s.TileInset, len(scenePreviews(s)))
828+ }
829+}
830+
831+// The predicates that choose between the workspace and its previews follow
832+// the cards as they follow the overview: the frame, a covering fullscreen
833+// and the capture state.
834+func TestSwitcherShownPreviewPredicates(t *testing.T) {
835+ c, _, _ := switcherCore(t, 3)
836+ sc := c.cur()
837+ m := sc.mon
838+ w := m.Current()
839+ w.Overflow = OverflowFixed // a fullscreen window covers whatever the scroll
840+ w.SetSize(100, 100) // the workspace frame is smaller than the output
841+ tile := w.Columns[w.Focus].Windows[0]
842+ w.SetFullscreen(tile, true)
843+ indicatorScene(t, c)
844+ c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: w.ID}}}
845+ type state struct {
846+ frameIsOutput, covers, captureHidden bool
847+ captureShown uint64
848+ }
849+ read := func() state {
850+ r, reason := c.capResolve(capTarget{workspace: w.ID})
851+ if reason != ports.CaptureReasonNone {
852+ t.Fatalf("capture target: %v", reason)
853+ }
854+ var shown uint64
855+ if cs := c.captureSceneFor(sc, &capView{}); cs != nil {
856+ shown = cs.Shown
857+ }
858+ return state{m.Frame() == m.Output(), hasFullscreen(sc), r.hidden, shown}
859+ }
860+ if got, want := read(), (state{false, true, false, w.ID}); got != want {
861+ t.Fatalf("closed: %+v, want %+v", got, want)
862+ }
863+ c.mods = ports.ModSuper
864+ bind(t, c, ActionSwitchColumnNext)
865+ c.switcherTick()
866+ if !m.switcherShown() {
867+ t.Fatal("setup: cards not shown")
868+ }
869+ if got, want := read(), (state{true, false, true, 0}); got != want {
870+ t.Fatalf("shown: %+v, want %+v", got, want)
871+ }
872+}
873+
874+// A card's window with no configure sent yet (it mapped since the last
875+// publish) is sized from its real client rect, not from the card.
876+func TestSwitcherCardOfUnconfiguredWindowKeepsRealSize(t *testing.T) {
877+ c, _, _ := switcherCore(t, 3)
878+ m := c.cur().mon
879+ id := m.Current().switchOrder()[1]
880+ c.configures.forget(id) // the card falls back to the usable size
881+ c.mods = ports.ModSuper
882+ bind(t, c, ActionSwitchColumnNext)
883+ c.configures.forget(id) // the bind's publish configured it again
884+ c.switcherTick()
885+ indicatorScene(t, c)
886+ var real Placement
887+ for _, p := range m.Current().Layout() {
888+ if p.ID == id {
889+ real = p
890+ }
891+ }
892+ want := c.clientRect(real)
893+ if got := c.configures.sent[id]; got.Width != want.W || got.Height != want.H || want.W == 0 {
894+ t.Fatalf("window %d configured %dx%d under its card, real client %dx%d", id, got.Width, got.Height, want.W, want.H)
895+ }
896+}
897+
898+// A focus change behind the cards (the real focus, not the selection) is not
899+// recorded as a use of the column until the cards are gone.
900+func TestSwitcherShownDoesNotNoteFocus(t *testing.T) {
901+ c, _, _ := switcherCore(t, 3)
902+ w := c.cur().mon.Current()
903+ c.mods = ports.ModSuper
904+ bind(t, c, ActionSwitchColumnNext)
905+ c.switcherTick()
906+ before := slices.Clone(w.recent)
907+ w.FocusID(c.cur().mon.sw.order[2])
908+ indicatorScene(t, c)
909+ if !slices.Equal(w.recent, before) {
910+ t.Fatalf("recent %v, was %v: noted while the cards show", w.recent, before)
911+ }
912+}
913+
914+// Showing the cards drops the motions of windows they hide.
915+func TestSwitcherShowDropsMotionsOfHiddenWindows(t *testing.T) {
916+ c, _, _ := switcherCore(t, 3)
917+ c.cfg.Animations.On = true
918+ sc := c.cur()
919+ c.mapWindow(ports.WindowMapped{ID: 4})
920+ w := sc.mon.Current()
921+ w.ConsumeOrExpel(-1) // 4 joins 3's column
922+ w.FocusID(3) // 3 stands for the column; 4 is hidden
923+ if _, ok := sc.rects[4]; !ok {
924+ t.Fatal("setup: no entrance motion for window 4")
925+ }
926+ c.mods = ports.ModSuper
927+ bind(t, c, ActionSwitchColumnNext)
928+ if _, ok := sc.rects[4]; !ok {
929+ t.Fatal("setup: motion dropped by the bind")
930+ }
931+ c.switcherTick()
932+ if _, ok := sc.rects[4]; ok {
933+ t.Fatalf("a hidden window keeps its motion: %+v", sc.rects[4])
934+ }
935+}
936+
937+func TestSwitcherCardsKeepKeysOnRealFocus(t *testing.T) {
938+ c, _, cmds := switcherCore(t, 3)
939+ focus := switcherFocus(c)
940+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
941+ c.switcherTick()
942+ indicatorScene(t, c)
943+ forwarded(cmds)
944+ if c.keyboardFocus() != focus {
945+ t.Fatalf("keyboard focus %d, want %d", c.keyboardFocus(), focus)
946+ }
947+ sendKeys(t, c, ports.KeyEvent{Keysym: "x", Keycode: 45, Mods: ports.ModSuper, Pressed: true})
948+ // An unbound key goes to the real focus (Cmd+x has no bind here).
949+ fk := forwarded(cmds)
950+ if len(fk) != 1 || fk[0].ID != focus || fk[0].Key.Keysym != "x" {
951+ t.Fatalf("forwarded %+v, want x to %d", fk, focus)
952+ }
953+}
954+
955+// --- P3.3: click ---
956+
957+func cardCenter(t *testing.T, c *Core, id WindowID) (float64, float64) {
958+ t.Helper()
959+ for _, p := range c.cur().shownLayout() {
960+ if p.ID == id && p.Preview > 0 {
961+ return float64(p.Rect.X + p.Rect.W/2), float64(p.Rect.Y + p.Rect.H/2)
962+ }
963+ }
964+ t.Fatalf("no card for %d", id)
965+ return 0, 0
966+}
967+
968+func TestSwitcherClickOnCardCommits(t *testing.T) {
969+ c, _, cmds := switcherCore(t, 3)
970+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
971+ c.switcherTick()
972+ indicatorScene(t, c)
973+ target := c.cur().mon.sw.order[2]
974+ c.cursorX, c.cursorY = cardCenter(t, c, target)
975+ forwarded(cmds)
976+ if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {
977+ t.Fatal(err)
978+ }
979+ requireClosed(t, c)
980+ if switcherFocus(c) != target {
981+ t.Fatalf("focus %d, want %d", switcherFocus(c), target)
982+ }
983+ if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110}); err != nil {
984+ t.Fatal(err)
985+ }
986+ for len(cmds) > 0 {
987+ if v, ok := (<-cmds).(ports.PointerButtonTo); ok {
988+ t.Fatalf("click reached a client: %+v", v)
989+ }
990+ }
991+}
992+
993+// A click lands on a card where it is drawn, while its motion still runs.
994+func TestSwitcherClickOnMovingCardCommits(t *testing.T) {
995+ c, ic, _ := switcherCore(t, 3)
996+ c.cfg.Animations.On = true
997+ sc := c.cur()
998+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
999+ c.switcherTick()
1000+ indicatorScene(t, c)
1001+ ic.now = ic.now.Add(16 * time.Millisecond)
1002+ if err := c.step(context.Background(), nil); err != nil {
1003+ t.Fatal(err)
1004+ }
1005+ lastScene(t, c)
1006+ target := sc.mon.sw.order[2]
1007+ if len(sc.rects) == 0 || !c.animating() {
1008+ t.Fatalf("setup: %d card motions, animating %v", len(sc.rects), c.animating())
1009+ }
1010+ var settled, shown Rect
1011+ for _, p := range sc.settledLayout {
1012+ if p.ID == target {
1013+ settled = p.Rect
1014+ }
1015+ }
1016+ for _, p := range sc.shownLayout() {
1017+ if p.ID == target {
1018+ shown = p.Rect
1019+ }
1020+ }
1021+ if settled == shown {
1022+ t.Fatalf("setup: card %d shown at its settled rect %+v", target, shown)
1023+ }
1024+ c.cursorX, c.cursorY = cardCenter(t, c, target)
1025+ if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {
1026+ t.Fatal(err)
1027+ }
1028+ requireClosed(t, c)
1029+ if switcherFocus(c) != target {
1030+ t.Fatalf("focus %d, want %d", switcherFocus(c), target)
1031+ }
1032+}
1033+
1034+// Cancelling while the switch is held leaves nothing for the delayed show.
1035+func TestSwitcherTickAfterCancelDoesNothing(t *testing.T) {
1036+ c, _, _ := switcherCore(t, 3)
1037+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1038+ if !c.switching() {
1039+ t.Fatal("setup: no switcher open")
1040+ }
1041+ c.cancelSwitcher()
1042+ if c.switcherTick() {
1043+ t.Fatal("switcherTick reports a switcher after the cancel")
1044+ }
1045+ requireClosed(t, c)
1046+}
1047+
1048+func TestSwitcherClickElsewhereCancels(t *testing.T) {
1049+ c, _, _ := switcherCore(t, 3)
1050+ start := switcherFocus(c)
1051+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1052+ c.switcherTick()
1053+ indicatorScene(t, c)
1054+ c.cursorX, c.cursorY = 1, 1
1055+ if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {
1056+ t.Fatal(err)
1057+ }
1058+ requireClosed(t, c)
1059+ if switcherFocus(c) != start {
1060+ t.Fatalf("focus %d, want %d", switcherFocus(c), start)
1061+ }
1062+}
1063+
1064+func TestSwitcherPressBeforeCardsCancelsAndContinues(t *testing.T) {
1065+ c, _, _ := switcherCore(t, 3)
1066+ start := switcherFocus(c)
1067+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1068+ handled, err := c.switcherClick(context.Background(), 0x110)
1069+ if err != nil || handled {
1070+ t.Fatalf("handled %v err %v", handled, err)
1071+ }
1072+ requireClosed(t, c)
1073+ if switcherFocus(c) != start {
1074+ t.Fatal("press did not cancel")
1075+ }
1076+}
1077+
1078+// --- P3.4: robustness ---
1079+
1080+func TestSwitcherClosingSelectedWindowKeepsSelectionValid(t *testing.T) {
1081+ c, _, _ := switcherCore(t, 3)
1082+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1083+ c.switcherTick()
1084+ sw := &c.cur().mon.sw
1085+ gone := sw.order[sw.at]
1086+ c.cur().mon.RemoveWindow(gone)
1087+ if sw.ws == nil || len(sw.order) != 2 || sw.at < 0 || sw.at >= len(sw.order) || slices.Contains(sw.order, gone) {
1088+ t.Fatalf("open %v order %v at %d", sw.ws != nil, sw.order, sw.at)
1089+ }
1090+ s := indicatorScene(t, c)
1091+ if n := len(scenePreviews(s)); n != 2 {
1092+ t.Fatalf("%d cards after a close", n)
1093+ }
1094+ want := sw.order[sw.at]
1095+ sendKeys(t, c, superKey(0, false))
1096+ if switcherFocus(c) != want {
1097+ t.Fatalf("focus %d, want neighbour %d", switcherFocus(c), want)
1098+ }
1099+}
1100+
1101+// Closing a window ahead of the selection shifts the cards left: the
1102+// selection follows its card, so it still stands on the same window.
1103+func TestSwitcherClosingBeforeSelectionKeepsCard(t *testing.T) {
1104+ c, _, _ := switcherCore(t, 4)
1105+ c.mods = ports.ModSuper
1106+ bind(t, c, ActionSwitchColumnNext)
1107+ sw := &c.cur().mon.sw
1108+ if sw.at != 1 || len(sw.order) != 4 {
1109+ t.Fatalf("setup: at %d of %d", sw.at, len(sw.order))
1110+ }
1111+ selected := sw.order[sw.at]
1112+ c.cur().mon.RemoveWindow(sw.order[0])
1113+ if sw.ws == nil || len(sw.order) != 3 {
1114+ t.Fatalf("order %v after the close", sw.order)
1115+ }
1116+ if sw.at != 0 || sw.order[sw.at] != selected {
1117+ t.Fatalf("selection moved from %d: order %v at %d, want at 0", selected, sw.order, sw.at)
1118+ }
1119+ sendKeys(t, c, superKey(0, false))
1120+ if switcherFocus(c) != selected {
1121+ t.Fatalf("focus %d after the commit, want %d", switcherFocus(c), selected)
1122+ }
1123+}
1124+
1125+func TestSwitcherClosingDownToOneColumnClosesIt(t *testing.T) {
1126+ c, _, _ := switcherCore(t, 2)
1127+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1128+ c.switcherTick()
1129+ c.cur().mon.RemoveWindow(c.cur().mon.sw.order[1])
1130+ if sw := c.cur().mon.sw; sw.ws != nil || sw.shown {
1131+ t.Fatal("switcher still open with one column")
1132+ }
1133+ if s := indicatorScene(t, c); len(scenePreviews(s)) != 0 {
1134+ t.Fatalf("cards left: %+v", s.Windows)
1135+ }
1136+ // The timer fires on a closed switcher: harmless, and it clears the
1137+ // core's side.
1138+ if c.switcherTick() {
1139+ t.Fatal("tick on a closed switcher")
1140+ }
1141+ requireClosed(t, c)
1142+ sendKeys(t, c, superKey(0, false))
1143+}
1144+
1145+func TestSwitcherOutputRemovalCancels(t *testing.T) {
1146+ c, _, _ := switcherCore(t, 3)
1147+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
1148+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1149+ c.switcherTick()
1150+ sc := c.switcher.sc
1151+ if sc == nil {
1152+ t.Fatal("no switcher screen")
1153+ }
1154+ c.removeScreen(sc.name())
1155+ requireClosed(t, c)
1156+ if sc.mon.sw.ws != nil {
1157+ t.Fatal("switcher survived its output")
1158+ }
1159+ if err := c.publish(context.Background()); err != nil {
1160+ t.Fatal(err)
1161+ }
1162+ if c.switcherTick() {
1163+ t.Fatal("tick after the output went")
1164+ }
1165+}
1166+
1167+func TestSwitcherFocusMovingToAnotherOutputReopensThere(t *testing.T) {
1168+ c, _, _ := switcherCore(t, 3)
1169+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
1170+ first := c.cur()
1171+ for id := WindowID(11); id <= 13; id++ {
1172+ c.screens[c.screenIndex("B")].mon.AddWindow(id)
1173+ }
1174+ indicatorScene(t, c)
1175+ c.mods = ports.ModSuper
1176+ bind(t, c, ActionSwitchColumnNext)
1177+ if c.switcher.sc != first {
1178+ t.Fatal("switcher not on the focused screen")
1179+ }
1180+ c.focusScreen = c.screenIndex("B")
1181+ bind(t, c, ActionSwitchColumnNext)
1182+ if first.mon.sw.ws != nil {
1183+ t.Fatal("old switcher left open")
1184+ }
1185+ if c.switcher.sc != c.cur() || c.cur().mon.sw.ws == nil || c.cur().mon.sw.at != 1 {
1186+ t.Fatalf("new switcher on %v open %v", c.switcher.sc == c.cur(), c.cur().mon.sw.ws != nil)
1187+ }
1188+}
1189+
1190+// With animations on, the cards fly in and the commit flies them back; the
1191+// windows end where the layout puts them, with no card left.
1192+func TestSwitcherAnimatedHoldAndCommit(t *testing.T) {
1193+ c, ic, _ := switcherCore(t, 3)
1194+ c.cfg.Animations.On = true
1195+ target := c.cur().mon.Current().switchOrder()[1]
1196+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1197+ c.switcherTick()
1198+ if err := c.publish(context.Background()); err != nil {
1199+ t.Fatal(err)
1200+ }
1201+ if len(scenePreviews(lastScene(t, c))) != 3 {
1202+ t.Fatal("cards not shown")
1203+ }
1204+ sendKeys(t, c, superKey(0, false))
1205+ for range 200 {
1206+ ic.now = ic.now.Add(16 * time.Millisecond)
1207+ if err := c.step(context.Background(), nil); err != nil {
1208+ t.Fatal(err)
1209+ }
1210+ }
1211+ s := lastScene(t, c)
1212+ if len(scenePreviews(s)) != 0 || switcherFocus(c) != target {
1213+ t.Fatalf("cards %d, focus %d (want %d)", len(scenePreviews(s)), switcherFocus(c), target)
1214+ }
1215+ if n := len(c.cur().rects); n != 0 || c.animating() {
1216+ t.Fatalf("motions left: %d rect motions, animating %v", n, c.animating())
1217+ }
1218+}
1219+
1220+// --- review fixes ---
1221+
1222+// twoWorkspaces is a switcher core whose second workspace holds two
1223+// windows (10, 11) and is not on screen.
1224+func twoWorkspaces(t *testing.T) (*Core, *Workspace) {
1225+ t.Helper()
1226+ c, _, _ := switcherCore(t, 3)
1227+ c.cfg.Floating.Dim = 0.4
1228+ m := c.cur().mon
1229+ first := m.Current()
1230+ m.Focus(1)
1231+ m.AddWindow(10)
1232+ m.AddWindow(11)
1233+ other := m.Current()
1234+ m.show(first)
1235+ if other == first || m.Current() != first {
1236+ t.Fatal("workspaces not set up")
1237+ }
1238+ indicatorScene(t, c)
1239+ return c, other
1240+}
1241+
1242+func TestSwitcherShownDropsWhenAnotherWorkspaceComesOnScreen(t *testing.T) {
1243+ c, other := twoWorkspaces(t)
1244+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1245+ c.switcherTick()
1246+ if s := indicatorScene(t, c); len(scenePreviews(s)) != 3 {
1247+ t.Fatalf("cards not shown: %+v", s.Windows)
1248+ }
1249+ // An xdg-activation request for a window of the other workspace.
1250+ if err := c.activate(context.Background(), 10); err != nil {
1251+ t.Fatal(err)
1252+ }
1253+ m := c.cur().mon
1254+ if m.Current() != other || m.previewing() {
1255+ t.Fatalf("on screen %v, previewing %v", m.Current() == other, m.previewing())
1256+ }
1257+ s := indicatorScene(t, c)
1258+ if len(scenePreviews(s)) != 0 || s.Dim != 0 || s.DimBehind {
1259+ t.Fatalf("scene still dimmed or previewing: dim %v previews %+v", s.Dim, scenePreviews(s))
1260+ }
1261+ requireClosed(t, c)
1262+ var any bool
1263+ for _, p := range c.cur().shownLayout() {
1264+ if p.ID == 10 || p.ID == 11 {
1265+ x, y := float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2)
1266+ if id, _, _ := c.hit(x, y); id != p.ID {
1267+ t.Fatalf("hit %d at window %d", id, p.ID)
1268+ }
1269+ any = true
1270+ }
1271+ }
1272+ if !any {
1273+ t.Fatal("workspace windows not laid out")
1274+ }
1275+ // The next switch opens fresh on the new workspace.
1276+ c.mods = ports.ModSuper
1277+ bind(t, c, ActionSwitchColumnNext)
1278+ if sw := m.sw; sw.ws == nil || sw.ws != other || sw.at != 1 {
1279+ t.Fatalf("open %v on other %v at %d", sw.ws != nil, sw.ws == other, sw.at)
1280+ }
1281+}
1282+
1283+func TestSwitcherStepOnStaleWorkspaceOpensFresh(t *testing.T) {
1284+ c, other := twoWorkspaces(t)
1285+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1286+ c.switcherTick()
1287+ // Shown on screen, then the workspace changes with no publish between.
1288+ c.cur().mon.show(other)
1289+ if c.cur().mon.previewing() {
1290+ t.Fatal("stale cards still count as previews")
1291+ }
1292+ sendKeys(t, c, tab(ports.ModSuper, true))
1293+ sw := c.cur().mon.sw
1294+ if sw.ws == nil || sw.ws != other || sw.shown || sw.at != 1 {
1295+ t.Fatalf("open %v on other %v shown %v at %d", sw.ws != nil, sw.ws == other, sw.shown, sw.at)
1296+ }
1297+ // A release on a stale switcher commits nothing and closes it.
1298+ c.cur().mon.show(c.cur().mon.Workspaces[0])
1299+ focus := switcherFocus(c)
1300+ sendKeys(t, c, superKey(0, false))
1301+ requireClosed(t, c)
1302+ if switcherFocus(c) != focus {
1303+ t.Fatal("stale switcher survived the release")
1304+ }
1305+}
1306+
1307+// The delay passes after another workspace came on screen with no publish
1308+// in between: the tick drops the switcher instead of showing stale cards.
1309+func TestSwitcherTickOnStaleWorkspaceCancels(t *testing.T) {
1310+ c, other := twoWorkspaces(t)
1311+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1312+ m := c.cur().mon
1313+ if m.sw.ws == nil {
1314+ t.Fatal("switcher not open")
1315+ }
1316+ m.show(other)
1317+ if !c.switcherTick() {
1318+ t.Fatal("tick on a stale workspace asks for no scene")
1319+ }
1320+ requireClosed(t, c)
1321+}
1322+
1323+func TestSwitchIgnoredDuringDrag(t *testing.T) {
1324+ c, _, _ := switcherCore(t, 3)
1325+ start := switcherFocus(c)
1326+ d := &dragState{id: start, button: btnLeft}
1327+ c.drag = d
1328+ c.buttons[btnLeft] = true
1329+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1330+ requireClosed(t, c)
1331+ if c.drag != d || switcherFocus(c) != start {
1332+ t.Fatal("drag disturbed")
1333+ }
1334+ sendKeys(t, c, ports.KeyEvent{Keysym: "Escape", Keycode: 1, Mods: ports.ModSuper, Pressed: true})
1335+ if c.drag != nil {
1336+ t.Fatal("Escape did not cancel the drag")
1337+ }
1338+}
1339+
1340+// Another bind closing the shown cards still flies them back: the snapshot
1341+// is taken while they are drawn, so the windows move from the card rects.
1342+func TestSwitchBindOverShownCardsFliesBack(t *testing.T) {
1343+ c, ic, _ := switcherCore(t, 3)
1344+ c.cfg.Animations.On = true
1345+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1346+ c.switcherTick()
1347+ for range 200 {
1348+ ic.now = ic.now.Add(16 * time.Millisecond)
1349+ if err := c.step(context.Background(), nil); err != nil {
1350+ t.Fatal(err)
1351+ }
1352+ }
1353+ sc := c.cur()
1354+ cards := map[WindowID]Rect{}
1355+ for _, p := range sc.shownLayout() {
1356+ if p.Preview > 0 {
1357+ cards[p.ID] = p.Rect
1358+ }
1359+ }
1360+ if len(cards) != 3 || len(sc.rects) != 0 {
1361+ t.Fatalf("%d cards, %d motions: the cards did not settle", len(cards), len(sc.rects))
1362+ }
1363+ bind(t, c, ActionFocusColumnLeft)
1364+ if c.switching() || sc.mon.sw.shown {
1365+ t.Fatal("the bind left the switcher open")
1366+ }
1367+ settled := map[WindowID]Rect{}
1368+ for _, p := range sc.mon.Layout() {
1369+ settled[p.ID] = p.Rect
1370+ }
1371+ for id, card := range cards {
1372+ rm, ok := sc.rects[id]
1373+ if !ok || !rm.scale {
1374+ t.Fatalf("window %d has no fly-back motion", id)
1375+ }
1376+ if got := rm.apply(settled[id]); got != card {
1377+ t.Fatalf("window %d flies back from %+v, want its card %+v", id, got, card)
1378+ }
1379+ }
1380+}
1381+
1382+func TestSwitcherClosingSelectedWindowPicksColumnsNextWindow(t *testing.T) {
1383+ m := NewMonitor()
1384+ m.SetOutput(300, 200)
1385+ m.SetMaxColumns(3)
1386+ for id := WindowID(1); id <= 4; id++ {
1387+ m.AddWindow(id)
1388+ }
1389+ w := m.Current()
1390+ w.FocusID(3)
1391+ w.ConsumeOrExpel(-1) // columns [1] [2 3] [4]
1392+ if len(w.Columns) != 3 || len(w.Columns[1].Windows) != 2 {
1393+ t.Fatalf("columns %+v", w.Columns)
1394+ }
1395+ w.FocusID(1)
1396+ openSwitcher(m, 0)
1397+ at := slices.IndexFunc(m.sw.order, func(id WindowID) bool { return w.columnOf(id) == 1 })
1398+ m.sw.at = at
1399+ gone := m.sw.order[at]
1400+ size := Rect{W: 120, H: 90}
1401+ m.sw.sizes = map[WindowID]Rect{gone: size}
1402+ other := w.Columns[1].Windows[0]
1403+ if other == gone {
1404+ other = w.Columns[1].Windows[1]
1405+ }
1406+ m.RemoveWindow(gone)
1407+ if len(m.sw.order) != 3 || m.sw.at != at || m.sw.order[at] != other {
1408+ t.Fatalf("order %v at %d, want card %d to stand for %d", m.sw.order, m.sw.at, at, other)
1409+ }
1410+ if m.sw.sizes[other] != size {
1411+ t.Fatalf("sizes %v: the new card keeps no size of the closed window's", m.sw.sizes)
1412+ }
1413+ m.commitSwitcher()
1414+ if id, _ := w.Focused(); id != other {
1415+ t.Fatalf("focus %d, want %d", id, other)
1416+ }
1417+}
1418+
1419+func TestSwitchCommitFocusesTileUnderFloat(t *testing.T) {
1420+ c, _, _ := switcherCore(t, 3)
1421+ m := c.cur().mon
1422+ m.AddFloating(9, 100, 80)
1423+ w := m.Current()
1424+ if !w.floatFocus {
1425+ t.Fatal("float not focused")
1426+ }
1427+ tile := w.Columns[w.Focus].Windows[0]
1428+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1429+ bind(t, c, ActionSwitchColumnPrev) // back to card 0: the column under the float
1430+ if m.sw.at != 0 || m.sw.order[0] != tile {
1431+ t.Fatalf("at %d order %v", m.sw.at, m.sw.order)
1432+ }
1433+ sendKeys(t, c, superKey(0, false))
1434+ if w.floatFocus || switcherFocus(c) != tile {
1435+ t.Fatalf("float focused %v, focus %d, want tile %d", w.floatFocus, switcherFocus(c), tile)
1436+ }
1437+}
1438+
1439+func TestSwitchCommitFollowsToItsOutput(t *testing.T) {
1440+ c, _, _ := switcherCore(t, 3)
1441+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
1442+ first := c.focusScreen
1443+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1444+ want := c.cur().mon.sw.order[1]
1445+ // The pointer crosses to the other output meanwhile.
1446+ c.focusScreen = c.screenIndex("B")
1447+ sendKeys(t, c, superKey(0, false))
1448+ if c.focusScreen != first || switcherFocus(c) != want {
1449+ t.Fatalf("focus screen %d (want %d), focus %d (want %d)", c.focusScreen, first, switcherFocus(c), want)
1450+ }
1451+}
@@ -0,0 +1,1451 @@
1+package core
2+
3+import (
4+ "context"
5+ "math"
6+ "slices"
7+ "testing"
8+ "time"
9+
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11+ "github.com/bnema/neferwl/internal/ports"
12+)
13+
14+// threeColumns is a workspace of three one-window columns, windows 1..3.
15+func threeColumns() *Workspace {
16+ w := workspace()
17+ w.SetMaxColumns(3)
18+ for id := WindowID(1); id <= 3; id++ {
19+ w.AddWindow(id)
20+ }
21+ return w
22+}
23+
24+func focusNote(w *Workspace, id WindowID) {
25+ w.FocusID(id)
26+ w.noteFocus()
27+}
28+
29+func TestMRUOrder(t *testing.T) {
30+ tests := []struct {
31+ name string
32+ run func(w *Workspace)
33+ want []WindowID
34+ }{
35+ {"follows focus", func(w *Workspace) {
36+ // Columns 0, 2, 1 in turn: the last one used goes first.
37+ focusNote(w, 1)
38+ focusNote(w, 3)
39+ focusNote(w, 2)
40+ }, []WindowID{2, 3, 1}},
41+ {"back to the oldest", func(w *Workspace) {
42+ focusNote(w, 1)
43+ focusNote(w, 3)
44+ focusNote(w, 2)
45+ focusNote(w, 1)
46+ }, []WindowID{1, 2, 3}},
47+ {"never focused follow by position", func(w *Workspace) {
48+ focusNote(w, 3)
49+ }, []WindowID{3, 1, 2}},
50+ {"closed windows are pruned", func(w *Workspace) {
51+ focusNote(w, 1)
52+ focusNote(w, 3)
53+ focusNote(w, 2)
54+ w.RemoveWindow(3)
55+ }, []WindowID{2, 1}},
56+ {"a moved column keeps its rank", func(w *Workspace) {
57+ focusNote(w, 1)
58+ focusNote(w, 3)
59+ focusNote(w, 2)
60+ w.MoveColumn(1)
61+ w.noteFocus()
62+ }, []WindowID{2, 3, 1}},
63+ }
64+ for _, tt := range tests {
65+ t.Run(tt.name, func(t *testing.T) {
66+ w := threeColumns()
67+ tt.run(w)
68+ if got := w.switchOrder(); !slices.Equal(got, tt.want) {
69+ t.Fatalf("order %v, want %v", got, tt.want)
70+ }
71+ // recent never keeps a closed window nor two of one column.
72+ seen := map[int]bool{}
73+ for _, id := range w.recent {
74+ i := w.columnOf(id)
75+ if i < 0 || seen[i] {
76+ t.Fatalf("recent %v keeps window %d (column %d)", w.recent, id, i)
77+ }
78+ seen[i] = true
79+ }
80+ })
81+ }
82+}
83+
84+func TestMRUOneWindowPerColumn(t *testing.T) {
85+ w := threeColumns()
86+ w.AddWindow(4)
87+ w.FocusColumn(-1)
88+ w.ConsumeOrExpel(-1)
89+ // Whatever the columns became, each appears once.
90+ for range 3 {
91+ w.noteFocus()
92+ w.FocusColumn(-1)
93+ }
94+ order := w.switchOrder()
95+ if len(order) != len(w.Columns) {
96+ t.Fatalf("order %v for %d columns", order, len(w.Columns))
97+ }
98+ seen := map[int]bool{}
99+ for _, id := range order {
100+ if seen[w.columnOf(id)] {
101+ t.Fatalf("order %v lists a column twice", order)
102+ }
103+ seen[w.columnOf(id)] = true
104+ }
105+}
106+
107+func TestMRUSwitchOrderNeedsTwoColumns(t *testing.T) {
108+ w := workspace()
109+ if w.switchOrder() != nil {
110+ t.Fatal("order of an empty workspace")
111+ }
112+ w.AddWindow(1)
113+ w.noteFocus()
114+ if w.switchOrder() != nil {
115+ t.Fatal("order of one column")
116+ }
117+}
118+
119+func TestMRUCapped(t *testing.T) {
120+ w := workspace()
121+ w.SetMaxColumns(3)
122+ for id := WindowID(1); id <= switcherRecentMax+10; id++ {
123+ w.AddWindow(id)
124+ w.noteFocus()
125+ }
126+ if len(w.recent) != switcherRecentMax {
127+ t.Fatalf("recent holds %d", len(w.recent))
128+ }
129+}
130+
131+func TestMRUNoteFocusDoesNotAllocateWhenSettled(t *testing.T) {
132+ w := threeColumns()
133+ focusNote(w, 2)
134+ if n := testing.AllocsPerRun(100, w.noteFocus); n != 0 {
135+ t.Fatalf("noteFocus allocates %v", n)
136+ }
137+ // A focus change inside the latest column updates its entry in place.
138+ w.AddWindow(4)
139+ w.ConsumeOrExpel(-1)
140+ w.FocusID(2)
141+ w.noteFocus()
142+ if len(w.recent) == 0 || w.recent[0] != 2 {
143+ t.Fatalf("recent %v, want window 2 first", w.recent)
144+ }
145+ n := len(w.recent)
146+ col := w.Columns[w.Focus]
147+ if len(col.Windows) < 2 {
148+ t.Fatalf("setup: column %v has one window", col.Windows)
149+ }
150+ other := col.Windows[0]
151+ if other == 2 {
152+ other = col.Windows[1]
153+ }
154+ w.FocusID(other)
155+ if got := testing.AllocsPerRun(100, w.noteFocus); got != 0 {
156+ t.Fatalf("noteFocus allocates %v after a same-column change", got)
157+ }
158+ if len(w.recent) != n || w.recent[0] != other {
159+ t.Fatalf("recent %v, want window %d first in place of 2 (%d entries)", w.recent, other, n)
160+ }
161+}
162+
163+// Browsing the overview focuses nothing: the selection moving to another
164+// column, then Escape, leave the recent order as it was.
165+func TestOverviewBrowsingKeepsMRU(t *testing.T) {
166+ c, _, _ := switcherCore(t, 3)
167+ m := c.cur().mon
168+ w := m.Current()
169+ focusNote(w, 1)
170+ focusNote(w, 3)
171+ focusNote(w, 2)
172+ indicatorScene(t, c)
173+ order, recent := w.switchOrder(), slices.Clone(w.recent)
174+ focus := switcherFocus(c)
175+ m.ToggleOverview()
176+ m.OverviewMove(1, 0)
177+ m.OverviewMove(1, 0)
178+ if switcherFocus(c) == focus {
179+ t.Fatal("setup: the overview selection is still on the focused window")
180+ }
181+ indicatorScene(t, c)
182+ // Cancelling would note the original column again: look while browsing.
183+ if !slices.Equal(w.recent, recent) {
184+ t.Fatalf("recent %v while browsing, was %v", w.recent, recent)
185+ }
186+ m.CancelOverview()
187+ indicatorScene(t, c)
188+ if got := w.switchOrder(); !slices.Equal(got, order) || !slices.Equal(w.recent, recent) {
189+ t.Fatalf("order %v recent %v, were %v %v", got, w.recent, order, recent)
190+ }
191+ if switcherFocus(c) != focus {
192+ t.Fatalf("focus %d, want %d", switcherFocus(c), focus)
193+ }
194+}
195+
196+// switcherBinds returns the binds of the switcher tests: the defaults, a
197+// fullscreen bind to interrupt a switch and F9, a switch with no Cmd.
198+func switcherBinds() map[string]string {
199+ return map[string]string{
200+ "Cmd+Tab": string(ActionSwitchColumnNext),
201+ "Cmd+Shift+Tab": string(ActionSwitchColumnPrev),
202+ "Cmd+f": string(ActionToggleFullscreen),
203+ "F9": string(ActionSwitchColumnNext),
204+ }
205+}
206+
207+const (
208+ keyTab = 15
209+ keySuper = 133
210+)
211+
212+// switcherCore is indicatorCore with the switcher binds, up to three
213+// columns on screen and n one-window columns (windows 1..n, n focused),
214+// published once so the configures carry the windows' sizes.
215+func switcherCore(t *testing.T, n int) (*Core, *indicatorClock, <-chan ports.ClientCommand) {
216+ t.Helper()
217+ commands := make(chan ports.ClientCommand, 64)
218+ c, ic := indicatorCoreWith(t, commands)
219+ cfg := c.cfg
220+ cfg.Layout.MaxColumns = 3
221+ cfg.Binds = switcherBinds()
222+ if err := c.apply(cfg); err != nil {
223+ t.Fatal(err)
224+ }
225+ for id := WindowID(1); id <= WindowID(n); id++ {
226+ c.cur().mon.AddWindow(id)
227+ }
228+ indicatorScene(t, c)
229+ return c, ic, commands
230+}
231+
232+// requireClosed fails unless no switcher is open: no monitor holds one, and
233+// the core has neither a switcher screen nor a timer.
234+func requireClosed(t *testing.T, c *Core) {
235+ t.Helper()
236+ for _, sc := range c.screens {
237+ if sc.mon.sw.ws != nil || sc.mon.sw.shown {
238+ t.Fatalf("screen %q still holds a switcher: %+v", sc.name(), sc.mon.sw)
239+ }
240+ }
241+ if c.switcher.sc != nil || c.switcher.timerC != nil || c.switcher.timerStop != nil {
242+ t.Fatalf("switcher screen %v, timer %v", c.switcher.sc != nil, c.switcher.timerC != nil)
243+ }
244+}
245+
246+func tab(mods ports.Mods, pressed bool) ports.KeyEvent {
247+ return ports.KeyEvent{Keysym: "Tab", Keycode: keyTab, Mods: mods, Pressed: pressed}
248+}
249+
250+func shiftTab(pressed bool) ports.KeyEvent {
251+ return ports.KeyEvent{Keysym: "ISO_Left_Tab", Base: "Tab", Keycode: keyTab, Mods: ports.ModSuper | ports.ModShift, Pressed: pressed}
252+}
253+
254+func superKey(mods ports.Mods, pressed bool) ports.KeyEvent {
255+ return ports.KeyEvent{Keysym: "Super_L", Keycode: keySuper, Mods: mods, Pressed: pressed}
256+}
257+
258+func bind(t *testing.T, c *Core, a Action) {
259+ t.Helper()
260+ if err := c.runBind(context.Background(), a); err != nil {
261+ t.Fatal(err)
262+ }
263+}
264+
265+func sendKeys(t *testing.T, c *Core, keys ...ports.KeyEvent) {
266+ t.Helper()
267+ for _, k := range keys {
268+ if err := c.keyEvent(context.Background(), k); err != nil {
269+ t.Fatal(err)
270+ }
271+ }
272+}
273+
274+// lastScene returns the latest published scene.
275+func lastScene(t *testing.T, c *Core) ports.Scene {
276+ t.Helper()
277+ var s []ports.Scene
278+ for len(c.ch.Scenes) > 0 {
279+ s = <-c.ch.Scenes
280+ }
281+ if s == nil {
282+ t.Fatal("no scene published")
283+ }
284+ return s[0]
285+}
286+
287+func switcherFocus(c *Core) WindowID {
288+ id, _ := c.cur().mon.Focused()
289+ return id
290+}
291+
292+// forwarded drains the commands and returns the forwarded keys.
293+func forwarded(ch <-chan ports.ClientCommand) []ports.ForwardKey {
294+ var out []ports.ForwardKey
295+ for len(ch) > 0 {
296+ if v, ok := (<-ch).(ports.ForwardKey); ok {
297+ out = append(out, v)
298+ }
299+ }
300+ return out
301+}
302+
303+func scenePreviews(s ports.Scene) []ports.SceneWindow {
304+ var out []ports.SceneWindow
305+ for _, w := range s.Windows {
306+ if w.Preview > 0 {
307+ out = append(out, w)
308+ }
309+ }
310+ return out
311+}
312+
313+// --- P2.1: commit ---
314+
315+func switcherMonitor(overflow Overflow, n int) (*Monitor, *Workspace) {
316+ m := NewMonitor()
317+ m.SetOutput(300, 200)
318+ m.SetMaxColumns(3)
319+ m.SetOverflow(overflow)
320+ for id := WindowID(1); id <= WindowID(n); id++ {
321+ m.AddWindow(id)
322+ }
323+ return m, m.Current()
324+}
325+
326+func openSwitcher(m *Monitor, at int) {
327+ w := m.Current()
328+ m.sw = switcherState{ws: w, order: w.switchOrder(), at: at}
329+}
330+
331+func TestSwitchCommitFocusesSelectedColumn(t *testing.T) {
332+ m, w := switcherMonitor(OverflowScroll, 3)
333+ openSwitcher(m, 1)
334+ want := m.sw.order[1]
335+ m.commitSwitcher()
336+ if id, _ := w.Focused(); id != want || m.sw.ws != nil {
337+ t.Fatalf("focus %d (want %d), switcher open %v", id, want, m.sw.ws != nil)
338+ }
339+}
340+
341+func TestSwitchCommitTransfersMaximization(t *testing.T) {
342+ for _, overflow := range []Overflow{OverflowCascade, OverflowFixed, OverflowScroll} {
343+ t.Run(string(overflow), func(t *testing.T) {
344+ m, w := switcherMonitor(overflow, 2)
345+ a, b := w.Focus, 1-w.Focus
346+ w.FocusID(w.Columns[a].Windows[0])
347+ w.ToggleFullWidth()
348+ w.noteFocus()
349+ if !w.Columns[a].FullWidth {
350+ t.Fatal("setup: source not maximized")
351+ }
352+ openSwitcher(m, 1)
353+ m.commitSwitcher()
354+ if w.Focus != b || !w.Columns[b].FullWidth || w.Columns[a].FullWidth {
355+ t.Fatalf("focus %d, FullWidth source %v target %v", w.Focus, w.Columns[a].FullWidth, w.Columns[b].FullWidth)
356+ }
357+ if w.policy().equalCells && !w.hiddenByMaximized(a) {
358+ t.Fatal("source not hidden by the maximized target")
359+ }
360+ })
361+ }
362+}
363+
364+func TestSwitchCommitLeavesPinnedFullscreen(t *testing.T) {
365+ m, w := switcherMonitor(OverflowFixed, 2)
366+ src := w.Columns[w.Focus].Windows[0]
367+ w.SetFullscreen(src, true)
368+ if w.fullscreen != src {
369+ t.Fatal("setup: no fullscreen")
370+ }
371+ openSwitcher(m, 1)
372+ target := m.sw.order[1]
373+ m.commitSwitcher()
374+ if w.fullscreen != 0 {
375+ t.Fatalf("fullscreen %d kept", w.fullscreen)
376+ }
377+ if id, _ := w.Focused(); id != target {
378+ t.Fatalf("focus %d, want %d", id, target)
379+ }
380+}
381+
382+func TestSwitchCommitIgnoresGoneWindowAndOtherWorkspace(t *testing.T) {
383+ m, w := switcherMonitor(OverflowScroll, 3)
384+ before, _ := w.Focused()
385+ openSwitcher(m, 1)
386+ m.sw.order[1] = 99 // not in the workspace any more
387+ m.commitSwitcher()
388+ if id, _ := w.Focused(); id != before || m.sw.ws != nil {
389+ t.Fatalf("focus %d, want %d; open %v", id, before, m.sw.ws != nil)
390+ }
391+ openSwitcher(m, 1)
392+ m.sw.ws = &Workspace{}
393+ m.commitSwitcher()
394+ if id, _ := w.Focused(); id != before || m.sw.ws != nil {
395+ t.Fatalf("focus %d for a foreign workspace, want %d", id, before)
396+ }
397+}
398+
399+// --- P2.2: actions and step ---
400+
401+func TestSwitchStepOpensOnPreviousColumnAndCycles(t *testing.T) {
402+ c, ic, _ := switcherCore(t, 3)
403+ c.mods = ports.ModSuper
404+ before := switcherFocus(c)
405+ if err := c.runBind(context.Background(), ActionSwitchColumnNext); err != nil {
406+ t.Fatal(err)
407+ }
408+ sw := &c.cur().mon.sw
409+ if sw.ws == nil || sw.at != 1 || switcherFocus(c) != before {
410+ t.Fatalf("open %v at %d focus %d (was %d)", sw.ws != nil, sw.at, switcherFocus(c), before)
411+ }
412+ if len(ic.timers) != 1 || ic.timers[0] != switcherDelay {
413+ t.Fatalf("timers %v, want one of %v", ic.timers, switcherDelay)
414+ }
415+ bind(t, c, ActionSwitchColumnNext)
416+ if sw.at != 2 {
417+ t.Fatalf("at %d", sw.at)
418+ }
419+ bind(t, c, ActionSwitchColumnNext)
420+ if sw.at != 0 {
421+ t.Fatalf("at %d, want wrap to 0", sw.at)
422+ }
423+ bind(t, c, ActionSwitchColumnPrev)
424+ if sw.at != 2 {
425+ t.Fatalf("at %d, want wrap to 2", sw.at)
426+ }
427+ bind(t, c, ActionSwitchColumnPrev)
428+ if sw.at != 1 {
429+ t.Fatalf("at %d", sw.at)
430+ }
431+ if len(ic.timers) != 1 {
432+ t.Fatalf("timers %v: stepping must not re-arm", ic.timers)
433+ }
434+}
435+
436+func TestSwitchWithoutCmdCommitsAtOnce(t *testing.T) {
437+ c, ic, _ := switcherCore(t, 3)
438+ start := switcherFocus(c)
439+ // F9 is bound to switch-column-next with no Cmd held.
440+ f9 := func(pressed bool) ports.KeyEvent {
441+ return ports.KeyEvent{Keysym: "F9", Keycode: 75, Pressed: pressed}
442+ }
443+ sendKeys(t, c, f9(true), f9(false))
444+ requireClosed(t, c)
445+ if len(ic.timers) != 0 {
446+ t.Fatalf("timers %v armed for a switch with no Cmd", ic.timers)
447+ }
448+ if switcherFocus(c) == start {
449+ t.Fatal("focus did not move")
450+ }
451+ sendKeys(t, c, f9(true), f9(false))
452+ if switcherFocus(c) != start {
453+ t.Fatalf("focus %d, want back on %d", switcherFocus(c), start)
454+ }
455+}
456+
457+func TestSwitchIgnoredWithOneColumnOrOverview(t *testing.T) {
458+ c, _, _ := switcherCore(t, 1)
459+ c.mods = ports.ModSuper
460+ bind(t, c, ActionSwitchColumnNext)
461+ requireClosed(t, c)
462+ c, _, _ = switcherCore(t, 3)
463+ c.cur().mon.ToggleOverview()
464+ before := switcherFocus(c)
465+ bind(t, c, ActionSwitchColumnNext)
466+ requireClosed(t, c)
467+ if switcherFocus(c) != before {
468+ t.Fatal("switcher acted in the overview")
469+ }
470+}
471+
472+// --- P2.3: release commit and timer ---
473+
474+func TestSwitchQuickTapSwapsWithoutCards(t *testing.T) {
475+ c, _, _ := switcherCore(t, 3)
476+ start := switcherFocus(c)
477+ for len(c.ch.Scenes) > 0 {
478+ <-c.ch.Scenes
479+ }
480+ tap := func() {
481+ t.Helper()
482+ sendKeys(t, c,
483+ superKey(ports.ModSuper, true),
484+ tab(ports.ModSuper, true),
485+ tab(ports.ModSuper, false),
486+ superKey(0, false),
487+ )
488+ for len(c.ch.Scenes) > 0 {
489+ if p := scenePreviews((<-c.ch.Scenes)[0]); len(p) > 0 {
490+ t.Fatalf("cards published during a quick tap: %+v", p)
491+ }
492+ }
493+ requireClosed(t, c)
494+ }
495+ tap()
496+ if switcherFocus(c) == start {
497+ t.Fatal("tap did not swap")
498+ }
499+ tap()
500+ if switcherFocus(c) != start {
501+ t.Fatalf("second tap focus %d, want back on %d", switcherFocus(c), start)
502+ }
503+}
504+
505+func TestSwitchHoldCommitsOnReleaseAndForwardsIt(t *testing.T) {
506+ c, _, cmds := switcherCore(t, 3)
507+ start := switcherFocus(c)
508+ order := c.cur().mon.Current().switchOrder()
509+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true), tab(ports.ModSuper, false))
510+ if switcherFocus(c) != start || c.cur().mon.sw.ws == nil {
511+ t.Fatal("focus moved or switcher closed before the release")
512+ }
513+ forwarded(cmds)
514+ sendKeys(t, c, superKey(0, false))
515+ requireClosed(t, c)
516+ if switcherFocus(c) != order[1] {
517+ t.Fatalf("focus %d, want %d", switcherFocus(c), order[1])
518+ }
519+ fk := forwarded(cmds)
520+ if len(fk) != 1 || fk[0].Key.Keysym != "Super_L" || fk[0].Key.Pressed || fk[0].ID != order[1] {
521+ t.Fatalf("release not forwarded to the new focus: %+v", fk)
522+ }
523+}
524+
525+// --- P3.1: keys and gestures ---
526+
527+func TestSwitchEscapeCancelsAndIsNotForwarded(t *testing.T) {
528+ c, _, cmds := switcherCore(t, 3)
529+ start := switcherFocus(c)
530+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true), tab(ports.ModSuper, false))
531+ forwarded(cmds)
532+ esc := ports.KeyEvent{Keysym: "Escape", Keycode: 1, Mods: ports.ModSuper, Pressed: true}
533+ sendKeys(t, c, esc)
534+ esc.Pressed = false
535+ sendKeys(t, c, esc, superKey(0, false))
536+ requireClosed(t, c)
537+ if switcherFocus(c) != start {
538+ t.Fatalf("focus %d, want %d", switcherFocus(c), start)
539+ }
540+ for _, k := range forwarded(cmds) {
541+ if k.Key.Keysym == "Escape" {
542+ t.Fatalf("Escape forwarded: %+v", k)
543+ }
544+ }
545+}
546+
547+func TestSwitchAnotherBindCancelsThenRuns(t *testing.T) {
548+ c, _, _ := switcherCore(t, 3)
549+ start := switcherFocus(c)
550+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
551+ sendKeys(t, c, ports.KeyEvent{Keysym: "f", Keycode: 33, Mods: ports.ModSuper, Pressed: true})
552+ requireClosed(t, c)
553+ if switcherFocus(c) != start {
554+ t.Fatalf("focus %d moved", switcherFocus(c))
555+ }
556+ if c.cur().mon.Current().fullscreen != start {
557+ t.Fatalf("bind not applied: fullscreen %d", c.cur().mon.Current().fullscreen)
558+ }
559+}
560+
561+func TestSwitchShiftTabGoesBackward(t *testing.T) {
562+ c, _, _ := switcherCore(t, 3)
563+ sendKeys(t, c, superKey(ports.ModSuper, true), shiftTab(true))
564+ sw := c.cur().mon.sw
565+ if sw.ws == nil || sw.at != len(sw.order)-1 {
566+ t.Fatalf("open %v at %d of %d", sw.ws != nil, sw.at, len(sw.order))
567+ }
568+ sendKeys(t, c, shiftTab(false), tab(ports.ModSuper, true))
569+ if c.cur().mon.sw.at != 0 {
570+ t.Fatalf("at %d, want 0 after next", c.cur().mon.sw.at)
571+ }
572+}
573+
574+func TestSwitchSwipeBeginCancels(t *testing.T) {
575+ c, _, _ := switcherCore(t, 3)
576+ start := switcherFocus(c)
577+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
578+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
579+ requireClosed(t, c)
580+ if switcherFocus(c) != start {
581+ t.Fatalf("focus %d, want %d", switcherFocus(c), start)
582+ }
583+}
584+
585+func TestSwitchSecurityChangeCancels(t *testing.T) {
586+ c, _, _ := switcherCore(t, 3)
587+ state := ports.SecurityState{Generation: 1}
588+ gate := portsmocks.NewMockSessionSecurity(t)
589+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }).Maybe()
590+ c.opts.Security = gate
591+ start := switcherFocus(c)
592+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
593+ state = ports.SecurityState{Generation: 2}
594+ if !c.syncSecurity() {
595+ t.Fatal("security change not seen")
596+ }
597+ requireClosed(t, c)
598+ if switcherFocus(c) != start {
599+ t.Fatalf("focus %d, want %d", switcherFocus(c), start)
600+ }
601+}
602+
603+func TestSwitchCmdKeyFollowsConfig(t *testing.T) {
604+ c, _, _ := switcherCore(t, 3)
605+ cfg := c.cfg
606+ cfg.Keyboard.CmdKey = "alt"
607+ if err := c.apply(cfg); err != nil {
608+ t.Fatal(err)
609+ }
610+ order := c.cur().mon.Current().switchOrder()
611+ sendKeys(t, c, tab(ports.ModAlt, true))
612+ if c.cur().mon.sw.ws == nil {
613+ t.Fatal("Alt+Tab did not open the switcher")
614+ }
615+ sendKeys(t, c, ports.KeyEvent{Keysym: "Alt_L", Keycode: 64, Pressed: false})
616+ requireClosed(t, c)
617+ if switcherFocus(c) != order[1] {
618+ t.Fatalf("focus %d, want %d", switcherFocus(c), order[1])
619+ }
620+}
621+
622+// --- P3.2: layout ---
623+
624+func TestSwitcherLayoutCards(t *testing.T) {
625+ c, _, _ := switcherCore(t, 3)
626+ c.cfg.Floating.Dim = 0.4
627+ // A wider focused column: the cards have different aspect ratios.
628+ c.cur().mon.Current().ResizeColumn(30)
629+ indicatorScene(t, c)
630+ area := c.cur().mon.Current().overviewArea()
631+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
632+ if s := indicatorScene(t, c); len(scenePreviews(s)) != 0 || c.cur().mon.sw.shown {
633+ t.Fatalf("cards before the delay: %+v", s.Windows)
634+ }
635+ order := c.cur().mon.sw.order
636+ if !c.switcherTick() || !c.cur().mon.sw.shown || c.switcher.timerC != nil {
637+ t.Fatal("the tick did not show the cards and clear the timer")
638+ }
639+ s := indicatorScene(t, c)
640+ cards := scenePreviews(s)
641+ if len(cards) != 3 {
642+ t.Fatalf("%d cards: %+v", len(cards), s.Windows)
643+ }
644+ focused := 0
645+ distinct := false
646+ var centers []int
647+ for k, card := range cards {
648+ if card.ID != order[k] {
649+ t.Fatalf("card %d is window %d, want %d (MRU order)", k, card.ID, order[k])
650+ }
651+ // The boxes are equal: the cards' centres are evenly spaced.
652+ centers = append(centers, card.Rect.X+card.Rect.W/2)
653+ // A card is its window's client size, scaled down uniformly.
654+ sent := c.configures.sent[card.ID]
655+ if sent.Width <= 0 || sent.Height <= 0 || card.Preview <= 0 || card.Preview >= 1 {
656+ t.Fatalf("card %d: client %dx%d, preview %v", card.ID, sent.Width, sent.Height, card.Preview)
657+ }
658+ if d := card.Rect.W - int(math.Round(float64(sent.Width)*card.Preview)); d < -1 || d > 1 {
659+ t.Fatalf("card %d is %dx%d for a %dx%d client at %v", card.ID, card.Rect.W, card.Rect.H, sent.Width, sent.Height, card.Preview)
660+ }
661+ if d := card.Rect.H - int(math.Round(float64(sent.Height)*card.Preview)); d < -1 || d > 1 {
662+ t.Fatalf("card %d is %dx%d for a %dx%d client at %v", card.ID, card.Rect.W, card.Rect.H, sent.Width, sent.Height, card.Preview)
663+ }
664+ if first := c.configures.sent[order[0]]; sent.Width != first.Width {
665+ distinct = true
666+ }
667+ if card.Focused {
668+ focused++
669+ if card.ID != order[c.cur().mon.sw.at] {
670+ t.Fatalf("focused card %d is not the selected %d", card.ID, order[c.cur().mon.sw.at])
671+ }
672+ }
673+ }
674+ if !distinct {
675+ t.Fatal("setup: every window has the same size, so the aspect ratios prove nothing")
676+ }
677+ if d := (centers[1] - centers[0]) - (centers[2] - centers[1]); d < -2 || d > 2 {
678+ t.Fatalf("boxes not equal: card centres %v", centers)
679+ }
680+ if d := centers[0] + centers[2] - 2*(area.X+area.W/2); d < -2 || d > 2 {
681+ t.Fatalf("row not centered in %+v: card centres %v", area, centers)
682+ }
683+ if focused != 1 {
684+ t.Fatalf("%d focused cards", focused)
685+ }
686+ if len(s.Separators) != 4 {
687+ t.Fatalf("separators %+v, want the 4 lines of the outline", s.Separators)
688+ }
689+ if s.Dim != 0.4 {
690+ t.Fatalf("dim %v", s.Dim)
691+ }
692+ if s.TileInset != (ports.Insets{}) {
693+ t.Fatalf("tile inset %+v", s.TileInset)
694+ }
695+ for _, w := range s.Windows {
696+ if w.Preview == 0 && !w.Hidden {
697+ t.Fatalf("window %d drawn under the cards", w.ID)
698+ }
699+ }
700+ // A commit gives the workspace back.
701+ sendKeys(t, c, superKey(0, false))
702+ if s := lastScene(t, c); len(scenePreviews(s)) != 0 {
703+ t.Fatalf("cards after the commit: %+v", s.Windows)
704+ }
705+}
706+
707+func TestSwitcherLayoutHidesOtherWindowsOfColumns(t *testing.T) {
708+ // Windows 10 and 11 are on a second workspace.
709+ c, _ := twoWorkspaces(t)
710+ m := c.cur().mon
711+ m.AddWindow(4)
712+ w := m.Current()
713+ w.ConsumeOrExpel(-1)
714+ m.AddFloating(9, 100, 80)
715+ if len(w.Columns) != 3 || w.columnWindows() != 4 || len(w.Floats) != 1 {
716+ t.Fatalf("setup: %d columns, %d windows, %d floats", len(w.Columns), w.columnWindows(), len(w.Floats))
717+ }
718+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
719+ c.switcherTick()
720+ s := indicatorScene(t, c)
721+ reps := map[WindowID]bool{}
722+ for _, id := range m.sw.order {
723+ reps[id] = true
724+ }
725+ seen := map[WindowID]bool{}
726+ for _, sw := range s.Windows {
727+ seen[sw.ID] = true
728+ if reps[sw.ID] {
729+ if sw.Preview == 0 || sw.Hidden {
730+ t.Fatalf("representative %d not a card: %+v", sw.ID, sw)
731+ }
732+ } else if !sw.Hidden {
733+ t.Fatalf("window %d not hidden: %+v", sw.ID, sw)
734+ }
735+ }
736+ // The other window of a column, the float and the other workspace's
737+ // windows are all in the scene, hidden.
738+ for _, id := range []WindowID{4, 9, 10, 11} {
739+ if !seen[id] {
740+ t.Fatalf("window %d missing from the scene (hidden windows must be listed): %+v", id, s.Windows)
741+ }
742+ }
743+}
744+
745+// While the cards show, the screen draws previews: the tiles do not stop at
746+// the panels, a card takes no pointer, nothing is
747+// dragged, and a window mapping or unmapping behind them does not animate.
748+func TestSwitcherShownIsAPreview(t *testing.T) {
749+ c, ic, _ := switcherCore(t, 3)
750+ c.cfg.Animations.On = true
751+ m := c.cur().mon
752+ // A bar reserves the top of the output: tiles stop below it.
753+ c.setLayers([]ports.LayerSurface{{ID: 90, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Width: 300, Height: 20, ExclusiveZone: 20}})
754+ if s := indicatorScene(t, c); s.TileInset.Top != 20 {
755+ t.Fatalf("setup: tile inset %+v, want the bar's 20", s.TileInset)
756+ }
757+ sizes := map[WindowID]ports.ConfigureWindow{}
758+ for id, v := range c.configures.sent {
759+ sizes[id] = v
760+ }
761+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
762+ c.switcherTick()
763+ indicatorScene(t, c)
764+ // The cards fly in, and the motions survive a publish.
765+ if len(c.cur().rects) == 0 {
766+ t.Fatal("no card motion after the show")
767+ }
768+ settle := func() {
769+ t.Helper()
770+ for range 200 {
771+ ic.now = ic.now.Add(16 * time.Millisecond)
772+ if err := c.step(context.Background(), nil); err != nil {
773+ t.Fatal(err)
774+ }
775+ }
776+ lastScene(t, c)
777+ if len(c.cur().rects) != 0 || c.animating() {
778+ t.Fatalf("motions did not settle: %d rects", len(c.cur().rects))
779+ }
780+ }
781+ settle()
782+ if !m.switcherShown() {
783+ t.Fatal("setup: cards not shown")
784+ }
785+ s := indicatorScene(t, c)
786+ if len(scenePreviews(s)) != 3 {
787+ t.Fatalf("%d cards", len(scenePreviews(s)))
788+ }
789+ // A card keeps its client's real size.
790+ for _, id := range m.sw.order {
791+ if got := c.configures.sent[id]; got.Width != sizes[id].Width || got.Height != sizes[id].Height {
792+ t.Fatalf("window %d configured %dx%d under its card, was %dx%d", id, got.Width, got.Height, sizes[id].Width, sizes[id].Height)
793+ }
794+ }
795+ if s.TileInset != (ports.Insets{}) {
796+ t.Fatalf("tile inset %+v while the cards show", s.TileInset)
797+ }
798+ // The pointer finds no window under a card.
799+ card := m.sw.order[1]
800+ x, y := cardCenter(t, c, card)
801+ if id, _, _ := c.hit(x, y); id != 0 {
802+ t.Fatalf("hit window %d under a card", id)
803+ }
804+ if _, _, ok := c.draggable(card); ok {
805+ t.Fatal("a card can be dragged")
806+ }
807+ if tgt := c.dropAt(card, x, y); tgt.kind != dropNone {
808+ t.Fatalf("a drop target %+v under the cards", tgt)
809+ }
810+ // A map and an unmap behind the cards do not animate.
811+ c.mapWindow(ports.WindowMapped{ID: 20})
812+ if len(c.cur().rects) != 0 {
813+ t.Fatalf("a map behind the cards started motions: %+v", c.cur().rects)
814+ }
815+ gone := m.sw.order[2]
816+ c.unmapWindow(ports.WindowUnmapped{ID: gone})
817+ if len(c.cur().rects) != 0 {
818+ t.Fatalf("an unmap behind the cards started motions: %+v", c.cur().rects)
819+ }
820+ if m.Current().columnOf(gone) >= 0 || len(m.sw.order) != 2 {
821+ t.Fatalf("window %d not removed from the cards: %v", gone, m.sw.order)
822+ }
823+ // The workspace comes back with the panel inset.
824+ sendKeys(t, c, superKey(0, false))
825+ settle()
826+ if s := indicatorScene(t, c); s.TileInset.Top != 20 || len(scenePreviews(s)) != 0 {
827+ t.Fatalf("after the commit: tile inset %+v, %d cards", s.TileInset, len(scenePreviews(s)))
828+ }
829+}
830+
831+// The predicates that choose between the workspace and its previews follow
832+// the cards as they follow the overview: the frame, a covering fullscreen
833+// and the capture state.
834+func TestSwitcherShownPreviewPredicates(t *testing.T) {
835+ c, _, _ := switcherCore(t, 3)
836+ sc := c.cur()
837+ m := sc.mon
838+ w := m.Current()
839+ w.Overflow = OverflowFixed // a fullscreen window covers whatever the scroll
840+ w.SetSize(100, 100) // the workspace frame is smaller than the output
841+ tile := w.Columns[w.Focus].Windows[0]
842+ w.SetFullscreen(tile, true)
843+ indicatorScene(t, c)
844+ c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: w.ID}}}
845+ type state struct {
846+ frameIsOutput, covers, captureHidden bool
847+ captureShown uint64
848+ }
849+ read := func() state {
850+ r, reason := c.capResolve(capTarget{workspace: w.ID})
851+ if reason != ports.CaptureReasonNone {
852+ t.Fatalf("capture target: %v", reason)
853+ }
854+ var shown uint64
855+ if cs := c.captureSceneFor(sc, &capView{}); cs != nil {
856+ shown = cs.Shown
857+ }
858+ return state{m.Frame() == m.Output(), hasFullscreen(sc), r.hidden, shown}
859+ }
860+ if got, want := read(), (state{false, true, false, w.ID}); got != want {
861+ t.Fatalf("closed: %+v, want %+v", got, want)
862+ }
863+ c.mods = ports.ModSuper
864+ bind(t, c, ActionSwitchColumnNext)
865+ c.switcherTick()
866+ if !m.switcherShown() {
867+ t.Fatal("setup: cards not shown")
868+ }
869+ if got, want := read(), (state{true, false, true, 0}); got != want {
870+ t.Fatalf("shown: %+v, want %+v", got, want)
871+ }
872+}
873+
874+// A card's window with no configure sent yet (it mapped since the last
875+// publish) is sized from its real client rect, not from the card.
876+func TestSwitcherCardOfUnconfiguredWindowKeepsRealSize(t *testing.T) {
877+ c, _, _ := switcherCore(t, 3)
878+ m := c.cur().mon
879+ id := m.Current().switchOrder()[1]
880+ c.configures.forget(id) // the card falls back to the usable size
881+ c.mods = ports.ModSuper
882+ bind(t, c, ActionSwitchColumnNext)
883+ c.configures.forget(id) // the bind's publish configured it again
884+ c.switcherTick()
885+ indicatorScene(t, c)
886+ var real Placement
887+ for _, p := range m.Current().Layout() {
888+ if p.ID == id {
889+ real = p
890+ }
891+ }
892+ want := c.clientRect(real)
893+ if got := c.configures.sent[id]; got.Width != want.W || got.Height != want.H || want.W == 0 {
894+ t.Fatalf("window %d configured %dx%d under its card, real client %dx%d", id, got.Width, got.Height, want.W, want.H)
895+ }
896+}
897+
898+// A focus change behind the cards (the real focus, not the selection) is not
899+// recorded as a use of the column until the cards are gone.
900+func TestSwitcherShownDoesNotNoteFocus(t *testing.T) {
901+ c, _, _ := switcherCore(t, 3)
902+ w := c.cur().mon.Current()
903+ c.mods = ports.ModSuper
904+ bind(t, c, ActionSwitchColumnNext)
905+ c.switcherTick()
906+ before := slices.Clone(w.recent)
907+ w.FocusID(c.cur().mon.sw.order[2])
908+ indicatorScene(t, c)
909+ if !slices.Equal(w.recent, before) {
910+ t.Fatalf("recent %v, was %v: noted while the cards show", w.recent, before)
911+ }
912+}
913+
914+// Showing the cards drops the motions of windows they hide.
915+func TestSwitcherShowDropsMotionsOfHiddenWindows(t *testing.T) {
916+ c, _, _ := switcherCore(t, 3)
917+ c.cfg.Animations.On = true
918+ sc := c.cur()
919+ c.mapWindow(ports.WindowMapped{ID: 4})
920+ w := sc.mon.Current()
921+ w.ConsumeOrExpel(-1) // 4 joins 3's column
922+ w.FocusID(3) // 3 stands for the column; 4 is hidden
923+ if _, ok := sc.rects[4]; !ok {
924+ t.Fatal("setup: no entrance motion for window 4")
925+ }
926+ c.mods = ports.ModSuper
927+ bind(t, c, ActionSwitchColumnNext)
928+ if _, ok := sc.rects[4]; !ok {
929+ t.Fatal("setup: motion dropped by the bind")
930+ }
931+ c.switcherTick()
932+ if _, ok := sc.rects[4]; ok {
933+ t.Fatalf("a hidden window keeps its motion: %+v", sc.rects[4])
934+ }
935+}
936+
937+func TestSwitcherCardsKeepKeysOnRealFocus(t *testing.T) {
938+ c, _, cmds := switcherCore(t, 3)
939+ focus := switcherFocus(c)
940+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
941+ c.switcherTick()
942+ indicatorScene(t, c)
943+ forwarded(cmds)
944+ if c.keyboardFocus() != focus {
945+ t.Fatalf("keyboard focus %d, want %d", c.keyboardFocus(), focus)
946+ }
947+ sendKeys(t, c, ports.KeyEvent{Keysym: "x", Keycode: 45, Mods: ports.ModSuper, Pressed: true})
948+ // An unbound key goes to the real focus (Cmd+x has no bind here).
949+ fk := forwarded(cmds)
950+ if len(fk) != 1 || fk[0].ID != focus || fk[0].Key.Keysym != "x" {
951+ t.Fatalf("forwarded %+v, want x to %d", fk, focus)
952+ }
953+}
954+
955+// --- P3.3: click ---
956+
957+func cardCenter(t *testing.T, c *Core, id WindowID) (float64, float64) {
958+ t.Helper()
959+ for _, p := range c.cur().shownLayout() {
960+ if p.ID == id && p.Preview > 0 {
961+ return float64(p.Rect.X + p.Rect.W/2), float64(p.Rect.Y + p.Rect.H/2)
962+ }
963+ }
964+ t.Fatalf("no card for %d", id)
965+ return 0, 0
966+}
967+
968+func TestSwitcherClickOnCardCommits(t *testing.T) {
969+ c, _, cmds := switcherCore(t, 3)
970+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
971+ c.switcherTick()
972+ indicatorScene(t, c)
973+ target := c.cur().mon.sw.order[2]
974+ c.cursorX, c.cursorY = cardCenter(t, c, target)
975+ forwarded(cmds)
976+ if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {
977+ t.Fatal(err)
978+ }
979+ requireClosed(t, c)
980+ if switcherFocus(c) != target {
981+ t.Fatalf("focus %d, want %d", switcherFocus(c), target)
982+ }
983+ if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110}); err != nil {
984+ t.Fatal(err)
985+ }
986+ for len(cmds) > 0 {
987+ if v, ok := (<-cmds).(ports.PointerButtonTo); ok {
988+ t.Fatalf("click reached a client: %+v", v)
989+ }
990+ }
991+}
992+
993+// A click lands on a card where it is drawn, while its motion still runs.
994+func TestSwitcherClickOnMovingCardCommits(t *testing.T) {
995+ c, ic, _ := switcherCore(t, 3)
996+ c.cfg.Animations.On = true
997+ sc := c.cur()
998+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
999+ c.switcherTick()
1000+ indicatorScene(t, c)
1001+ ic.now = ic.now.Add(16 * time.Millisecond)
1002+ if err := c.step(context.Background(), nil); err != nil {
1003+ t.Fatal(err)
1004+ }
1005+ lastScene(t, c)
1006+ target := sc.mon.sw.order[2]
1007+ if len(sc.rects) == 0 || !c.animating() {
1008+ t.Fatalf("setup: %d card motions, animating %v", len(sc.rects), c.animating())
1009+ }
1010+ var settled, shown Rect
1011+ for _, p := range sc.settledLayout {
1012+ if p.ID == target {
1013+ settled = p.Rect
1014+ }
1015+ }
1016+ for _, p := range sc.shownLayout() {
1017+ if p.ID == target {
1018+ shown = p.Rect
1019+ }
1020+ }
1021+ if settled == shown {
1022+ t.Fatalf("setup: card %d shown at its settled rect %+v", target, shown)
1023+ }
1024+ c.cursorX, c.cursorY = cardCenter(t, c, target)
1025+ if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {
1026+ t.Fatal(err)
1027+ }
1028+ requireClosed(t, c)
1029+ if switcherFocus(c) != target {
1030+ t.Fatalf("focus %d, want %d", switcherFocus(c), target)
1031+ }
1032+}
1033+
1034+// Cancelling while the switch is held leaves nothing for the delayed show.
1035+func TestSwitcherTickAfterCancelDoesNothing(t *testing.T) {
1036+ c, _, _ := switcherCore(t, 3)
1037+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1038+ if !c.switching() {
1039+ t.Fatal("setup: no switcher open")
1040+ }
1041+ c.cancelSwitcher()
1042+ if c.switcherTick() {
1043+ t.Fatal("switcherTick reports a switcher after the cancel")
1044+ }
1045+ requireClosed(t, c)
1046+}
1047+
1048+func TestSwitcherClickElsewhereCancels(t *testing.T) {
1049+ c, _, _ := switcherCore(t, 3)
1050+ start := switcherFocus(c)
1051+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1052+ c.switcherTick()
1053+ indicatorScene(t, c)
1054+ c.cursorX, c.cursorY = 1, 1
1055+ if err := c.pointerButton(context.Background(), ports.PointerButton{Button: 0x110, Pressed: true}); err != nil {
1056+ t.Fatal(err)
1057+ }
1058+ requireClosed(t, c)
1059+ if switcherFocus(c) != start {
1060+ t.Fatalf("focus %d, want %d", switcherFocus(c), start)
1061+ }
1062+}
1063+
1064+func TestSwitcherPressBeforeCardsCancelsAndContinues(t *testing.T) {
1065+ c, _, _ := switcherCore(t, 3)
1066+ start := switcherFocus(c)
1067+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1068+ handled, err := c.switcherClick(context.Background(), 0x110)
1069+ if err != nil || handled {
1070+ t.Fatalf("handled %v err %v", handled, err)
1071+ }
1072+ requireClosed(t, c)
1073+ if switcherFocus(c) != start {
1074+ t.Fatal("press did not cancel")
1075+ }
1076+}
1077+
1078+// --- P3.4: robustness ---
1079+
1080+func TestSwitcherClosingSelectedWindowKeepsSelectionValid(t *testing.T) {
1081+ c, _, _ := switcherCore(t, 3)
1082+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1083+ c.switcherTick()
1084+ sw := &c.cur().mon.sw
1085+ gone := sw.order[sw.at]
1086+ c.cur().mon.RemoveWindow(gone)
1087+ if sw.ws == nil || len(sw.order) != 2 || sw.at < 0 || sw.at >= len(sw.order) || slices.Contains(sw.order, gone) {
1088+ t.Fatalf("open %v order %v at %d", sw.ws != nil, sw.order, sw.at)
1089+ }
1090+ s := indicatorScene(t, c)
1091+ if n := len(scenePreviews(s)); n != 2 {
1092+ t.Fatalf("%d cards after a close", n)
1093+ }
1094+ want := sw.order[sw.at]
1095+ sendKeys(t, c, superKey(0, false))
1096+ if switcherFocus(c) != want {
1097+ t.Fatalf("focus %d, want neighbour %d", switcherFocus(c), want)
1098+ }
1099+}
1100+
1101+// Closing a window ahead of the selection shifts the cards left: the
1102+// selection follows its card, so it still stands on the same window.
1103+func TestSwitcherClosingBeforeSelectionKeepsCard(t *testing.T) {
1104+ c, _, _ := switcherCore(t, 4)
1105+ c.mods = ports.ModSuper
1106+ bind(t, c, ActionSwitchColumnNext)
1107+ sw := &c.cur().mon.sw
1108+ if sw.at != 1 || len(sw.order) != 4 {
1109+ t.Fatalf("setup: at %d of %d", sw.at, len(sw.order))
1110+ }
1111+ selected := sw.order[sw.at]
1112+ c.cur().mon.RemoveWindow(sw.order[0])
1113+ if sw.ws == nil || len(sw.order) != 3 {
1114+ t.Fatalf("order %v after the close", sw.order)
1115+ }
1116+ if sw.at != 0 || sw.order[sw.at] != selected {
1117+ t.Fatalf("selection moved from %d: order %v at %d, want at 0", selected, sw.order, sw.at)
1118+ }
1119+ sendKeys(t, c, superKey(0, false))
1120+ if switcherFocus(c) != selected {
1121+ t.Fatalf("focus %d after the commit, want %d", switcherFocus(c), selected)
1122+ }
1123+}
1124+
1125+func TestSwitcherClosingDownToOneColumnClosesIt(t *testing.T) {
1126+ c, _, _ := switcherCore(t, 2)
1127+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1128+ c.switcherTick()
1129+ c.cur().mon.RemoveWindow(c.cur().mon.sw.order[1])
1130+ if sw := c.cur().mon.sw; sw.ws != nil || sw.shown {
1131+ t.Fatal("switcher still open with one column")
1132+ }
1133+ if s := indicatorScene(t, c); len(scenePreviews(s)) != 0 {
1134+ t.Fatalf("cards left: %+v", s.Windows)
1135+ }
1136+ // The timer fires on a closed switcher: harmless, and it clears the
1137+ // core's side.
1138+ if c.switcherTick() {
1139+ t.Fatal("tick on a closed switcher")
1140+ }
1141+ requireClosed(t, c)
1142+ sendKeys(t, c, superKey(0, false))
1143+}
1144+
1145+func TestSwitcherOutputRemovalCancels(t *testing.T) {
1146+ c, _, _ := switcherCore(t, 3)
1147+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
1148+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1149+ c.switcherTick()
1150+ sc := c.switcher.sc
1151+ if sc == nil {
1152+ t.Fatal("no switcher screen")
1153+ }
1154+ c.removeScreen(sc.name())
1155+ requireClosed(t, c)
1156+ if sc.mon.sw.ws != nil {
1157+ t.Fatal("switcher survived its output")
1158+ }
1159+ if err := c.publish(context.Background()); err != nil {
1160+ t.Fatal(err)
1161+ }
1162+ if c.switcherTick() {
1163+ t.Fatal("tick after the output went")
1164+ }
1165+}
1166+
1167+func TestSwitcherFocusMovingToAnotherOutputReopensThere(t *testing.T) {
1168+ c, _, _ := switcherCore(t, 3)
1169+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
1170+ first := c.cur()
1171+ for id := WindowID(11); id <= 13; id++ {
1172+ c.screens[c.screenIndex("B")].mon.AddWindow(id)
1173+ }
1174+ indicatorScene(t, c)
1175+ c.mods = ports.ModSuper
1176+ bind(t, c, ActionSwitchColumnNext)
1177+ if c.switcher.sc != first {
1178+ t.Fatal("switcher not on the focused screen")
1179+ }
1180+ c.focusScreen = c.screenIndex("B")
1181+ bind(t, c, ActionSwitchColumnNext)
1182+ if first.mon.sw.ws != nil {
1183+ t.Fatal("old switcher left open")
1184+ }
1185+ if c.switcher.sc != c.cur() || c.cur().mon.sw.ws == nil || c.cur().mon.sw.at != 1 {
1186+ t.Fatalf("new switcher on %v open %v", c.switcher.sc == c.cur(), c.cur().mon.sw.ws != nil)
1187+ }
1188+}
1189+
1190+// With animations on, the cards fly in and the commit flies them back; the
1191+// windows end where the layout puts them, with no card left.
1192+func TestSwitcherAnimatedHoldAndCommit(t *testing.T) {
1193+ c, ic, _ := switcherCore(t, 3)
1194+ c.cfg.Animations.On = true
1195+ target := c.cur().mon.Current().switchOrder()[1]
1196+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1197+ c.switcherTick()
1198+ if err := c.publish(context.Background()); err != nil {
1199+ t.Fatal(err)
1200+ }
1201+ if len(scenePreviews(lastScene(t, c))) != 3 {
1202+ t.Fatal("cards not shown")
1203+ }
1204+ sendKeys(t, c, superKey(0, false))
1205+ for range 200 {
1206+ ic.now = ic.now.Add(16 * time.Millisecond)
1207+ if err := c.step(context.Background(), nil); err != nil {
1208+ t.Fatal(err)
1209+ }
1210+ }
1211+ s := lastScene(t, c)
1212+ if len(scenePreviews(s)) != 0 || switcherFocus(c) != target {
1213+ t.Fatalf("cards %d, focus %d (want %d)", len(scenePreviews(s)), switcherFocus(c), target)
1214+ }
1215+ if n := len(c.cur().rects); n != 0 || c.animating() {
1216+ t.Fatalf("motions left: %d rect motions, animating %v", n, c.animating())
1217+ }
1218+}
1219+
1220+// --- review fixes ---
1221+
1222+// twoWorkspaces is a switcher core whose second workspace holds two
1223+// windows (10, 11) and is not on screen.
1224+func twoWorkspaces(t *testing.T) (*Core, *Workspace) {
1225+ t.Helper()
1226+ c, _, _ := switcherCore(t, 3)
1227+ c.cfg.Floating.Dim = 0.4
1228+ m := c.cur().mon
1229+ first := m.Current()
1230+ m.Focus(1)
1231+ m.AddWindow(10)
1232+ m.AddWindow(11)
1233+ other := m.Current()
1234+ m.show(first)
1235+ if other == first || m.Current() != first {
1236+ t.Fatal("workspaces not set up")
1237+ }
1238+ indicatorScene(t, c)
1239+ return c, other
1240+}
1241+
1242+func TestSwitcherShownDropsWhenAnotherWorkspaceComesOnScreen(t *testing.T) {
1243+ c, other := twoWorkspaces(t)
1244+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1245+ c.switcherTick()
1246+ if s := indicatorScene(t, c); len(scenePreviews(s)) != 3 {
1247+ t.Fatalf("cards not shown: %+v", s.Windows)
1248+ }
1249+ // An xdg-activation request for a window of the other workspace.
1250+ if err := c.activate(context.Background(), 10); err != nil {
1251+ t.Fatal(err)
1252+ }
1253+ m := c.cur().mon
1254+ if m.Current() != other || m.previewing() {
1255+ t.Fatalf("on screen %v, previewing %v", m.Current() == other, m.previewing())
1256+ }
1257+ s := indicatorScene(t, c)
1258+ if len(scenePreviews(s)) != 0 || s.Dim != 0 || s.DimBehind {
1259+ t.Fatalf("scene still dimmed or previewing: dim %v previews %+v", s.Dim, scenePreviews(s))
1260+ }
1261+ requireClosed(t, c)
1262+ var any bool
1263+ for _, p := range c.cur().shownLayout() {
1264+ if p.ID == 10 || p.ID == 11 {
1265+ x, y := float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2)
1266+ if id, _, _ := c.hit(x, y); id != p.ID {
1267+ t.Fatalf("hit %d at window %d", id, p.ID)
1268+ }
1269+ any = true
1270+ }
1271+ }
1272+ if !any {
1273+ t.Fatal("workspace windows not laid out")
1274+ }
1275+ // The next switch opens fresh on the new workspace.
1276+ c.mods = ports.ModSuper
1277+ bind(t, c, ActionSwitchColumnNext)
1278+ if sw := m.sw; sw.ws == nil || sw.ws != other || sw.at != 1 {
1279+ t.Fatalf("open %v on other %v at %d", sw.ws != nil, sw.ws == other, sw.at)
1280+ }
1281+}
1282+
1283+func TestSwitcherStepOnStaleWorkspaceOpensFresh(t *testing.T) {
1284+ c, other := twoWorkspaces(t)
1285+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1286+ c.switcherTick()
1287+ // Shown on screen, then the workspace changes with no publish between.
1288+ c.cur().mon.show(other)
1289+ if c.cur().mon.previewing() {
1290+ t.Fatal("stale cards still count as previews")
1291+ }
1292+ sendKeys(t, c, tab(ports.ModSuper, true))
1293+ sw := c.cur().mon.sw
1294+ if sw.ws == nil || sw.ws != other || sw.shown || sw.at != 1 {
1295+ t.Fatalf("open %v on other %v shown %v at %d", sw.ws != nil, sw.ws == other, sw.shown, sw.at)
1296+ }
1297+ // A release on a stale switcher commits nothing and closes it.
1298+ c.cur().mon.show(c.cur().mon.Workspaces[0])
1299+ focus := switcherFocus(c)
1300+ sendKeys(t, c, superKey(0, false))
1301+ requireClosed(t, c)
1302+ if switcherFocus(c) != focus {
1303+ t.Fatal("stale switcher survived the release")
1304+ }
1305+}
1306+
1307+// The delay passes after another workspace came on screen with no publish
1308+// in between: the tick drops the switcher instead of showing stale cards.
1309+func TestSwitcherTickOnStaleWorkspaceCancels(t *testing.T) {
1310+ c, other := twoWorkspaces(t)
1311+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1312+ m := c.cur().mon
1313+ if m.sw.ws == nil {
1314+ t.Fatal("switcher not open")
1315+ }
1316+ m.show(other)
1317+ if !c.switcherTick() {
1318+ t.Fatal("tick on a stale workspace asks for no scene")
1319+ }
1320+ requireClosed(t, c)
1321+}
1322+
1323+func TestSwitchIgnoredDuringDrag(t *testing.T) {
1324+ c, _, _ := switcherCore(t, 3)
1325+ start := switcherFocus(c)
1326+ d := &dragState{id: start, button: btnLeft}
1327+ c.drag = d
1328+ c.buttons[btnLeft] = true
1329+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1330+ requireClosed(t, c)
1331+ if c.drag != d || switcherFocus(c) != start {
1332+ t.Fatal("drag disturbed")
1333+ }
1334+ sendKeys(t, c, ports.KeyEvent{Keysym: "Escape", Keycode: 1, Mods: ports.ModSuper, Pressed: true})
1335+ if c.drag != nil {
1336+ t.Fatal("Escape did not cancel the drag")
1337+ }
1338+}
1339+
1340+// Another bind closing the shown cards still flies them back: the snapshot
1341+// is taken while they are drawn, so the windows move from the card rects.
1342+func TestSwitchBindOverShownCardsFliesBack(t *testing.T) {
1343+ c, ic, _ := switcherCore(t, 3)
1344+ c.cfg.Animations.On = true
1345+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1346+ c.switcherTick()
1347+ for range 200 {
1348+ ic.now = ic.now.Add(16 * time.Millisecond)
1349+ if err := c.step(context.Background(), nil); err != nil {
1350+ t.Fatal(err)
1351+ }
1352+ }
1353+ sc := c.cur()
1354+ cards := map[WindowID]Rect{}
1355+ for _, p := range sc.shownLayout() {
1356+ if p.Preview > 0 {
1357+ cards[p.ID] = p.Rect
1358+ }
1359+ }
1360+ if len(cards) != 3 || len(sc.rects) != 0 {
1361+ t.Fatalf("%d cards, %d motions: the cards did not settle", len(cards), len(sc.rects))
1362+ }
1363+ bind(t, c, ActionFocusColumnLeft)
1364+ if c.switching() || sc.mon.sw.shown {
1365+ t.Fatal("the bind left the switcher open")
1366+ }
1367+ settled := map[WindowID]Rect{}
1368+ for _, p := range sc.mon.Layout() {
1369+ settled[p.ID] = p.Rect
1370+ }
1371+ for id, card := range cards {
1372+ rm, ok := sc.rects[id]
1373+ if !ok || !rm.scale {
1374+ t.Fatalf("window %d has no fly-back motion", id)
1375+ }
1376+ if got := rm.apply(settled[id]); got != card {
1377+ t.Fatalf("window %d flies back from %+v, want its card %+v", id, got, card)
1378+ }
1379+ }
1380+}
1381+
1382+func TestSwitcherClosingSelectedWindowPicksColumnsNextWindow(t *testing.T) {
1383+ m := NewMonitor()
1384+ m.SetOutput(300, 200)
1385+ m.SetMaxColumns(3)
1386+ for id := WindowID(1); id <= 4; id++ {
1387+ m.AddWindow(id)
1388+ }
1389+ w := m.Current()
1390+ w.FocusID(3)
1391+ w.ConsumeOrExpel(-1) // columns [1] [2 3] [4]
1392+ if len(w.Columns) != 3 || len(w.Columns[1].Windows) != 2 {
1393+ t.Fatalf("columns %+v", w.Columns)
1394+ }
1395+ w.FocusID(1)
1396+ openSwitcher(m, 0)
1397+ at := slices.IndexFunc(m.sw.order, func(id WindowID) bool { return w.columnOf(id) == 1 })
1398+ m.sw.at = at
1399+ gone := m.sw.order[at]
1400+ size := Rect{W: 120, H: 90}
1401+ m.sw.sizes = map[WindowID]Rect{gone: size}
1402+ other := w.Columns[1].Windows[0]
1403+ if other == gone {
1404+ other = w.Columns[1].Windows[1]
1405+ }
1406+ m.RemoveWindow(gone)
1407+ if len(m.sw.order) != 3 || m.sw.at != at || m.sw.order[at] != other {
1408+ t.Fatalf("order %v at %d, want card %d to stand for %d", m.sw.order, m.sw.at, at, other)
1409+ }
1410+ if m.sw.sizes[other] != size {
1411+ t.Fatalf("sizes %v: the new card keeps no size of the closed window's", m.sw.sizes)
1412+ }
1413+ m.commitSwitcher()
1414+ if id, _ := w.Focused(); id != other {
1415+ t.Fatalf("focus %d, want %d", id, other)
1416+ }
1417+}
1418+
1419+func TestSwitchCommitFocusesTileUnderFloat(t *testing.T) {
1420+ c, _, _ := switcherCore(t, 3)
1421+ m := c.cur().mon
1422+ m.AddFloating(9, 100, 80)
1423+ w := m.Current()
1424+ if !w.floatFocus {
1425+ t.Fatal("float not focused")
1426+ }
1427+ tile := w.Columns[w.Focus].Windows[0]
1428+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1429+ bind(t, c, ActionSwitchColumnPrev) // back to card 0: the column under the float
1430+ if m.sw.at != 0 || m.sw.order[0] != tile {
1431+ t.Fatalf("at %d order %v", m.sw.at, m.sw.order)
1432+ }
1433+ sendKeys(t, c, superKey(0, false))
1434+ if w.floatFocus || switcherFocus(c) != tile {
1435+ t.Fatalf("float focused %v, focus %d, want tile %d", w.floatFocus, switcherFocus(c), tile)
1436+ }
1437+}
1438+
1439+func TestSwitchCommitFollowsToItsOutput(t *testing.T) {
1440+ c, _, _ := switcherCore(t, 3)
1441+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
1442+ first := c.focusScreen
1443+ sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
1444+ want := c.cur().mon.sw.order[1]
1445+ // The pointer crosses to the other output meanwhile.
1446+ c.focusScreen = c.screenIndex("B")
1447+ sendKeys(t, c, superKey(0, false))
1448+ if c.focusScreen != first || switcherFocus(c) != want {
1449+ t.Fatalf("focus screen %d (want %d), focus %d (want %d)", c.focusScreen, first, switcherFocus(c), want)
1450+ }
1451+}
M internal/core/transition.go +34 -31
@@ -25,9 +25,10 @@ import (
2525 type viewShot struct {
2626 sc *screen
2727 ws *Workspace
28- // overview is set when the screen showed its overview: ws is then the
29- // row on screen, the camera fields stay zero, and rects are the cards.
30- overview bool
28+ // preview is set when the screen showed a preview (the overview or the
29+ // column switcher): ws is then the workspace on screen, the camera
30+ // fields stay zero, and rects are the cards.
31+ preview bool
3132 // settledView is ws's settled view and view where its columns were on screen
3233 // (settledView plus shift).
3334 settledView int
@@ -50,7 +51,7 @@ type viewShot struct {
5051 list []*Workspace
5152 pos float64
5253 ok bool
53- // rects are the windows the screen drew (cards in the overview),
54+ // rects are the windows the screen drew (the cards in a preview),
5455 // hidden windows left out. The slice is reused like list.
5556 rects []rectShot
5657 // stash holds the settled placements of the stash windows the screen
@@ -70,7 +71,7 @@ type rectShot struct {
7071 rect Rect
7172 off, vel rectOffsets
7273 // fade and fadeV are the fade offset of a running fade motion and its
73- // speed, so an overview toggle carries it over (transitionOverview).
74+ // speed, so a preview toggle carries it over (transitionOverview).
7475 fade, fadeV float64
7576 // dim and dimV are the dim offset of a running dim motion and its
7677 // speed; veil is the veil the window's settled placement drew
@@ -91,7 +92,7 @@ type rectShot struct {
9192 // The walk shrinks content toward the content rect's top-left, so this
9293 // suits aspect-preserving changes: the zoom follows the smaller of the width
9394 // and height ratios, so content never exceeds its frame when the aspect
94-// differs a little (rounding) or a lot (the frame is then letterboxed). An overview card's settled zoom is
95+// differs a little (rounding) or a lot (the frame is then letterboxed). A card's settled zoom is
9596 // its Preview: a window that opens into a card goes from 1 to Preview with
9697 // its width, and back.
9798 //
@@ -227,7 +228,7 @@ func (c *Core) retargetWith(m *motion, cur *float64, s spring, now time.Time) {
227228 // refreshShown builds the layouts of every screen once per publish: the
228229 // settled one (what configures are sized from) and the shown one (the same
229230 // with the rect motions applied: what is drawn and hit-tested). Rect motions
230-// belong to the workspace they began on, or to the open overview (rectsOwner):
231+// belong to the workspace they began on, or to the open preview (the overview or the switcher's cards, rectsOwner):
231232 // any other change drops them.
232233 //
233234 // Leaving motions add an entry for a window the layout no longer shows:
@@ -403,9 +404,9 @@ func (c *Core) mapWindow(v ports.WindowMapped) {
403404 }
404405 shots := c.snapshot(now)
405406 for i := range shots {
406- // A screen with its overview open re-lays its cards at once: a map
407- // is not an overview action (transition skips a shot without ws).
408- if shots[i].overview {
407+ // A screen showing a preview re-lays its cards at once: a map is
408+ // not a preview action (transition skips a shot without ws).
409+ if shots[i].preview {
409410 shots[i].ws = nil
410411 }
411412 }
@@ -416,12 +417,12 @@ func (c *Core) mapWindow(v ports.WindowMapped) {
416417
417418 // appearMapped starts the entrance of a window that just mapped when it
418419 // shows on its screen's current workspace as a tile or an ordinary float:
419-// not with the overview open or a session lock, not fullscreen (it keeps
420+// not with a preview open or a session lock, not fullscreen (it keeps
420421 // the direct scanout path), not a float below the columns, not a stash
421422 // peek.
422423 func (c *Core) appearMapped(id WindowID, now time.Time) {
423424 sc, w := c.screenOf(id)
424- if sc == nil || c.security.Protected || sc.mon.ov.open || sc.mon.Current() != w {
425+ if sc == nil || c.security.Protected || sc.mon.previewing() || sc.mon.Current() != w {
425426 return
426427 }
427428 for _, p := range sc.mon.Layout() {
@@ -442,7 +443,7 @@ func (c *Core) appearMapped(id WindowID, now time.Time) {
442443 // leaves from its drawn rect, fade and speed, not from its settled rect).
443444 // Like a map, an unmap is a client event that animates; the settled layout
444445 // is the same with animations off. A fullscreen window, one under a
445-// session lock or with the overview open just goes.
446+// session lock or with a preview open just goes.
446447 func (c *Core) unmapWindow(v ports.WindowUnmapped) {
447448 // The window is unmapped: a toplevel that maps again keeps its ID and
448449 // must be configured from nothing (a leaving entry keeps no configure).
@@ -451,7 +452,7 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) {
451452 if sc == nil {
452453 return
453454 }
454- if !c.animOn() || c.security.Protected || sc.mon.ov.open {
455+ if !c.animOn() || c.security.Protected || sc.mon.previewing() {
455456 sc.mon.RemoveWindow(v.ID)
456457 delete(sc.rects, v.ID)
457458 return
@@ -471,7 +472,7 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) {
471472 shots := c.snapshot(now)
472473 var shot *rectShot
473474 for i := range shots {
474- if shots[i].overview {
475+ if shots[i].preview {
475476 shots[i].ws = nil
476477 }
477478 if shots[i].sc == sc {
@@ -599,7 +600,8 @@ func (c *Core) scaleFrom(rm *rectMotion, settled Rect, k float64, now time.Time)
599600 c.retargetComponent(&rm.h, &rm.dh, h-float64(settled.H), 0, now)
600601 }
601602
602-// snapshot records what every screen without an open overview shows at now.
603+// snapshot records what every screen shows at now, previews (the overview,
604+// the switcher's cards) included.
603605 // Running motions are sampled at now on a copy, so a transition that
604606 // follows chains from their speed at the time of the action, not from the
605607 // last frame's. The view is the one drawn (the layouts it is measured
@@ -619,9 +621,9 @@ func (c *Core) snapshot(now time.Time) []viewShot {
619621 }
620622 s.sc = sc
621623 w := m.Current()
622- s.ws, s.overview = w, m.ov.open
623- if !m.ov.open {
624- // The overview does not scroll or slide: no camera.
624+ s.ws, s.preview = w, m.previewing()
625+ if !m.previewing() {
626+ // A preview (the overview or the switcher's cards) does not scroll or slide: no camera.
625627 s.settledView, s.view = w.View, float64(w.View)+w.view.off
626628 s.switchAxis = w.policy().workspace
627629 if m.switchView.busy() {
@@ -640,7 +642,7 @@ func (c *Core) snapshot(now time.Time) []viewShot {
640642 }
641643 // settledLayout and shown are index-aligned and differ only by
642644 // the offsets of sc.rects: the shot keeps both unrounded. The
643- // overview's cards are recorded like windows.
645+ // cards of a preview are recorded like windows.
644646 for _, p := range sc.settledLayout {
645647 if p.Hidden {
646648 continue
@@ -655,11 +657,11 @@ func (c *Core) snapshot(now time.Time) []viewShot {
655657 }
656658 r.veil = c.peekDim(p)
657659 s.rects = append(s.rects, r)
658- if !m.ov.open && w.stashIndex(p.ID) >= 0 {
660+ if !m.previewing() && w.stashIndex(p.ID) >= 0 {
659661 s.stash = append(s.stash, p)
660662 }
661663 }
662- if m.shown == nil && !m.ov.open {
664+ if m.shown == nil && !m.previewing() {
663665 // A landing slide measures in the list it began on.
664666 list := m.Workspaces
665667 if m.switchList != nil {
@@ -685,7 +687,7 @@ func (c *Core) snapshot(now time.Time) []viewShot {
685687 // transition starts the springs that take each screen from what before
686688 // showed to what the action left, at now. It does nothing with animations
687689 // off, or while a swipe follows the fingers on that screen. Per-window rect
688-// motions join the camera here; with the overview open before or after
690+// motions join the camera here; with a preview open before or after
689691 // there is no camera, only the cards' (transitionOverview).
690692 //
691693 // A workspace moved to another monitor (move-workspace-to-monitor-*, or a
@@ -704,7 +706,7 @@ func (c *Core) transition(before []viewShot, now time.Time) {
704706 if b.ws == nil || !c.hasScreen(b.sc) || c.following(b.sc) {
705707 continue
706708 }
707- if b.overview || b.sc.mon.ov.open {
709+ if b.preview || b.sc.mon.previewing() {
708710 c.transitionOverview(b, now)
709711 continue
710712 }
@@ -885,18 +887,19 @@ func (c *Core) peekDim(p Placement) float64 {
885887 }
886888
887889 // transitionOverview starts the motions of an action that opened or closed
888-// the overview, or moved inside it (the selection, the rows). The camera
889-// springs stay stopped (ToggleOverview): every window drawn before and
890-// after moves from its shown rect to its settled one, its content scaled
891-// with it (a card's Zoom, rectMotion.show). A window drawn after and not
892-// before (another row, a column the overview hid) fades in with no motion
890+// a preview (the overview or the column switcher), or moved inside it (the
891+// selection, the rows). The camera springs stay stopped (ToggleOverview):
892+// every window drawn before and after moves from its shown rect to its
893+// settled one, its content scaled with it (a card's Zoom,
894+// rectMotion.show). A window drawn after and not before (another row, a
895+// column a preview hid) fades in with no motion
893896 // of its own; one drawn before and not after just goes. Motions of the
894897 // other state are dropped first: their rects are not the ones snapshot
895898 // measured from. A fade that ran on a window drawn in both states carries
896899 // over (picking a card that still fades in does not pop it).
897900 func (c *Core) transitionOverview(b *viewShot, now time.Time) {
898901 sc := b.sc
899- changed := b.overview != sc.mon.ov.open
902+ changed := b.preview != sc.mon.previewing()
900903 if changed {
901904 sc.stopRects()
902905 }
@@ -936,7 +939,7 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) {
936939 func movedFrom(b *viewShot, before []viewShot) *viewShot {
937940 w := b.sc.mon.Current()
938941 for i := range before {
939- if o := &before[i]; o.ws == w && o.sc != b.sc && !o.overview {
942+ if o := &before[i]; o.ws == w && o.sc != b.sc && !o.preview {
940943 return o
941944 }
942945 }
@@ -25,9 +25,10 @@ import (
25 type viewShot struct {25 type viewShot struct {
26 sc *screen26 sc *screen
27 ws *Workspace27 ws *Workspace
28- // overview is set when the screen showed its overview: ws is then the28+ // preview is set when the screen showed a preview (the overview or the
29- // row on screen, the camera fields stay zero, and rects are the cards.29+ // column switcher): ws is then the workspace on screen, the camera
30- overview bool30+ // fields stay zero, and rects are the cards.
31+ preview bool
31 // settledView is ws's settled view and view where its columns were on screen32 // settledView is ws's settled view and view where its columns were on screen
32 // (settledView plus shift).33 // (settledView plus shift).
33 settledView int34 settledView int
@@ -50,7 +51,7 @@ type viewShot struct {
50 list []*Workspace51 list []*Workspace
51 pos float6452 pos float64
52 ok bool53 ok bool
53- // rects are the windows the screen drew (cards in the overview),54+ // rects are the windows the screen drew (the cards in a preview),
54 // hidden windows left out. The slice is reused like list.55 // hidden windows left out. The slice is reused like list.
55 rects []rectShot56 rects []rectShot
56 // stash holds the settled placements of the stash windows the screen57 // stash holds the settled placements of the stash windows the screen
@@ -70,7 +71,7 @@ type rectShot struct {
70 rect Rect71 rect Rect
71 off, vel rectOffsets72 off, vel rectOffsets
72 // fade and fadeV are the fade offset of a running fade motion and its73 // fade and fadeV are the fade offset of a running fade motion and its
73- // speed, so an overview toggle carries it over (transitionOverview).74+ // speed, so a preview toggle carries it over (transitionOverview).
74 fade, fadeV float6475 fade, fadeV float64
75 // dim and dimV are the dim offset of a running dim motion and its76 // dim and dimV are the dim offset of a running dim motion and its
76 // speed; veil is the veil the window's settled placement drew77 // speed; veil is the veil the window's settled placement drew
@@ -91,7 +92,7 @@ type rectShot struct {
91 // The walk shrinks content toward the content rect's top-left, so this92 // The walk shrinks content toward the content rect's top-left, so this
92 // suits aspect-preserving changes: the zoom follows the smaller of the width93 // suits aspect-preserving changes: the zoom follows the smaller of the width
93 // and height ratios, so content never exceeds its frame when the aspect94 // and height ratios, so content never exceeds its frame when the aspect
94-// differs a little (rounding) or a lot (the frame is then letterboxed). An overview card's settled zoom is95+// differs a little (rounding) or a lot (the frame is then letterboxed). A card's settled zoom is
95 // its Preview: a window that opens into a card goes from 1 to Preview with96 // its Preview: a window that opens into a card goes from 1 to Preview with
96 // its width, and back.97 // its width, and back.
97 //98 //
@@ -227,7 +228,7 @@ func (c *Core) retargetWith(m *motion, cur *float64, s spring, now time.Time) {
227 // refreshShown builds the layouts of every screen once per publish: the228 // refreshShown builds the layouts of every screen once per publish: the
228 // settled one (what configures are sized from) and the shown one (the same229 // settled one (what configures are sized from) and the shown one (the same
229 // with the rect motions applied: what is drawn and hit-tested). Rect motions230 // with the rect motions applied: what is drawn and hit-tested). Rect motions
230-// belong to the workspace they began on, or to the open overview (rectsOwner):231+// belong to the workspace they began on, or to the open preview (the overview or the switcher's cards, rectsOwner):
231 // any other change drops them.232 // any other change drops them.
232 //233 //
233 // Leaving motions add an entry for a window the layout no longer shows:234 // Leaving motions add an entry for a window the layout no longer shows:
@@ -403,9 +404,9 @@ func (c *Core) mapWindow(v ports.WindowMapped) {
403 }404 }
404 shots := c.snapshot(now)405 shots := c.snapshot(now)
405 for i := range shots {406 for i := range shots {
406- // A screen with its overview open re-lays its cards at once: a map407+ // A screen showing a preview re-lays its cards at once: a map is
407- // is not an overview action (transition skips a shot without ws).408+ // not a preview action (transition skips a shot without ws).
408- if shots[i].overview {409+ if shots[i].preview {
409 shots[i].ws = nil410 shots[i].ws = nil
410 }411 }
411 }412 }
@@ -416,12 +417,12 @@ func (c *Core) mapWindow(v ports.WindowMapped) {
416 417
417 // appearMapped starts the entrance of a window that just mapped when it418 // appearMapped starts the entrance of a window that just mapped when it
418 // shows on its screen's current workspace as a tile or an ordinary float:419 // shows on its screen's current workspace as a tile or an ordinary float:
419-// not with the overview open or a session lock, not fullscreen (it keeps420+// not with a preview open or a session lock, not fullscreen (it keeps
420 // the direct scanout path), not a float below the columns, not a stash421 // the direct scanout path), not a float below the columns, not a stash
421 // peek.422 // peek.
422 func (c *Core) appearMapped(id WindowID, now time.Time) {423 func (c *Core) appearMapped(id WindowID, now time.Time) {
423 sc, w := c.screenOf(id)424 sc, w := c.screenOf(id)
424- if sc == nil || c.security.Protected || sc.mon.ov.open || sc.mon.Current() != w {425+ if sc == nil || c.security.Protected || sc.mon.previewing() || sc.mon.Current() != w {
425 return426 return
426 }427 }
427 for _, p := range sc.mon.Layout() {428 for _, p := range sc.mon.Layout() {
@@ -442,7 +443,7 @@ func (c *Core) appearMapped(id WindowID, now time.Time) {
442 // leaves from its drawn rect, fade and speed, not from its settled rect).443 // leaves from its drawn rect, fade and speed, not from its settled rect).
443 // Like a map, an unmap is a client event that animates; the settled layout444 // Like a map, an unmap is a client event that animates; the settled layout
444 // is the same with animations off. A fullscreen window, one under a445 // is the same with animations off. A fullscreen window, one under a
445-// session lock or with the overview open just goes.446+// session lock or with a preview open just goes.
446 func (c *Core) unmapWindow(v ports.WindowUnmapped) {447 func (c *Core) unmapWindow(v ports.WindowUnmapped) {
447 // The window is unmapped: a toplevel that maps again keeps its ID and448 // The window is unmapped: a toplevel that maps again keeps its ID and
448 // must be configured from nothing (a leaving entry keeps no configure).449 // must be configured from nothing (a leaving entry keeps no configure).
@@ -451,7 +452,7 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) {
451 if sc == nil {452 if sc == nil {
452 return453 return
453 }454 }
454- if !c.animOn() || c.security.Protected || sc.mon.ov.open {455+ if !c.animOn() || c.security.Protected || sc.mon.previewing() {
455 sc.mon.RemoveWindow(v.ID)456 sc.mon.RemoveWindow(v.ID)
456 delete(sc.rects, v.ID)457 delete(sc.rects, v.ID)
457 return458 return
@@ -471,7 +472,7 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) {
471 shots := c.snapshot(now)472 shots := c.snapshot(now)
472 var shot *rectShot473 var shot *rectShot
473 for i := range shots {474 for i := range shots {
474- if shots[i].overview {475+ if shots[i].preview {
475 shots[i].ws = nil476 shots[i].ws = nil
476 }477 }
477 if shots[i].sc == sc {478 if shots[i].sc == sc {
@@ -599,7 +600,8 @@ func (c *Core) scaleFrom(rm *rectMotion, settled Rect, k float64, now time.Time)
599 c.retargetComponent(&rm.h, &rm.dh, h-float64(settled.H), 0, now)600 c.retargetComponent(&rm.h, &rm.dh, h-float64(settled.H), 0, now)
600 }601 }
601 602
602-// snapshot records what every screen without an open overview shows at now.603+// snapshot records what every screen shows at now, previews (the overview,
604+// the switcher's cards) included.
603 // Running motions are sampled at now on a copy, so a transition that605 // Running motions are sampled at now on a copy, so a transition that
604 // follows chains from their speed at the time of the action, not from the606 // follows chains from their speed at the time of the action, not from the
605 // last frame's. The view is the one drawn (the layouts it is measured607 // last frame's. The view is the one drawn (the layouts it is measured
@@ -619,9 +621,9 @@ func (c *Core) snapshot(now time.Time) []viewShot {
619 }621 }
620 s.sc = sc622 s.sc = sc
621 w := m.Current()623 w := m.Current()
622- s.ws, s.overview = w, m.ov.open624+ s.ws, s.preview = w, m.previewing()
623- if !m.ov.open {625+ if !m.previewing() {
624- // The overview does not scroll or slide: no camera.626+ // A preview (the overview or the switcher's cards) does not scroll or slide: no camera.
625 s.settledView, s.view = w.View, float64(w.View)+w.view.off627 s.settledView, s.view = w.View, float64(w.View)+w.view.off
626 s.switchAxis = w.policy().workspace628 s.switchAxis = w.policy().workspace
627 if m.switchView.busy() {629 if m.switchView.busy() {
@@ -640,7 +642,7 @@ func (c *Core) snapshot(now time.Time) []viewShot {
640 }642 }
641 // settledLayout and shown are index-aligned and differ only by643 // settledLayout and shown are index-aligned and differ only by
642 // the offsets of sc.rects: the shot keeps both unrounded. The644 // the offsets of sc.rects: the shot keeps both unrounded. The
643- // overview's cards are recorded like windows.645+ // cards of a preview are recorded like windows.
644 for _, p := range sc.settledLayout {646 for _, p := range sc.settledLayout {
645 if p.Hidden {647 if p.Hidden {
646 continue648 continue
@@ -655,11 +657,11 @@ func (c *Core) snapshot(now time.Time) []viewShot {
655 }657 }
656 r.veil = c.peekDim(p)658 r.veil = c.peekDim(p)
657 s.rects = append(s.rects, r)659 s.rects = append(s.rects, r)
658- if !m.ov.open && w.stashIndex(p.ID) >= 0 {660+ if !m.previewing() && w.stashIndex(p.ID) >= 0 {
659 s.stash = append(s.stash, p)661 s.stash = append(s.stash, p)
660 }662 }
661 }663 }
662- if m.shown == nil && !m.ov.open {664+ if m.shown == nil && !m.previewing() {
663 // A landing slide measures in the list it began on.665 // A landing slide measures in the list it began on.
664 list := m.Workspaces666 list := m.Workspaces
665 if m.switchList != nil {667 if m.switchList != nil {
@@ -685,7 +687,7 @@ func (c *Core) snapshot(now time.Time) []viewShot {
685 // transition starts the springs that take each screen from what before687 // transition starts the springs that take each screen from what before
686 // showed to what the action left, at now. It does nothing with animations688 // showed to what the action left, at now. It does nothing with animations
687 // off, or while a swipe follows the fingers on that screen. Per-window rect689 // off, or while a swipe follows the fingers on that screen. Per-window rect
688-// motions join the camera here; with the overview open before or after690+// motions join the camera here; with a preview open before or after
689 // there is no camera, only the cards' (transitionOverview).691 // there is no camera, only the cards' (transitionOverview).
690 //692 //
691 // A workspace moved to another monitor (move-workspace-to-monitor-*, or a693 // A workspace moved to another monitor (move-workspace-to-monitor-*, or a
@@ -704,7 +706,7 @@ func (c *Core) transition(before []viewShot, now time.Time) {
704 if b.ws == nil || !c.hasScreen(b.sc) || c.following(b.sc) {706 if b.ws == nil || !c.hasScreen(b.sc) || c.following(b.sc) {
705 continue707 continue
706 }708 }
707- if b.overview || b.sc.mon.ov.open {709+ if b.preview || b.sc.mon.previewing() {
708 c.transitionOverview(b, now)710 c.transitionOverview(b, now)
709 continue711 continue
710 }712 }
@@ -885,18 +887,19 @@ func (c *Core) peekDim(p Placement) float64 {
885 }887 }
886 888
887 // transitionOverview starts the motions of an action that opened or closed889 // transitionOverview starts the motions of an action that opened or closed
888-// the overview, or moved inside it (the selection, the rows). The camera890+// a preview (the overview or the column switcher), or moved inside it (the
889-// springs stay stopped (ToggleOverview): every window drawn before and891+// selection, the rows). The camera springs stay stopped (ToggleOverview):
890-// after moves from its shown rect to its settled one, its content scaled892+// every window drawn before and after moves from its shown rect to its
891-// with it (a card's Zoom, rectMotion.show). A window drawn after and not893+// settled one, its content scaled with it (a card's Zoom,
892-// before (another row, a column the overview hid) fades in with no motion894+// rectMotion.show). A window drawn after and not before (another row, a
895+// column a preview hid) fades in with no motion
893 // of its own; one drawn before and not after just goes. Motions of the896 // of its own; one drawn before and not after just goes. Motions of the
894 // other state are dropped first: their rects are not the ones snapshot897 // other state are dropped first: their rects are not the ones snapshot
895 // measured from. A fade that ran on a window drawn in both states carries898 // measured from. A fade that ran on a window drawn in both states carries
896 // over (picking a card that still fades in does not pop it).899 // over (picking a card that still fades in does not pop it).
897 func (c *Core) transitionOverview(b *viewShot, now time.Time) {900 func (c *Core) transitionOverview(b *viewShot, now time.Time) {
898 sc := b.sc901 sc := b.sc
899- changed := b.overview != sc.mon.ov.open902+ changed := b.preview != sc.mon.previewing()
900 if changed {903 if changed {
901 sc.stopRects()904 sc.stopRects()
902 }905 }
@@ -936,7 +939,7 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) {
936 func movedFrom(b *viewShot, before []viewShot) *viewShot {939 func movedFrom(b *viewShot, before []viewShot) *viewShot {
937 w := b.sc.mon.Current()940 w := b.sc.mon.Current()
938 for i := range before {941 for i := range before {
939- if o := &before[i]; o.ws == w && o.sc != b.sc && !o.overview {942+ if o := &before[i]; o.ws == w && o.sc != b.sc && !o.preview {
940 return o943 return o
941 }944 }
942 }945 }

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close