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
22 files · +2060 -56
M Makefile +1 -1
| @@ -69,7 +69,7 @@ adapter-imports: | |||
| 69 | 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | |
| 70 | 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | |
| 71 | 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=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 | |
| 73 | 73 | check: vet test arch fakes-check log-check perf-check | |
| 74 | 74 | ||
| 75 | 75 | # 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=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 |
| 73 | check: vet test arch fakes-check log-check perf-check | 73 | check: vet test arch fakes-check log-check perf-check | ||
| 74 | 74 | ||||
| 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | 75 | # 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 | |||
| 280 | 280 | bind.cmd+s = toggle-stash-visible | |
| 281 | 281 | bind.cmd+shift+s = toggle-window-stash | |
| 282 | 282 | bind.cmd+o = toggle-overview | |
| 283 | + | bind.cmd+tab = switch-column-next | |
| 284 | + | bind.cmd+shift+tab = switch-column-prev | |
| 283 | 285 | ||
| 284 | 286 | # Workspaces | |
| 285 | 287 | bind.cmd+pageup = focus-workspace-prev | |
| @@ -280,6 +280,8 @@ bind.cmd+shift+f = toggle-fullscreen | |||||
| 280 | bind.cmd+s = toggle-stash-visible | 280 | bind.cmd+s = toggle-stash-visible | ||
| 281 | bind.cmd+shift+s = toggle-window-stash | 281 | bind.cmd+shift+s = toggle-window-stash | ||
| 282 | bind.cmd+o = toggle-overview | 282 | bind.cmd+o = toggle-overview | ||
| 283 | + | bind.cmd+tab = switch-column-next | |||
| 284 | + | bind.cmd+shift+tab = switch-column-prev | |||
| 283 | 285 | ||||
| 284 | # Workspaces | 286 | # Workspaces | ||
| 285 | bind.cmd+pageup = focus-workspace-prev | 287 | 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`). | |||
| 44 | 44 | | `mouse.accel-profile` | `adaptive` | `adaptive`: faster moves go further; `flat`: constant speed | | |
| 45 | 45 | | `mouse.left-handed` | `off` | Swaps the left and right mouse buttons | | |
| 46 | 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 | 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 | 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 | 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 | 52 | | `focus.strength` | `0.04` | Peak of the focus effect, `0.01` to `0.2`: black reaches about 10/255 at `0.04` | | |
| 53 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 319 | ### Default binds | |
| 318 | 320 | ||
| @@ -348,6 +350,8 @@ With `keyboard.cmd = alt`, the `cmd+alt+…` binds have the same keys as `cmd+ | |||
| 348 | 350 | | `cmd+shift+s` | `toggle-window-stash` | | |
| 349 | 351 | | `cmd+shift+space` | `toggle-floating` | | |
| 350 | 352 | | `cmd+o` | `toggle-overview` | | |
| 353 | + | | `cmd+tab` | `switch-column-next` | | |
| 354 | + | | `cmd+shift+tab` | `switch-column-prev` | | |
| 351 | 355 | | `cmd+q` | `close-window` | | |
| 352 | 356 | | `ctrl+alt+backspace` | `quit` | | |
| 353 | 357 | | `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 binds | 319 | ### 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 }{ | |||
| 44 | 44 | {"cmd+s", "toggle-stash-visible"}, | |
| 45 | 45 | {"cmd+shift+s", "toggle-window-stash"}, | |
| 46 | 46 | {"cmd+o", "toggle-overview"}, | |
| 47 | + | {"cmd+tab", "switch-column-next"}, | |
| 48 | + | {"cmd+shift+tab", "switch-column-prev"}, | |
| 47 | 49 | {"cmd+q", "close-window"}, | |
| 48 | 50 | {"ctrl+alt+backspace", "quit"}, | |
| 49 | 51 | {"ctrl+cmd+space", "spawn fuzzel"}, | |
| @@ -100,6 +102,8 @@ var actions = map[string]bool{ | |||
| 100 | 102 | "toggle-window-stash": true, | |
| 101 | 103 | "toggle-stash-visible": true, | |
| 102 | 104 | "toggle-overview": true, | |
| 105 | + | "switch-column-next": true, | |
| 106 | + | "switch-column-prev": true, | |
| 103 | 107 | "close-window": true, | |
| 104 | 108 | "quit": true, | |
| 105 | 109 | "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) { | |||
| 25 | 25 | func TestDefaultsAndLoad(t *testing.T) { | |
| 26 | 26 | t.Setenv("TERMINAL", "") | |
| 27 | 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 | 29 | t.Fatalf("defaults: %+v", d) | |
| 30 | 30 | } | |
| 31 | 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 | 416 | func TestInvalidDuplicateKeepsEarlierValue(t *testing.T) { | |
| 404 | 417 | c, w := parseString(t, "background = #000000\nbackground = red\nbackground = #111111\n") | |
| 405 | 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") { | |
| @@ -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 ( | |||
| 58 | 58 | ActionMoveWorkspaceNext Action = "move-workspace-next" | |
| 59 | 59 | // Turn the focused tile into a free floating window, or back. | |
| 60 | 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 | 67 | // 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 by | 67 | // 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 { | |||
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 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 | 29 | t.Helper() | |
| 24 | 30 | ic := &indicatorClock{now: time.Unix(1000, 0)} | |
| 25 | 31 | clock := portsmocks.NewMockClock(t) | |
| @@ -39,7 +45,7 @@ func indicatorCore(t *testing.T) (*Core, *indicatorClock) { | |||
| 39 | 45 | cfg.Keyboard.CmdKey = "super" | |
| 40 | 46 | cfg.Layout.MaxColumns = 2 | |
| 41 | 47 | 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 | 49 | if err != nil { | |
| 44 | 50 | t.Fatal(err) | |
| 45 | 51 | } | |
| @@ -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 = 2 | 46 | cfg.Layout.MaxColumns = 2 | ||
| 41 | cfg.Border.Width = 0 | 47 | 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) { | |||
| 153 | 153 | return r, ports.CaptureReasonWorkspaceGone | |
| 154 | 154 | } | |
| 155 | 155 | 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() | |
| 157 | 157 | return r, ports.CaptureReasonNone | |
| 158 | 158 | } | |
| 159 | 159 | i := c.screenIndex(t.output) | |
| @@ -467,7 +467,7 @@ func (c *Core) captureSceneFor(sc *screen, v *capView) *ports.SceneCapture { | |||
| 467 | 467 | return nil | |
| 468 | 468 | } | |
| 469 | 469 | want := ports.SceneCapture{} | |
| 470 | - | if !sc.mon.ov.open { | |
| 470 | + | if !sc.mon.previewing() { | |
| 471 | 471 | want.Shown = sc.mon.Current().ID | |
| 472 | 472 | } | |
| 473 | 473 | if e := v.exclusion; e != nil { | |
| @@ -153,7 +153,7 @@ func (c *Core) capResolve(t capTarget) (capResolved, ports.CaptureReason) { | |||||
| 153 | return r, ports.CaptureReasonWorkspaceGone | 153 | return r, ports.CaptureReasonWorkspaceGone | ||
| 154 | } | 154 | } | ||
| 155 | r.sc, r.ws, r.rect = sc, w, w.Output | 155 | 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() |
| 157 | return r, ports.CaptureReasonNone | 157 | 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 nil | 467 | 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().ID | 471 | 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 { | |||
| 152 | 152 | capt captureState | |
| 153 | 153 | // pulse marks a window that just got the focus (pulse.go). | |
| 154 | 154 | 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 | 164 | // publishScratch is where publishState and publishWorkspaces build their | |
| @@ -263,7 +270,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 263 | 270 | } | |
| 264 | 271 | switch Action(a) { | |
| 265 | 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 | 274 | default: | |
| 268 | 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 | 598 | if c.security.Protected { | |
| 592 | 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 | 605 | for _, sc := range c.screens { | |
| 595 | 606 | // The overview's selection is provisional: it focuses nothing yet. | |
| 596 | 607 | if !sc.mon.ov.open { | |
| 597 | 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 | 615 | c.refreshShown() | |
| 601 | 616 | c.captureExpire() | |
| @@ -971,9 +986,9 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||
| 971 | 986 | if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 { | |
| 972 | 987 | return id, px, py | |
| 973 | 988 | } | |
| 974 | - | if sc.mon.ov.open { | |
| 989 | + | if sc.mon.previewing() { | |
| 975 | 990 | // Previews and the layers under them take no input: a click | |
| 976 | - | // picks a preview (overviewClick). | |
| 991 | + | // picks a preview (overviewClick, switcherClick). | |
| 977 | 992 | return 0, 0, 0 | |
| 978 | 993 | } | |
| 979 | 994 | if !sc.mon.frameHas(lx, ly) { | |
| @@ -1013,6 +1028,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1013 | 1028 | defer c.stopFrame() | |
| 1014 | 1029 | defer c.stopCaptureTimer() | |
| 1015 | 1030 | defer c.stopPulseTimer() | |
| 1031 | + | defer c.cancelSwitcher() | |
| 1016 | 1032 | // Startup commands run once per session. Full launcher queues retain a | |
| 1017 | 1033 | // bounded remainder, selectable alongside owner events without polling. | |
| 1018 | 1034 | c.startup = make([][]string, len(c.cfg.Startup)) | |
| @@ -1085,6 +1101,14 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1085 | 1101 | return nil | |
| 1086 | 1102 | } | |
| 1087 | 1103 | 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 | 1112 | case <-c.capt.timerC: | |
| 1089 | 1113 | c.capt.timerC, c.capt.timerStop = nil, nil | |
| 1090 | 1114 | if c.captureFlashTick() && c.publish(ctx) != nil { | |
| @@ -152,6 +152,13 @@ type Core struct { | |||||
| 152 | capt captureState | 152 | 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 focusPulse | 154 | 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 their | 164 | // 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, py | 987 | 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 click | 990 | // 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, 0 | 992 | 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 a | 1032 | // 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 nil | 1101 | return nil | ||
| 1086 | } | 1102 | } | ||
| 1087 | continue | 1103 | 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, nil | 1113 | 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 { | |||
| 78 | 78 | // not fullscreen, outside the overview. | |
| 79 | 79 | func (c *Core) draggable(id WindowID) (*screen, *Workspace, bool) { | |
| 80 | 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 | 82 | return nil, nil, false | |
| 83 | 83 | } | |
| 84 | 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 | 288 | m := sc.mon | |
| 289 | 289 | lx, ly := int(x)-sc.x, int(y)-sc.y | |
| 290 | 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 | 292 | return dropTarget{} | |
| 293 | 293 | } | |
| 294 | 294 | 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, false | 82 | 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.mon | 288 | m := sc.mon | ||
| 289 | lx, ly := int(x)-sc.x, int(y)-sc.y | 289 | 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 { | |||
| 138 | 138 | // running (its end was lost) ends cancelled first: its slide settles back. | |
| 139 | 139 | func (c *Core) swipeBegin(b ports.SwipeBegin) bool { | |
| 140 | 140 | changed := false | |
| 141 | + | if c.switching() { | |
| 142 | + | c.cancelSwitcher() | |
| 143 | + | changed = true | |
| 144 | + | } | |
| 141 | 145 | if c.swipe != nil { | |
| 142 | 146 | c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: b.Time}) | |
| 143 | 147 | 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 := false | 140 | 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 = true | 147 | changed = true | ||
M internal/core/input.go +24 -0
| @@ -128,6 +128,11 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error { | |||
| 128 | 128 | } | |
| 129 | 129 | 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 | 136 | if started, err := c.startDrag(ctx, v); err != nil || started { | |
| 132 | 137 | return err | |
| 133 | 138 | } | |
| @@ -224,6 +229,18 @@ func (c *Core) pointerAxis(ctx context.Context, v ports.PointerAxis) error { | |||
| 224 | 229 | ||
| 225 | 230 | func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | |
| 226 | 231 | 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 | 244 | if c.drag != nil && key.Pressed && key.Keysym == "Escape" { | |
| 228 | 245 | // Escape cancels the drag; its release goes nowhere either. | |
| 229 | 246 | c.cancelDrag() | |
| @@ -308,10 +325,17 @@ func (c *Core) runBind(ctx context.Context, action Action) error { | |||
| 308 | 325 | if action == ActionScaleUp || action == ActionScaleDown { | |
| 309 | 326 | return c.scaleBind(ctx, action) | |
| 310 | 327 | } | |
| 328 | + | if dir := switchDir(action); dir != 0 { | |
| 329 | + | return c.switchBind(ctx, dir) | |
| 330 | + | } | |
| 311 | 331 | before := c.cur().mon.Current() | |
| 312 | 332 | swiped := c.swipedWorkspace() | |
| 313 | 333 | now := c.now() | |
| 334 | + | // Snapshot before the switcher closes: its cards fly back to the layout. | |
| 314 | 335 | shots := c.snapshot(now) | |
| 336 | + | if c.switching() { | |
| 337 | + | c.cancelSwitcher() | |
| 338 | + | } | |
| 315 | 339 | c.keyboard.takeBack() // a bind acts on the windows | |
| 316 | 340 | effect := c.applyAction(action) | |
| 317 | 341 | if effect.Quit { | |
| @@ -128,6 +128,11 @@ func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error { | |||||
| 128 | } | 128 | } | ||
| 129 | return nil | 129 | 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 err | 137 | 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.Mods | 231 | 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 windows | 339 | 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) { | |||
| 88 | 88 | ||
| 89 | 89 | // hasFullscreen reports whether a fullscreen window covers the screen. In | |
| 90 | 90 | // 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 | 92 | 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 | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | 96 | // 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. In | 89 | // hasFullscreen reports whether a fullscreen window covers the screen. In | ||
| 90 | // scroll overflow, one scrolled off by the user no longer does; nor does | 90 | // 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() != 0 | 93 | + | 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 is | 96 | // 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 { | |||
| 170 | 170 | // maximized remembers recently maximized columns by window ID, newest | |
| 171 | 171 | // first; stack() filters removed windows at read time. | |
| 172 | 172 | 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 | |
| 175 | 178 | // View is the settled offset along the layout's content axis. | |
| 176 | 179 | // view is its presentation offset, in logical pixels. | |
| 177 | 180 | view slide | |
| @@ -641,6 +644,7 @@ func (w *Workspace) unslot(n int) { | |||
| 641 | 644 | ||
| 642 | 645 | func (w *Workspace) RemoveWindow(id WindowID) { | |
| 643 | 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 | 648 | if w.fullscreen == id { | |
| 645 | 649 | w.fullscreen = 0 | |
| 646 | 650 | } | |
| @@ -170,8 +170,11 @@ type Workspace struct { | |||||
| 170 | // maximized remembers recently maximized columns by window ID, newest | 170 | // 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 []WindowID | 172 | maximized []WindowID | ||
| 173 | - | Focus int | 173 | + | // recent holds one window per column the user focused, newest first |
| 174 | - | View int | 174 | + | // (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 slide | 180 | 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 = 0 | 649 | w.fullscreen = 0 | ||
| 646 | } | 650 | } | ||
M internal/core/monitor.go +10 -2
| @@ -45,6 +45,8 @@ type Monitor struct { | |||
| 45 | 45 | switchList []*Workspace | |
| 46 | 46 | // ov holds the overview selection and its Escape snapshot. | |
| 47 | 47 | ov overviewState | |
| 48 | + | // sw is the column switcher (switcher.go). | |
| 49 | + | sw switcherState | |
| 48 | 50 | // overviewOpens counts the overview's openings: a swipe sliding when | |
| 49 | 51 | // one happens is dropped (gesture.go). | |
| 50 | 52 | overviewOpens int | |
| @@ -327,10 +329,12 @@ func (m *Monitor) AddFloating(id WindowID, width, height int) { | |||
| 327 | 329 | // RemoveWindow drops the window wherever it is; focus stays on the active | |
| 328 | 330 | // workspace. | |
| 329 | 331 | func (m *Monitor) RemoveWindow(id WindowID) { | |
| 332 | + | heir := m.sw.heir(id) | |
| 330 | 333 | if w, _ := m.find(id); w != nil { | |
| 331 | 334 | w.RemoveWindow(id) | |
| 332 | 335 | m.normalize() | |
| 333 | 336 | } | |
| 337 | + | m.sw.remove(id, heir) | |
| 334 | 338 | } | |
| 335 | 339 | ||
| 336 | 340 | // 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 | 427 | // so a reused dst's previous result is overwritten and only a caller done | |
| 424 | 428 | // with it may pass it. It is never the monitor's own scratch. | |
| 425 | 429 | func (m *Monitor) layoutInto(dst []Placement) []Placement { | |
| 430 | + | if m.switcherShown() { | |
| 431 | + | return m.switcherLayout(dst[:0]) | |
| 432 | + | } | |
| 426 | 433 | if m.ov.open { | |
| 427 | 434 | return m.overviewLayout(dst[:0]) | |
| 428 | 435 | } | |
| @@ -442,10 +449,11 @@ func (m *Monitor) layoutInto(dst []Placement) []Placement { | |||
| 442 | 449 | func (m *Monitor) Output() Rect { return m.template.Output } | |
| 443 | 450 | ||
| 444 | 451 | // 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 | |
| 446 | 454 | // involving a sized workspace remain settled inside the current frame. | |
| 447 | 455 | func (m *Monitor) Frame() Rect { | |
| 448 | - | if m.ov.open { | |
| 456 | + | if m.previewing() { | |
| 449 | 457 | return m.Output() | |
| 450 | 458 | } | |
| 451 | 459 | return m.Current().Output | |
| @@ -45,6 +45,8 @@ type Monitor struct { | |||||
| 45 | switchList []*Workspace | 45 | switchList []*Workspace | ||
| 46 | // ov holds the overview selection and its Escape snapshot. | 46 | // ov holds the overview selection and its Escape snapshot. | ||
| 47 | ov overviewState | 47 | ov overviewState | ||
| 48 | + | // sw is the column switcher (switcher.go). | |||
| 49 | + | sw switcherState | |||
| 48 | // overviewOpens counts the overview's openings: a swipe sliding when | 50 | // 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 int | 52 | 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 active | 329 | // 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 never | 340 | // 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 done | 427 | // 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 monitor | 451 | // 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 | |||
| 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().Output | 459 | return m.Current().Output | ||
M internal/core/publish_alloc_test.go +43 -0
| @@ -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 | 295 | // 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 leaving | 295 | // 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 | |||
| 22 | 22 | // windows are going, and alone sizes the configures. | |
| 23 | 23 | layout, settled := sc.shown, sc.settledLayout | |
| 24 | 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 | 26 | // Tiles stop at the panels; the overview lays out previews over | |
| 27 | 27 | // the whole output. | |
| 28 | 28 | u := view.usable | |
| 29 | 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 | 31 | var real map[WindowID]Placement | |
| 32 | - | if sc.mon.ov.open { | |
| 32 | + | if sc.mon.previewing() { | |
| 33 | 33 | // Local to this screen's build: reused, cleared each time. | |
| 34 | 34 | if c.overviewReal == nil { | |
| 35 | 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 | 46 | scene.Dim = floatDim(layout, view, c.cfg.Floating.Dim) | |
| 47 | - | if sc.mon.ov.open { | |
| 47 | + | if sc.mon.previewing() { | |
| 48 | 48 | // Darken the wallpaper around the previews. | |
| 49 | 49 | scene.Dim = c.cfg.Floating.Dim | |
| 50 | 50 | scene.DimBehind = scene.Dim > 0 | |
| @@ -56,6 +56,9 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||
| 56 | 56 | // Frame the selection and separate numbered and named row groups. | |
| 57 | 57 | _, scene.Separators = sc.mon.overviewRows() | |
| 58 | 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 | 62 | } else { | |
| 60 | 63 | // Only the focused output lights the focused window's lines. | |
| 61 | 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 | 93 | 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 | 96 | drawable = true | |
| 94 | 97 | if p.ID == c.pulse.id { | |
| 95 | 98 | 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.settledLayout | 23 | 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 over | 26 | // Tiles stop at the panels; the overview lays out previews over | ||
| 27 | // the whole output. | 27 | // the whole output. | ||
| 28 | u := view.usable | 28 | 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]Placement | 31 | 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.Dim | 49 | scene.Dim = c.cfg.Floating.Dim | ||
| 50 | scene.DimBehind = scene.Dim > 0 | 50 | 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 | continue | 93 | 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 = true | 96 | drawable = true | ||
| 94 | if p.ID == c.pulse.id { | 97 | if p.ID == c.pulse.id { | ||
| 95 | sw.FocusEffect = pulse | 98 | 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 } | |||
| 64 | 64 | func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil } | |
| 65 | 65 | ||
| 66 | 66 | // 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 | 69 | func (s *screen) rectsOwner() *Workspace { | |
| 69 | - | if s.mon.ov.open { | |
| 70 | + | if s.mon.previewing() { | |
| 70 | 71 | return nil | |
| 71 | 72 | } | |
| 72 | 73 | return s.mon.Current() | |
| @@ -334,6 +335,9 @@ func (c *Core) removeScreen(name string) { | |||
| 334 | 335 | if i < 0 { | |
| 335 | 336 | return | |
| 336 | 337 | } | |
| 338 | + | if c.switcher.sc == c.screens[i] { | |
| 339 | + | c.cancelSwitcher() | |
| 340 | + | } | |
| 337 | 341 | // A capture session's off-screen workspace of this output goes with it. | |
| 338 | 342 | if c.configures.cw.sc == c.screens[i] { | |
| 339 | 343 | 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 on | 66 | // 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 nil | 71 | 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 | return | 336 | 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 { | |||
| 46 | 46 | c.gesture = clientGesture{} | |
| 47 | 47 | c.stopAnimations() | |
| 48 | 48 | c.stopPulse() | |
| 49 | + | c.cancelSwitcher() | |
| 49 | 50 | if state.Protected { | |
| 50 | 51 | c.dropCaptureSessions() | |
| 51 | 52 | } | |
| @@ -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 ( | |||
| 25 | 25 | type viewShot struct { | |
| 26 | 26 | sc *screen | |
| 27 | 27 | 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 | |
| 31 | 32 | // settledView is ws's settled view and view where its columns were on screen | |
| 32 | 33 | // (settledView plus shift). | |
| 33 | 34 | settledView int | |
| @@ -50,7 +51,7 @@ type viewShot struct { | |||
| 50 | 51 | list []*Workspace | |
| 51 | 52 | pos float64 | |
| 52 | 53 | 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 | 55 | // hidden windows left out. The slice is reused like list. | |
| 55 | 56 | rects []rectShot | |
| 56 | 57 | // stash holds the settled placements of the stash windows the screen | |
| @@ -70,7 +71,7 @@ type rectShot struct { | |||
| 70 | 71 | rect Rect | |
| 71 | 72 | off, vel rectOffsets | |
| 72 | 73 | // 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 | 75 | fade, fadeV float64 | |
| 75 | 76 | // dim and dimV are the dim offset of a running dim motion and its | |
| 76 | 77 | // speed; veil is the veil the window's settled placement drew | |
| @@ -91,7 +92,7 @@ type rectShot struct { | |||
| 91 | 92 | // The walk shrinks content toward the content rect's top-left, so this | |
| 92 | 93 | // suits aspect-preserving changes: the zoom follows the smaller of the width | |
| 93 | 94 | // 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 | |
| 95 | 96 | // its Preview: a window that opens into a card goes from 1 to Preview with | |
| 96 | 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 | 228 | // refreshShown builds the layouts of every screen once per publish: the | |
| 228 | 229 | // settled one (what configures are sized from) and the shown one (the same | |
| 229 | 230 | // 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 | 232 | // any other change drops them. | |
| 232 | 233 | // | |
| 233 | 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 | 405 | shots := c.snapshot(now) | |
| 405 | 406 | 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 { | |
| 409 | 410 | shots[i].ws = nil | |
| 410 | 411 | } | |
| 411 | 412 | } | |
| @@ -416,12 +417,12 @@ func (c *Core) mapWindow(v ports.WindowMapped) { | |||
| 416 | 417 | ||
| 417 | 418 | // appearMapped starts the entrance of a window that just mapped when it | |
| 418 | 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 keeps | |
| 420 | + | // not with a preview open or a session lock, not fullscreen (it keeps | |
| 420 | 421 | // the direct scanout path), not a float below the columns, not a stash | |
| 421 | 422 | // peek. | |
| 422 | 423 | func (c *Core) appearMapped(id WindowID, now time.Time) { | |
| 423 | 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 | 426 | return | |
| 426 | 427 | } | |
| 427 | 428 | for _, p := range sc.mon.Layout() { | |
| @@ -442,7 +443,7 @@ func (c *Core) appearMapped(id WindowID, now time.Time) { | |||
| 442 | 443 | // leaves from its drawn rect, fade and speed, not from its settled rect). | |
| 443 | 444 | // Like a map, an unmap is a client event that animates; the settled layout | |
| 444 | 445 | // 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 | 447 | func (c *Core) unmapWindow(v ports.WindowUnmapped) { | |
| 447 | 448 | // The window is unmapped: a toplevel that maps again keeps its ID and | |
| 448 | 449 | // must be configured from nothing (a leaving entry keeps no configure). | |
| @@ -451,7 +452,7 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) { | |||
| 451 | 452 | if sc == nil { | |
| 452 | 453 | 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 | 456 | sc.mon.RemoveWindow(v.ID) | |
| 456 | 457 | delete(sc.rects, v.ID) | |
| 457 | 458 | return | |
| @@ -471,7 +472,7 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) { | |||
| 471 | 472 | shots := c.snapshot(now) | |
| 472 | 473 | var shot *rectShot | |
| 473 | 474 | for i := range shots { | |
| 474 | - | if shots[i].overview { | |
| 475 | + | if shots[i].preview { | |
| 475 | 476 | shots[i].ws = nil | |
| 476 | 477 | } | |
| 477 | 478 | if shots[i].sc == sc { | |
| @@ -599,7 +600,8 @@ func (c *Core) scaleFrom(rm *rectMotion, settled Rect, k float64, now time.Time) | |||
| 599 | 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 | 605 | // Running motions are sampled at now on a copy, so a transition that | |
| 604 | 606 | // follows chains from their speed at the time of the action, not from the | |
| 605 | 607 | // 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 | 622 | s.sc = sc | |
| 621 | 623 | 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. | |
| 625 | 627 | s.settledView, s.view = w.View, float64(w.View)+w.view.off | |
| 626 | 628 | s.switchAxis = w.policy().workspace | |
| 627 | 629 | if m.switchView.busy() { | |
| @@ -640,7 +642,7 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||
| 640 | 642 | } | |
| 641 | 643 | // settledLayout and shown are index-aligned and differ only by | |
| 642 | 644 | // 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 | 646 | for _, p := range sc.settledLayout { | |
| 645 | 647 | if p.Hidden { | |
| 646 | 648 | continue | |
| @@ -655,11 +657,11 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||
| 655 | 657 | } | |
| 656 | 658 | r.veil = c.peekDim(p) | |
| 657 | 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 | 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 | 665 | // A landing slide measures in the list it began on. | |
| 664 | 666 | list := m.Workspaces | |
| 665 | 667 | if m.switchList != nil { | |
| @@ -685,7 +687,7 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||
| 685 | 687 | // transition starts the springs that take each screen from what before | |
| 686 | 688 | // showed to what the action left, at now. It does nothing with animations | |
| 687 | 689 | // 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 | |
| 689 | 691 | // there is no camera, only the cards' (transitionOverview). | |
| 690 | 692 | // | |
| 691 | 693 | // 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 | 706 | if b.ws == nil || !c.hasScreen(b.sc) || c.following(b.sc) { | |
| 705 | 707 | continue | |
| 706 | 708 | } | |
| 707 | - | if b.overview || b.sc.mon.ov.open { | |
| 709 | + | if b.preview || b.sc.mon.previewing() { | |
| 708 | 710 | c.transitionOverview(b, now) | |
| 709 | 711 | continue | |
| 710 | 712 | } | |
| @@ -885,18 +887,19 @@ func (c *Core) peekDim(p Placement) float64 { | |||
| 885 | 887 | } | |
| 886 | 888 | ||
| 887 | 889 | // 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 | |
| 893 | 896 | // of its own; one drawn before and not after just goes. Motions of the | |
| 894 | 897 | // other state are dropped first: their rects are not the ones snapshot | |
| 895 | 898 | // measured from. A fade that ran on a window drawn in both states carries | |
| 896 | 899 | // over (picking a card that still fades in does not pop it). | |
| 897 | 900 | func (c *Core) transitionOverview(b *viewShot, now time.Time) { | |
| 898 | 901 | sc := b.sc | |
| 899 | - | changed := b.overview != sc.mon.ov.open | |
| 902 | + | changed := b.preview != sc.mon.previewing() | |
| 900 | 903 | if changed { | |
| 901 | 904 | sc.stopRects() | |
| 902 | 905 | } | |
| @@ -936,7 +939,7 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) { | |||
| 936 | 939 | func movedFrom(b *viewShot, before []viewShot) *viewShot { | |
| 937 | 940 | w := b.sc.mon.Current() | |
| 938 | 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 | 943 | return o | |
| 941 | 944 | } | |
| 942 | 945 | } | |
| @@ -25,9 +25,10 @@ import ( | |||||
| 25 | type viewShot struct { | 25 | type viewShot struct { | ||
| 26 | sc *screen | 26 | sc *screen | ||
| 27 | ws *Workspace | 27 | ws *Workspace | ||
| 28 | - | // overview is set when the screen showed its overview: ws is then the | 28 | + | // 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 bool | 30 | + | // 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 screen | 32 | // settledView is ws's settled view and view where its columns were on screen | ||
| 32 | // (settledView plus shift). | 33 | // (settledView plus shift). | ||
| 33 | settledView int | 34 | settledView int | ||
| @@ -50,7 +51,7 @@ type viewShot struct { | |||||
| 50 | list []*Workspace | 51 | list []*Workspace | ||
| 51 | pos float64 | 52 | pos float64 | ||
| 52 | ok bool | 53 | 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 []rectShot | 56 | rects []rectShot | ||
| 56 | // stash holds the settled placements of the stash windows the screen | 57 | // stash holds the settled placements of the stash windows the screen | ||
| @@ -70,7 +71,7 @@ type rectShot struct { | |||||
| 70 | rect Rect | 71 | rect Rect | ||
| 71 | off, vel rectOffsets | 72 | off, vel rectOffsets | ||
| 72 | // fade and fadeV are the fade offset of a running fade motion and its | 73 | // 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 float64 | 75 | fade, fadeV float64 | ||
| 75 | // dim and dimV are the dim offset of a running dim motion and its | 76 | // 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 drew | 77 | // 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 this | 92 | // 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 width | 93 | // suits aspect-preserving changes: the zoom follows the smaller of the width | ||
| 93 | // and height ratios, so content never exceeds its frame when the aspect | 94 | // 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 |
| 95 | // its Preview: a window that opens into a card goes from 1 to Preview with | 96 | // 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: the | 228 | // refreshShown builds the layouts of every screen once per publish: the | ||
| 228 | // settled one (what configures are sized from) and the shown one (the same | 229 | // 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 motions | 230 | // 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 map | 407 | + | // 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 = nil | 410 | 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 it | 418 | // 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 keeps | 420 | + | // 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 stash | 421 | // 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 | return | 426 | 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 layout | 444 | // 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 a | 445 | // 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 and | 448 | // 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 | return | 453 | 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 | return | 458 | 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 *rectShot | 473 | 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 = nil | 476 | 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 that | 605 | // 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 the | 606 | // 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 measured | 607 | // 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 = sc | 622 | s.sc = sc | ||
| 621 | w := m.Current() | 623 | w := m.Current() | ||
| 622 | - | s.ws, s.overview = w, m.ov.open | 624 | + | 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.off | 627 | s.settledView, s.view = w.View, float64(w.View)+w.view.off | ||
| 626 | s.switchAxis = w.policy().workspace | 628 | 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 by | 643 | // settledLayout and shown are index-aligned and differ only by | ||
| 642 | // the offsets of sc.rects: the shot keeps both unrounded. The | 644 | // 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 | continue | 648 | 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.Workspaces | 666 | 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 before | 687 | // transition starts the springs that take each screen from what before | ||
| 686 | // showed to what the action left, at now. It does nothing with animations | 688 | // 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 rect | 689 | // 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 |
| 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 a | 693 | // 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 | continue | 707 | 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 | continue | 711 | 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 closed | 889 | // transitionOverview starts the motions of an action that opened or closed | ||
| 888 | - | // the overview, or moved inside it (the selection, the rows). The camera | 890 | + | // a preview (the overview or the column switcher), or moved inside it (the |
| 889 | - | // springs stay stopped (ToggleOverview): every window drawn before and | 891 | + | // selection, the rows). The camera springs stay stopped (ToggleOverview): |
| 890 | - | // after moves from its shown rect to its settled one, its content scaled | 892 | + | // 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 not | 893 | + | // settled one, its content scaled with it (a card's Zoom, |
| 892 | - | // before (another row, a column the overview hid) fades in with no motion | 894 | + | // 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 the | 896 | // 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 snapshot | 897 | // 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 carries | 898 | // 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.sc | 901 | sc := b.sc | ||
| 899 | - | changed := b.overview != sc.mon.ov.open | 902 | + | 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 o | 943 | return o | ||
| 941 | } | 944 | } | ||
| 942 | } | 945 | } | ||