feat(core): add a workspace overview (#56)
* feat(core): add a cmd+o overview of the workspace's columns toggle-overview shows every column of the current workspace in one row, each tile shrunk by the same factor (fit to the width, 0.25 to 0.6, then scrolled) so windows keep their shape, with the workspaces above and below dimmed. h/l and j/k move the selection, Return keeps it, Escape returns, a click picks a preview. - Previews are drawn from the windows' last buffers: clients are not resized. ports.SceneWindow.Preview scales a window's surface tree in the Vulkan walk; DRM keeps previews off the overlay plane. - Previews take no pointer input and show no popups. - Stashed and native floating windows are hidden in the overview. * fix(core): smooth shrunk previews and keep launcher keys in the overview - Buffers drawn under half size average four bilinear samples per pixel (flagSmooth), so shrunk text and thin lines stay smooth. - A layer surface or grabbing menu holding the keyboard gets its keys while the overview is open. - Fixed-overflow workspaces preview as a row of columns, not their overlapping spiral. - Picking a preview gives the keyboard back to windows. * feat(core): show each workspace's stash as a card pile in the overview The stash, hidden or not, shows as a pile of cards on the left of its row: the selected stashed window in front, up to three others behind it, dimmed; the row takes the width left. h from the first column enters the pile, h/l browse it, l past its end returns to the first column. Return or a click on a card closes the overview with the stash shown on that window; Escape leaves the stash unchanged. * fix(core): track the overview's stash card by window, not index - The selected card is a window ID: removing another stash entry keeps it, removing it drops the selection back to the columns. - A workspace shown by a bind does not inherit the card selection. - The card is the monitor's focused window while selected: it gets the keyboard, and close-window closes it. - Picking a card drops another stashed window's pending fullscreen. - The pile takes at most a third of the width on tiny outputs. * feat(core): move the overview selection with two-finger scrolling Two-finger scrolling steps one column or workspace every 60 units along the axis the fingers move most, and starts over when they lift; a wheel notch steps once. It follows touchpad.natural-scroll. A launcher holding the keyboard keeps getting the scroll. * feat(input): open and close the overview with a four-finger swipe - libinput streams four-finger swipes too; SwipeBegin carries the finger count. - A vertical four-finger swipe that goes far enough opens the overview (up) or closes it on the selection (down), whatever natural-scroll says, as niri does. Sideways it does nothing. - In the overview a three-finger swipe does not slide the view: it moves the selection one step when the fingers lift. - Headless scripts gain swipe4 up|down. * fix(core): open the overview on the focus and drop swipes it interrupts - The overview opens on the shown stash's card when the stash has the focus, and moves a native float's focus to its column (floats are hidden in the overview); Escape still returns to the float. - A three-finger column or workspace swipe running when the overview opens is dropped: it no longer slides or lands under it. - Scroll distance is per monitor and starts from zero when the overview opens; a wheel frame of several notches moves once per notch. - Test that previews never go to an overlay plane. * fix(core): drop a swipe the overview opened over, even once closed A swipe remembers how many times the overview had opened on its monitor when it picked its mode. A column or workspace slide the overview opened over is dropped, even when the overview closed again before the next update or the lift.
- commit
- 56de2833f822a74108202f85c603d94eb984dae7
- author
- bnema · · 2026-09-29 02:27 UTC
- committer
- GitHub · · 2026-09-29 02:27 UTC
- parent
- 9682d35
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
31 files · +1555 -86
M docs/config.md +19 -0
| @@ -108,6 +108,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||
| 108 | 108 | | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; activating one of them leaves fullscreen | | |
| 109 | 109 | | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | | |
| 110 | 110 | | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window | | |
| 111 | + | | `toggle-overview` | Open the [overview](#overview), or close it on the selected window | | |
| 111 | 112 | | `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 | | |
| 112 | 113 | | `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 | | |
| 113 | 114 | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen | | |
| @@ -131,6 +132,23 @@ While the stash has the focus, `focus-column-left/right` and the three-finger sw | |||
| 131 | 132 | ||
| 132 | 133 | Native floating windows (dialogs, file pickers) are not in the stash: they stay centred above it. | |
| 133 | 134 | ||
| 135 | + | ### Overview | |
| 136 | + | ||
| 137 | + | `toggle-overview` shows every column of the current workspace in one row, shrunk by the same factor so each window keeps its shape, as small as needed to fit (down to a quarter, then the row scrolls). The workspaces above and below show dimmed. Windows are not resized: the previews are their last frames. | |
| 138 | + | ||
| 139 | + | | Key | Action | | |
| 140 | + | | --- | --- | | |
| 141 | + | | `h` / `l`, `left` / `right` | Select the column on the left / right; left of the first column, the stash pile | | |
| 142 | + | | `k` / `j`, `up` / `down` | Select the workspace above / below | | |
| 143 | + | | `return` | Close the overview on the selected window | | |
| 144 | + | | `escape` | Close it and return to the window it opened on | | |
| 145 | + | ||
| 146 | + | Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row: its selected window in front, up to three others behind it, dimmed. In the pile, `h` / `l` browse the stash and `l` past its last window returns to the first column. `return` or a click on a card closes the overview with the stash shown on that window. | |
| 147 | + | ||
| 148 | + | A four-finger swipe up opens the overview and a swipe down closes it on the selection, whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through the workspaces, following `touchpad.natural-scroll`. A three-finger swipe moves it one step when the fingers lift. | |
| 149 | + | ||
| 150 | + | A click on a preview picks it. Other binds still work. Native floating windows (dialogs) are not shown. | |
| 151 | + | ||
| 134 | 152 | ### Default binds | |
| 135 | 153 | ||
| 136 | 154 | | Keys | Action | | |
| @@ -155,6 +173,7 @@ Native floating windows (dialogs, file pickers) are not in the stash: they stay | |||
| 155 | 173 | | `cmd+shift+f` | `toggle-fullscreen` | | |
| 156 | 174 | | `cmd+s` | `toggle-window-stash` | | |
| 157 | 175 | | `cmd+shift+s` | `toggle-stash-visible` | | |
| 176 | + | | `cmd+o` | `toggle-overview` | | |
| 158 | 177 | | `cmd+q` | `close-window` | | |
| 159 | 178 | | `ctrl+alt+backspace` | `quit` | | |
| 160 | 179 | | `ctrl+cmd+space` | `spawn fuzzel` | | |
| @@ -108,6 +108,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||||
| 108 | | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; activating one of them leaves fullscreen | | 108 | | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; activating one of them leaves fullscreen | | ||
| 109 | | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | | 109 | | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | | ||
| 110 | | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window | | 110 | | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window | | ||
| 111 | + | | `toggle-overview` | Open the [overview](#overview), or close it on the selected window | | |||
| 111 | | `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 | | 112 | | `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 | | ||
| 112 | | `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 | | 113 | | `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 | | ||
| 113 | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen | | 114 | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen | | ||
| @@ -131,6 +132,23 @@ While the stash has the focus, `focus-column-left/right` and the three-finger sw | |||||
| 131 | 132 | ||||
| 132 | Native floating windows (dialogs, file pickers) are not in the stash: they stay centred above it. | 133 | Native floating windows (dialogs, file pickers) are not in the stash: they stay centred above it. | ||
| 133 | 134 | ||||
| 135 | + | ### Overview | |||
| 136 | + | ||||
| 137 | + | `toggle-overview` shows every column of the current workspace in one row, shrunk by the same factor so each window keeps its shape, as small as needed to fit (down to a quarter, then the row scrolls). The workspaces above and below show dimmed. Windows are not resized: the previews are their last frames. | |||
| 138 | + | ||||
| 139 | + | | Key | Action | | |||
| 140 | + | | --- | --- | | |||
| 141 | + | | `h` / `l`, `left` / `right` | Select the column on the left / right; left of the first column, the stash pile | | |||
| 142 | + | | `k` / `j`, `up` / `down` | Select the workspace above / below | | |||
| 143 | + | | `return` | Close the overview on the selected window | | |||
| 144 | + | | `escape` | Close it and return to the window it opened on | | |||
| 145 | + | ||||
| 146 | + | Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row: its selected window in front, up to three others behind it, dimmed. In the pile, `h` / `l` browse the stash and `l` past its last window returns to the first column. `return` or a click on a card closes the overview with the stash shown on that window. | |||
| 147 | + | ||||
| 148 | + | A four-finger swipe up opens the overview and a swipe down closes it on the selection, whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through the workspaces, following `touchpad.natural-scroll`. A three-finger swipe moves it one step when the fingers lift. | |||
| 149 | + | ||||
| 150 | + | A click on a preview picks it. Other binds still work. Native floating windows (dialogs) are not shown. | |||
| 151 | + | ||||
| 134 | ### Default binds | 152 | ### Default binds | ||
| 135 | 153 | ||||
| 136 | | Keys | Action | | 154 | | Keys | Action | | ||
| @@ -155,6 +173,7 @@ Native floating windows (dialogs, file pickers) are not in the stash: they stay | |||||
| 155 | | `cmd+shift+f` | `toggle-fullscreen` | | 173 | | `cmd+shift+f` | `toggle-fullscreen` | | ||
| 156 | | `cmd+s` | `toggle-window-stash` | | 174 | | `cmd+s` | `toggle-window-stash` | | ||
| 157 | | `cmd+shift+s` | `toggle-stash-visible` | | 175 | | `cmd+shift+s` | `toggle-stash-visible` | | ||
| 176 | + | | `cmd+o` | `toggle-overview` | | |||
| 158 | | `cmd+q` | `close-window` | | 177 | | `cmd+q` | `close-window` | | ||
| 159 | | `ctrl+alt+backspace` | `quit` | | 178 | | `ctrl+alt+backspace` | `quit` | | ||
| 160 | | `ctrl+cmd+space` | `spawn fuzzel` | | 179 | | `ctrl+cmd+space` | `spawn fuzzel` | | ||
M docs/headless.md +1 -1
| @@ -18,7 +18,7 @@ Screenshots through `grim` are verified with `zwlr_screencopy_v1` and `ext_image | |||
| 18 | 18 | - `sleep 1s` | |
| 19 | 19 | - `move <x> <y>` (output coordinates) | |
| 20 | 20 | - `click [left|right|middle]`, `down <button>`, `up <button>` | |
| 21 | - | - `swipe up|down|left|right` (a quick three-finger touchpad flick) | |
| 21 | + | - `swipe up|down|left|right` (a quick three-finger touchpad flick); `swipe4 up|down` is a four-finger one | |
| 22 | 22 | ||
| 23 | 23 | `examples/testime` is a minimal input method for testing text input. Each time an app enables a text input, it sends a preedit string, then commits the final text: | |
| 24 | 24 | ||
| @@ -18,7 +18,7 @@ Screenshots through `grim` are verified with `zwlr_screencopy_v1` and `ext_image | |||||
| 18 | - `sleep 1s` | 18 | - `sleep 1s` | ||
| 19 | - `move <x> <y>` (output coordinates) | 19 | - `move <x> <y>` (output coordinates) | ||
| 20 | - `click [left|right|middle]`, `down <button>`, `up <button>` | 20 | - `click [left|right|middle]`, `down <button>`, `up <button>` | ||
| 21 | - | - `swipe up|down|left|right` (a quick three-finger touchpad flick) | 21 | + | - `swipe up|down|left|right` (a quick three-finger touchpad flick); `swipe4 up|down` is a four-finger one |
| 22 | 22 | ||||
| 23 | `examples/testime` is a minimal input method for testing text input. Each time an app enables a text input, it sends a preedit string, then commits the final text: | 23 | `examples/testime` is a minimal input method for testing text input. Each time an app enables a text input, it sends a preedit string, then commits the final text: | ||
| 24 | 24 | ||||
M examples/config +1 -0
| @@ -127,6 +127,7 @@ bind.cmd+f = maximize-column | |||
| 127 | 127 | bind.cmd+shift+f = toggle-fullscreen | |
| 128 | 128 | bind.cmd+s = toggle-window-stash | |
| 129 | 129 | bind.cmd+shift+s = toggle-stash-visible | |
| 130 | + | bind.cmd+o = toggle-overview | |
| 130 | 131 | bind.cmd+q = close-window | |
| 131 | 132 | bind.ctrl+alt+backspace = quit | |
| 132 | 133 | bind.ctrl+cmd+space = spawn fuzzel | |
| @@ -127,6 +127,7 @@ bind.cmd+f = maximize-column | |||||
| 127 | bind.cmd+shift+f = toggle-fullscreen | 127 | bind.cmd+shift+f = toggle-fullscreen | ||
| 128 | bind.cmd+s = toggle-window-stash | 128 | bind.cmd+s = toggle-window-stash | ||
| 129 | bind.cmd+shift+s = toggle-stash-visible | 129 | bind.cmd+shift+s = toggle-stash-visible | ||
| 130 | + | bind.cmd+o = toggle-overview | |||
| 130 | bind.cmd+q = close-window | 131 | bind.cmd+q = close-window | ||
| 131 | bind.ctrl+alt+backspace = quit | 132 | bind.ctrl+alt+backspace = quit | ||
| 132 | bind.ctrl+cmd+space = spawn fuzzel | 133 | bind.ctrl+cmd+space = spawn fuzzel | ||
M internal/adapters/config/config.go +2 -1
| @@ -34,6 +34,7 @@ var defaultBinds = []struct{ combo, action string }{ | |||
| 34 | 34 | {"cmd+shift+f", "toggle-fullscreen"}, | |
| 35 | 35 | {"cmd+s", "toggle-window-stash"}, | |
| 36 | 36 | {"cmd+shift+s", "toggle-stash-visible"}, | |
| 37 | + | {"cmd+o", "toggle-overview"}, | |
| 37 | 38 | {"cmd+q", "close-window"}, | |
| 38 | 39 | {"ctrl+alt+backspace", "quit"}, | |
| 39 | 40 | {"ctrl+cmd+space", "spawn fuzzel"}, | |
| @@ -46,7 +47,7 @@ var defaultBinds = []struct{ combo, action string }{ | |||
| 46 | 47 | {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"}, | |
| 47 | 48 | {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"}, | |
| 48 | 49 | } | |
| 49 | - | var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "consume-or-expel-window-left": true, "consume-or-expel-window-right": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "maximize-column": true, "toggle-fullscreen": true, "toggle-window-stash": true, "toggle-stash-visible": true, "close-window": true, "quit": true} | |
| 50 | + | var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "consume-or-expel-window-left": true, "consume-or-expel-window-right": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "maximize-column": true, "toggle-fullscreen": true, "toggle-window-stash": true, "toggle-stash-visible": true, "toggle-overview": true, "close-window": true, "quit": true} | |
| 50 | 51 | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true} | |
| 51 | 52 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | |
| 52 | 53 | ||
| @@ -34,6 +34,7 @@ var defaultBinds = []struct{ combo, action string }{ | |||||
| 34 | {"cmd+shift+f", "toggle-fullscreen"}, | 34 | {"cmd+shift+f", "toggle-fullscreen"}, | ||
| 35 | {"cmd+s", "toggle-window-stash"}, | 35 | {"cmd+s", "toggle-window-stash"}, | ||
| 36 | {"cmd+shift+s", "toggle-stash-visible"}, | 36 | {"cmd+shift+s", "toggle-stash-visible"}, | ||
| 37 | + | {"cmd+o", "toggle-overview"}, | |||
| 37 | {"cmd+q", "close-window"}, | 38 | {"cmd+q", "close-window"}, | ||
| 38 | {"ctrl+alt+backspace", "quit"}, | 39 | {"ctrl+alt+backspace", "quit"}, | ||
| 39 | {"ctrl+cmd+space", "spawn fuzzel"}, | 40 | {"ctrl+cmd+space", "spawn fuzzel"}, | ||
| @@ -46,7 +47,7 @@ var defaultBinds = []struct{ combo, action string }{ | |||||
| 46 | {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"}, | 47 | {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"}, | ||
| 47 | {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"}, | 48 | {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"}, | ||
| 48 | } | 49 | } | ||
| 49 | - | var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "consume-or-expel-window-left": true, "consume-or-expel-window-right": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "maximize-column": true, "toggle-fullscreen": true, "toggle-window-stash": true, "toggle-stash-visible": true, "close-window": true, "quit": true} | 50 | + | var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "consume-or-expel-window-left": true, "consume-or-expel-window-right": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "maximize-column": true, "toggle-fullscreen": true, "toggle-window-stash": true, "toggle-stash-visible": true, "toggle-overview": true, "close-window": true, "quit": true} |
| 50 | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true} | 51 | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true} | ||
| 51 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | 52 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | ||
| 52 | 53 | ||||
M internal/adapters/config/config_test.go +2 -2
| @@ -22,10 +22,10 @@ func parseString(t *testing.T, s string) (ports.Config, []Warning) { | |||
| 22 | 22 | func TestDefaultsAndLoad(t *testing.T) { | |
| 23 | 23 | t.Setenv("TERMINAL", "") | |
| 24 | 24 | d := Defaults() | |
| 25 | - | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 55 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 { | |
| 25 | + | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 56 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 { | |
| 26 | 26 | t.Fatalf("defaults: %+v", d) | |
| 27 | 27 | } | |
| 28 | - | if d.Binds["Cmd+s"] != "toggle-window-stash" || d.Binds["Cmd+Shift+s"] != "toggle-stash-visible" || 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 { | |
| 28 | + | if d.Binds["Cmd+s"] != "toggle-window-stash" || d.Binds["Cmd+Shift+s"] != "toggle-stash-visible" || 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 { | |
| 29 | 29 | t.Fatal(d.Binds) | |
| 30 | 30 | } | |
| 31 | 31 | if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" { | |
| @@ -22,10 +22,10 @@ func parseString(t *testing.T, s string) (ports.Config, []Warning) { | |||||
| 22 | func TestDefaultsAndLoad(t *testing.T) { | 22 | func TestDefaultsAndLoad(t *testing.T) { | ||
| 23 | t.Setenv("TERMINAL", "") | 23 | t.Setenv("TERMINAL", "") | ||
| 24 | d := Defaults() | 24 | d := Defaults() | ||
| 25 | - | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 55 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 { | 25 | + | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 56 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 { |
| 26 | t.Fatalf("defaults: %+v", d) | 26 | t.Fatalf("defaults: %+v", d) | ||
| 27 | } | 27 | } | ||
| 28 | - | if d.Binds["Cmd+s"] != "toggle-window-stash" || d.Binds["Cmd+Shift+s"] != "toggle-stash-visible" || 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 { | 28 | + | if d.Binds["Cmd+s"] != "toggle-window-stash" || d.Binds["Cmd+Shift+s"] != "toggle-stash-visible" || 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 { |
| 29 | t.Fatal(d.Binds) | 29 | t.Fatal(d.Binds) | ||
| 30 | } | 30 | } | ||
| 31 | if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" { | 31 | if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" { | ||
M internal/adapters/drm/overlay.go +3 -2
| @@ -75,8 +75,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 75 | 75 | c := surfaces[w.ID] | |
| 76 | 76 | // A viewport crop or PQ content is composed: the plane would show | |
| 77 | 77 | // the whole buffer, and raw PQ values on this SDR output. A dimmed | |
| 78 | - | // window (a peeking stashed one) needs the veil drawn over it. | |
| 79 | - | if w.Dim <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && !c.Color.IsPQ2020() { | |
| 78 | + | // window (a peeking stashed one) needs the veil drawn over it; an | |
| 79 | + | // overview preview is drawn smaller than its buffer. | |
| 80 | + | if w.Dim <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && !c.Color.IsPQ2020() { | |
| 80 | 81 | pick = w | |
| 81 | 82 | continue | |
| 82 | 83 | } | |
| @@ -75,8 +75,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 75 | c := surfaces[w.ID] | 75 | c := surfaces[w.ID] | ||
| 76 | // A viewport crop or PQ content is composed: the plane would show | 76 | // A viewport crop or PQ content is composed: the plane would show | ||
| 77 | // the whole buffer, and raw PQ values on this SDR output. A dimmed | 77 | // the whole buffer, and raw PQ values on this SDR output. A dimmed | ||
| 78 | - | // window (a peeking stashed one) needs the veil drawn over it. | 78 | + | // window (a peeking stashed one) needs the veil drawn over it; an |
| 79 | - | if w.Dim <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && !c.Color.IsPQ2020() { | 79 | + | // overview preview is drawn smaller than its buffer. |
| 80 | + | if w.Dim <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && !c.Color.IsPQ2020() { | |||
| 80 | pick = w | 81 | pick = w | ||
| 81 | continue | 82 | continue | ||
| 82 | } | 83 | } | ||
M internal/adapters/drm/overlay_test.go +6 -0
| @@ -84,6 +84,12 @@ func TestOverlayCandidate(t *testing.T) { | |||
| 84 | 84 | if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" { | |
| 85 | 85 | t.Fatalf("dimmed window reason %q", reason) | |
| 86 | 86 | } | |
| 87 | + | // An overview preview is drawn smaller than its buffer: composed. | |
| 88 | + | s, _ = overlayScene() | |
| 89 | + | s.Windows[len(s.Windows)-1].Preview = 0.5 | |
| 90 | + | if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" { | |
| 91 | + | t.Fatalf("preview reason %q", reason) | |
| 92 | + | } | |
| 87 | 93 | // A viewport crop is composed: the plane would show the whole buffer. | |
| 88 | 94 | s, c = overlayScene() | |
| 89 | 95 | cropped := c[2] | |
| @@ -84,6 +84,12 @@ func TestOverlayCandidate(t *testing.T) { | |||||
| 84 | if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" { | 84 | if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" { | ||
| 85 | t.Fatalf("dimmed window reason %q", reason) | 85 | t.Fatalf("dimmed window reason %q", reason) | ||
| 86 | } | 86 | } | ||
| 87 | + | // An overview preview is drawn smaller than its buffer: composed. | |||
| 88 | + | s, _ = overlayScene() | |||
| 89 | + | s.Windows[len(s.Windows)-1].Preview = 0.5 | |||
| 90 | + | if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" { | |||
| 91 | + | t.Fatalf("preview reason %q", reason) | |||
| 92 | + | } | |||
| 87 | // A viewport crop is composed: the plane would show the whole buffer. | 93 | // A viewport crop is composed: the plane would show the whole buffer. | ||
| 88 | s, c = overlayScene() | 94 | s, c = overlayScene() | ||
| 89 | cropped := c[2] | 95 | cropped := c[2] | ||
M internal/adapters/headlessinput/input.go +6 -2
| @@ -125,7 +125,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||
| 125 | 125 | log.Warn().Str("line", line).Msg("invalid move") | |
| 126 | 126 | continue | |
| 127 | 127 | } | |
| 128 | - | if len(fields) > 0 && fields[0] == "swipe" { | |
| 128 | + | if len(fields) > 0 && (fields[0] == "swipe" || fields[0] == "swipe4") { | |
| 129 | 129 | dirs := map[string][2]float64{"up": {0, -1}, "down": {0, 1}, "left": {-1, 0}, "right": {1, 0}} | |
| 130 | 130 | d, ok := [2]float64{}, len(fields) == 2 | |
| 131 | 131 | if ok { | |
| @@ -135,7 +135,11 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||
| 135 | 135 | log.Warn().Str("line", line).Msg("invalid swipe") | |
| 136 | 136 | continue | |
| 137 | 137 | } | |
| 138 | - | for _, ev := range swipeEvents(d[0], d[1], monotonic()) { | |
| 138 | + | fingers := 3 | |
| 139 | + | if fields[0] == "swipe4" { | |
| 140 | + | fingers = 4 | |
| 141 | + | } | |
| 142 | + | for _, ev := range swipeEvents(fingers, d[0], d[1], monotonic()) { | |
| 139 | 143 | if err := sendPointer(ev); err != nil { | |
| 140 | 144 | return err | |
| 141 | 145 | } | |
| @@ -125,7 +125,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||||
| 125 | log.Warn().Str("line", line).Msg("invalid move") | 125 | log.Warn().Str("line", line).Msg("invalid move") | ||
| 126 | continue | 126 | continue | ||
| 127 | } | 127 | } | ||
| 128 | - | if len(fields) > 0 && fields[0] == "swipe" { | 128 | + | if len(fields) > 0 && (fields[0] == "swipe" || fields[0] == "swipe4") { |
| 129 | dirs := map[string][2]float64{"up": {0, -1}, "down": {0, 1}, "left": {-1, 0}, "right": {1, 0}} | 129 | dirs := map[string][2]float64{"up": {0, -1}, "down": {0, 1}, "left": {-1, 0}, "right": {1, 0}} | ||
| 130 | d, ok := [2]float64{}, len(fields) == 2 | 130 | d, ok := [2]float64{}, len(fields) == 2 | ||
| 131 | if ok { | 131 | if ok { | ||
| @@ -135,7 +135,11 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||||
| 135 | log.Warn().Str("line", line).Msg("invalid swipe") | 135 | log.Warn().Str("line", line).Msg("invalid swipe") | ||
| 136 | continue | 136 | continue | ||
| 137 | } | 137 | } | ||
| 138 | - | for _, ev := range swipeEvents(d[0], d[1], monotonic()) { | 138 | + | fingers := 3 |
| 139 | + | if fields[0] == "swipe4" { | |||
| 140 | + | fingers = 4 | |||
| 141 | + | } | |||
| 142 | + | for _, ev := range swipeEvents(fingers, d[0], d[1], monotonic()) { | |||
| 139 | if err := sendPointer(ev); err != nil { | 143 | if err := sendPointer(ev); err != nil { | ||
| 140 | return err | 144 | return err | ||
| 141 | } | 145 | } | ||
M internal/adapters/headlessinput/input_test.go +11 -2
| @@ -62,7 +62,7 @@ func TestPointerScript(t *testing.T) { | |||
| 62 | 62 | t.Skip(err) | |
| 63 | 63 | } | |
| 64 | 64 | script := make(chan string, 8) | |
| 65 | - | for _, line := range []string{"move 12.5 45", "click", "down right", "up right", "swipe sideways", "swipe left"} { | |
| 65 | + | for _, line := range []string{"move 12.5 45", "click", "down right", "up right", "swipe sideways", "swipe left", "swipe4 up"} { | |
| 66 | 66 | script <- line | |
| 67 | 67 | } | |
| 68 | 68 | close(script) | |
| @@ -70,7 +70,7 @@ func TestPointerScript(t *testing.T) { | |||
| 70 | 70 | if err := Run(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input")); err != nil { | |
| 71 | 71 | t.Fatal(err) | |
| 72 | 72 | } | |
| 73 | - | if len(input) != 5+swipeSteps+2 { | |
| 73 | + | if len(input) != 5+2*(swipeSteps+2) { | |
| 74 | 74 | t.Fatalf("events: %d", len(input)) | |
| 75 | 75 | } | |
| 76 | 76 | m := (<-input).(ports.PointerMotion) | |
| @@ -84,6 +84,9 @@ func TestPointerScript(t *testing.T) { | |||
| 84 | 84 | } | |
| 85 | 85 | } | |
| 86 | 86 | begin := (<-input).(ports.SwipeBegin) | |
| 87 | + | if begin.Fingers != 3 { | |
| 88 | + | t.Fatal(begin) | |
| 89 | + | } | |
| 87 | 90 | last := begin.Time | |
| 88 | 91 | for range swipeSteps { | |
| 89 | 92 | u := (<-input).(ports.SwipeUpdate) | |
| @@ -95,4 +98,10 @@ func TestPointerScript(t *testing.T) { | |||
| 95 | 98 | if e := (<-input).(ports.SwipeEnd); e.Cancelled || e.Time != last { | |
| 96 | 99 | t.Fatal(e) | |
| 97 | 100 | } | |
| 101 | + | if b := (<-input).(ports.SwipeBegin); b.Fingers != 4 { | |
| 102 | + | t.Fatal(b) | |
| 103 | + | } | |
| 104 | + | if u := (<-input).(ports.SwipeUpdate); u.DY >= 0 || u.DX != 0 { | |
| 105 | + | t.Fatal(u) | |
| 106 | + | } | |
| 98 | 107 | } | |
| @@ -62,7 +62,7 @@ func TestPointerScript(t *testing.T) { | |||||
| 62 | t.Skip(err) | 62 | t.Skip(err) | ||
| 63 | } | 63 | } | ||
| 64 | script := make(chan string, 8) | 64 | script := make(chan string, 8) | ||
| 65 | - | for _, line := range []string{"move 12.5 45", "click", "down right", "up right", "swipe sideways", "swipe left"} { | 65 | + | for _, line := range []string{"move 12.5 45", "click", "down right", "up right", "swipe sideways", "swipe left", "swipe4 up"} { |
| 66 | script <- line | 66 | script <- line | ||
| 67 | } | 67 | } | ||
| 68 | close(script) | 68 | close(script) | ||
| @@ -70,7 +70,7 @@ func TestPointerScript(t *testing.T) { | |||||
| 70 | if err := Run(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input")); err != nil { | 70 | if err := Run(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input")); err != nil { | ||
| 71 | t.Fatal(err) | 71 | t.Fatal(err) | ||
| 72 | } | 72 | } | ||
| 73 | - | if len(input) != 5+swipeSteps+2 { | 73 | + | if len(input) != 5+2*(swipeSteps+2) { |
| 74 | t.Fatalf("events: %d", len(input)) | 74 | t.Fatalf("events: %d", len(input)) | ||
| 75 | } | 75 | } | ||
| 76 | m := (<-input).(ports.PointerMotion) | 76 | m := (<-input).(ports.PointerMotion) | ||
| @@ -84,6 +84,9 @@ func TestPointerScript(t *testing.T) { | |||||
| 84 | } | 84 | } | ||
| 85 | } | 85 | } | ||
| 86 | begin := (<-input).(ports.SwipeBegin) | 86 | begin := (<-input).(ports.SwipeBegin) | ||
| 87 | + | if begin.Fingers != 3 { | |||
| 88 | + | t.Fatal(begin) | |||
| 89 | + | } | |||
| 87 | last := begin.Time | 90 | last := begin.Time | ||
| 88 | for range swipeSteps { | 91 | for range swipeSteps { | ||
| 89 | u := (<-input).(ports.SwipeUpdate) | 92 | u := (<-input).(ports.SwipeUpdate) | ||
| @@ -95,4 +98,10 @@ func TestPointerScript(t *testing.T) { | |||||
| 95 | if e := (<-input).(ports.SwipeEnd); e.Cancelled || e.Time != last { | 98 | if e := (<-input).(ports.SwipeEnd); e.Cancelled || e.Time != last { | ||
| 96 | t.Fatal(e) | 99 | t.Fatal(e) | ||
| 97 | } | 100 | } | ||
| 101 | + | if b := (<-input).(ports.SwipeBegin); b.Fingers != 4 { | |||
| 102 | + | t.Fatal(b) | |||
| 103 | + | } | |||
| 104 | + | if u := (<-input).(ports.SwipeUpdate); u.DY >= 0 || u.DX != 0 { | |||
| 105 | + | t.Fatal(u) | |||
| 106 | + | } | |||
| 98 | } | 107 | } | ||
M internal/adapters/headlessinput/swipe.go +4 -4
| @@ -15,10 +15,10 @@ const ( | |||
| 15 | 15 | swipeEvery = 8 * time.Millisecond | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | - | // swipeEvents is a three-finger swipe toward (dx, dy), a unit direction, | |
| 19 | - | // starting at the device time start. | |
| 20 | - | func swipeEvents(dx, dy float64, start time.Duration) []ports.InputEvent { | |
| 21 | - | evs := []ports.InputEvent{ports.SwipeBegin{Time: start}} | |
| 18 | + | // swipeEvents is a swipe of fingers fingers toward (dx, dy), a unit | |
| 19 | + | // direction, starting at the device time start. | |
| 20 | + | func swipeEvents(fingers int, dx, dy float64, start time.Duration) []ports.InputEvent { | |
| 21 | + | evs := []ports.InputEvent{ports.SwipeBegin{Fingers: fingers, Time: start}} | |
| 22 | 22 | at := start | |
| 23 | 23 | for range swipeSteps { | |
| 24 | 24 | at += swipeEvery | |
| @@ -15,10 +15,10 @@ const ( | |||||
| 15 | swipeEvery = 8 * time.Millisecond | 15 | swipeEvery = 8 * time.Millisecond | ||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | - | // swipeEvents is a three-finger swipe toward (dx, dy), a unit direction, | 18 | + | // swipeEvents is a swipe of fingers fingers toward (dx, dy), a unit |
| 19 | - | // starting at the device time start. | 19 | + | // direction, starting at the device time start. |
| 20 | - | func swipeEvents(dx, dy float64, start time.Duration) []ports.InputEvent { | 20 | + | func swipeEvents(fingers int, dx, dy float64, start time.Duration) []ports.InputEvent { |
| 21 | - | evs := []ports.InputEvent{ports.SwipeBegin{Time: start}} | 21 | + | evs := []ports.InputEvent{ports.SwipeBegin{Fingers: fingers, Time: start}} |
| 22 | at := start | 22 | at := start | ||
| 23 | for range swipeSteps { | 23 | for range swipeSteps { | ||
| 24 | at += swipeEvery | 24 | at += swipeEvery | ||
M internal/adapters/libinput/libinput.go +4 -7
| @@ -376,22 +376,19 @@ func (s *inputState) release() { | |||
| 376 | 376 | clear(s.devices) | |
| 377 | 377 | } | |
| 378 | 378 | ||
| 379 | - | // swipeFingers is the finger count of the swipes neferwl handles; other | |
| 380 | - | // counts are left alone. | |
| 381 | - | const swipeFingers = 3 | |
| 382 | - | ||
| 383 | - | // swipes streams one three-finger swipe at a time to core, which follows | |
| 379 | + | // swipes streams one three- or four-finger swipe at a time to core, which | |
| 380 | + | // follows | |
| 384 | 381 | // the fingers. owner is the touchpad of the swipe in progress (0: none); | |
| 385 | 382 | // another touchpad's swipe is ignored until it ends, so core never sees | |
| 386 | 383 | // two streams interleaved. | |
| 387 | 384 | type swipes struct{ owner uintptr } | |
| 388 | 385 | ||
| 389 | 386 | func (s *swipes) begin(dev uintptr, fingers int, at time.Duration) ports.InputEvent { | |
| 390 | - | if fingers != swipeFingers || s.owner != 0 { | |
| 387 | + | if (fingers != 3 && fingers != 4) || s.owner != 0 { | |
| 391 | 388 | return nil | |
| 392 | 389 | } | |
| 393 | 390 | s.owner = dev | |
| 394 | - | return ports.SwipeBegin{Time: at} | |
| 391 | + | return ports.SwipeBegin{Fingers: fingers, Time: at} | |
| 395 | 392 | } | |
| 396 | 393 | ||
| 397 | 394 | func (s *swipes) update(dev uintptr, dx, dy float64, at time.Duration) ports.InputEvent { | |
| @@ -376,22 +376,19 @@ func (s *inputState) release() { | |||||
| 376 | clear(s.devices) | 376 | clear(s.devices) | ||
| 377 | } | 377 | } | ||
| 378 | 378 | ||||
| 379 | - | // swipeFingers is the finger count of the swipes neferwl handles; other | 379 | + | // swipes streams one three- or four-finger swipe at a time to core, which |
| 380 | - | // counts are left alone. | 380 | + | // follows |
| 381 | - | const swipeFingers = 3 | |||
| 382 | - | ||||
| 383 | - | // swipes streams one three-finger swipe at a time to core, which follows | |||
| 384 | // the fingers. owner is the touchpad of the swipe in progress (0: none); | 381 | // the fingers. owner is the touchpad of the swipe in progress (0: none); | ||
| 385 | // another touchpad's swipe is ignored until it ends, so core never sees | 382 | // another touchpad's swipe is ignored until it ends, so core never sees | ||
| 386 | // two streams interleaved. | 383 | // two streams interleaved. | ||
| 387 | type swipes struct{ owner uintptr } | 384 | type swipes struct{ owner uintptr } | ||
| 388 | 385 | ||||
| 389 | func (s *swipes) begin(dev uintptr, fingers int, at time.Duration) ports.InputEvent { | 386 | func (s *swipes) begin(dev uintptr, fingers int, at time.Duration) ports.InputEvent { | ||
| 390 | - | if fingers != swipeFingers || s.owner != 0 { | 387 | + | if (fingers != 3 && fingers != 4) || s.owner != 0 { |
| 391 | return nil | 388 | return nil | ||
| 392 | } | 389 | } | ||
| 393 | s.owner = dev | 390 | s.owner = dev | ||
| 394 | - | return ports.SwipeBegin{Time: at} | 391 | + | return ports.SwipeBegin{Fingers: fingers, Time: at} |
| 395 | } | 392 | } | ||
| 396 | 393 | ||||
| 397 | func (s *swipes) update(dev uintptr, dx, dy float64, at time.Duration) ports.InputEvent { | 394 | func (s *swipes) update(dev uintptr, dx, dy float64, at time.Duration) ports.InputEvent { | ||
M internal/adapters/libinput/libinput_test.go +13 -3
| @@ -55,13 +55,13 @@ func TestSwipesIgnoreSecondTouchpad(t *testing.T) { | |||
| 55 | 55 | func TestSwipesStreamThreeFingers(t *testing.T) { | |
| 56 | 56 | var s swipes | |
| 57 | 57 | const pad, other = 1, 2 | |
| 58 | - | if ev := s.begin(pad, 4, time.Second); ev != nil { | |
| 59 | - | t.Fatalf("four fingers: %v", ev) | |
| 58 | + | if ev := s.begin(pad, 2, time.Second); ev != nil { | |
| 59 | + | t.Fatalf("two fingers: %v", ev) | |
| 60 | 60 | } | |
| 61 | 61 | if ev := s.update(pad, 1, 1, time.Second); ev != nil { | |
| 62 | 62 | t.Fatalf("update without a swipe: %v", ev) | |
| 63 | 63 | } | |
| 64 | - | if ev := s.begin(pad, 3, time.Second); ev != (ports.SwipeBegin{Time: time.Second}) { | |
| 64 | + | if ev := s.begin(pad, 3, time.Second); ev != (ports.SwipeBegin{Fingers: 3, Time: time.Second}) { | |
| 65 | 65 | t.Fatalf("begin: %v", ev) | |
| 66 | 66 | } | |
| 67 | 67 | if ev := s.update(pad, -3, 2, 2*time.Second); ev != (ports.SwipeUpdate{DX: -3, DY: 2, Time: 2 * time.Second}) { | |
| @@ -78,6 +78,16 @@ func TestSwipesStreamThreeFingers(t *testing.T) { | |||
| 78 | 78 | } | |
| 79 | 79 | } | |
| 80 | 80 | ||
| 81 | + | func TestSwipesStreamFourFingers(t *testing.T) { | |
| 82 | + | var s swipes | |
| 83 | + | if ev := s.begin(1, 4, time.Second); ev != (ports.SwipeBegin{Fingers: 4, Time: time.Second}) { | |
| 84 | + | t.Fatalf("four fingers: %v", ev) | |
| 85 | + | } | |
| 86 | + | if ev := s.begin(1, 5, time.Second); ev != nil { | |
| 87 | + | t.Fatalf("five fingers during a swipe: %v", ev) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 81 | 91 | func TestAccelProfile(t *testing.T) { | |
| 82 | 92 | if accelProfile(ports.AccelFlat) != 1 || accelProfile(ports.AccelAdaptive) != 2 { | |
| 83 | 93 | t.Fatal(accelProfile(ports.AccelFlat), accelProfile(ports.AccelAdaptive)) | |
| @@ -55,13 +55,13 @@ func TestSwipesIgnoreSecondTouchpad(t *testing.T) { | |||||
| 55 | func TestSwipesStreamThreeFingers(t *testing.T) { | 55 | func TestSwipesStreamThreeFingers(t *testing.T) { | ||
| 56 | var s swipes | 56 | var s swipes | ||
| 57 | const pad, other = 1, 2 | 57 | const pad, other = 1, 2 | ||
| 58 | - | if ev := s.begin(pad, 4, time.Second); ev != nil { | 58 | + | if ev := s.begin(pad, 2, time.Second); ev != nil { |
| 59 | - | t.Fatalf("four fingers: %v", ev) | 59 | + | t.Fatalf("two fingers: %v", ev) |
| 60 | } | 60 | } | ||
| 61 | if ev := s.update(pad, 1, 1, time.Second); ev != nil { | 61 | if ev := s.update(pad, 1, 1, time.Second); ev != nil { | ||
| 62 | t.Fatalf("update without a swipe: %v", ev) | 62 | t.Fatalf("update without a swipe: %v", ev) | ||
| 63 | } | 63 | } | ||
| 64 | - | if ev := s.begin(pad, 3, time.Second); ev != (ports.SwipeBegin{Time: time.Second}) { | 64 | + | if ev := s.begin(pad, 3, time.Second); ev != (ports.SwipeBegin{Fingers: 3, Time: time.Second}) { |
| 65 | t.Fatalf("begin: %v", ev) | 65 | t.Fatalf("begin: %v", ev) | ||
| 66 | } | 66 | } | ||
| 67 | if ev := s.update(pad, -3, 2, 2*time.Second); ev != (ports.SwipeUpdate{DX: -3, DY: 2, Time: 2 * time.Second}) { | 67 | if ev := s.update(pad, -3, 2, 2*time.Second); ev != (ports.SwipeUpdate{DX: -3, DY: 2, Time: 2 * time.Second}) { | ||
| @@ -78,6 +78,16 @@ func TestSwipesStreamThreeFingers(t *testing.T) { | |||||
| 78 | } | 78 | } | ||
| 79 | } | 79 | } | ||
| 80 | 80 | ||||
| 81 | + | func TestSwipesStreamFourFingers(t *testing.T) { | |||
| 82 | + | var s swipes | |||
| 83 | + | if ev := s.begin(1, 4, time.Second); ev != (ports.SwipeBegin{Fingers: 4, Time: time.Second}) { | |||
| 84 | + | t.Fatalf("four fingers: %v", ev) | |||
| 85 | + | } | |||
| 86 | + | if ev := s.begin(1, 5, time.Second); ev != nil { | |||
| 87 | + | t.Fatalf("five fingers during a swipe: %v", ev) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 81 | func TestAccelProfile(t *testing.T) { | 91 | func TestAccelProfile(t *testing.T) { | ||
| 82 | if accelProfile(ports.AccelFlat) != 1 || accelProfile(ports.AccelAdaptive) != 2 { | 92 | if accelProfile(ports.AccelFlat) != 1 || accelProfile(ports.AccelAdaptive) != 2 { | ||
| 83 | t.Fatal(accelProfile(ports.AccelFlat), accelProfile(ports.AccelAdaptive)) | 93 | t.Fatal(accelProfile(ports.AccelFlat), accelProfile(ports.AccelAdaptive)) | ||
M internal/adapters/vulkan/compose.go +6 -0
| @@ -46,6 +46,9 @@ const ( | |||
| 46 | 46 | flagExtendedLinear = 8 | |
| 47 | 47 | flagYUV = 16 | |
| 48 | 48 | flagP010 = 32 | |
| 49 | + | // flagSmooth supersamples a buffer shrunk under half size (overview | |
| 50 | + | // previews): four bilinear samples per pixel instead of one. | |
| 51 | + | flagSmooth = 64 | |
| 49 | 52 | ) | |
| 50 | 53 | ||
| 51 | 54 | // pushConstants is struct Draw of the shaders (std430 push constant block). | |
| @@ -353,6 +356,9 @@ func (r *Renderer) contentDraw(rect, full image.Rectangle, w, h int, source [4]f | |||
| 353 | 356 | if float64(full.Dx()) == cw && float64(full.Dy()) == ch && cw == float64(int(cw)) && ch == float64(int(ch)) { | |
| 354 | 357 | flags |= flagExact | |
| 355 | 358 | } | |
| 359 | + | if float64(full.Dx()) < cw/2 || float64(full.Dy()) < ch/2 { | |
| 360 | + | flags |= flagSmooth | |
| 361 | + | } | |
| 356 | 362 | dr.pc.misc = [4]uint32{mode, flags, uint32(r.width), uint32(r.height)} | |
| 357 | 363 | return dr | |
| 358 | 364 | } | |
| @@ -46,6 +46,9 @@ const ( | |||||
| 46 | flagExtendedLinear = 8 | 46 | flagExtendedLinear = 8 | ||
| 47 | flagYUV = 16 | 47 | flagYUV = 16 | ||
| 48 | flagP010 = 32 | 48 | flagP010 = 32 | ||
| 49 | + | // flagSmooth supersamples a buffer shrunk under half size (overview | |||
| 50 | + | // previews): four bilinear samples per pixel instead of one. | |||
| 51 | + | flagSmooth = 64 | |||
| 49 | ) | 52 | ) | ||
| 50 | 53 | ||||
| 51 | // pushConstants is struct Draw of the shaders (std430 push constant block). | 54 | // pushConstants is struct Draw of the shaders (std430 push constant block). | ||
| @@ -353,6 +356,9 @@ func (r *Renderer) contentDraw(rect, full image.Rectangle, w, h int, source [4]f | |||||
| 353 | if float64(full.Dx()) == cw && float64(full.Dy()) == ch && cw == float64(int(cw)) && ch == float64(int(ch)) { | 356 | if float64(full.Dx()) == cw && float64(full.Dy()) == ch && cw == float64(int(cw)) && ch == float64(int(ch)) { | ||
| 354 | flags |= flagExact | 357 | flags |= flagExact | ||
| 355 | } | 358 | } | ||
| 359 | + | if float64(full.Dx()) < cw/2 || float64(full.Dy()) < ch/2 { | |||
| 360 | + | flags |= flagSmooth | |||
| 361 | + | } | |||
| 356 | dr.pc.misc = [4]uint32{mode, flags, uint32(r.width), uint32(r.height)} | 362 | dr.pc.misc = [4]uint32{mode, flags, uint32(r.width), uint32(r.height)} | ||
| 357 | return dr | 363 | return dr | ||
| 358 | } | 364 | } | ||
M internal/adapters/vulkan/renderer_test.go +66 -0
| @@ -283,6 +283,72 @@ func TestRendererScale(t *testing.T) { | |||
| 283 | 283 | } | |
| 284 | 284 | } | |
| 285 | 285 | ||
| 286 | + | // An overview preview draws the whole buffer shrunk into its rect, not | |
| 287 | + | // the buffer's corner. | |
| 288 | + | func TestRendererPreview(t *testing.T) { | |
| 289 | + | r, err := New(64, 48) | |
| 290 | + | if err != nil { | |
| 291 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 292 | + | } | |
| 293 | + | defer r.Close() | |
| 294 | + | // 40x20 buffer: left half red, right half blue, drawn at half size. | |
| 295 | + | px := make([]byte, 40*20*4) | |
| 296 | + | for i := 0; i < len(px); i += 4 { | |
| 297 | + | if (i/4)%40 < 20 { | |
| 298 | + | px[i+2] = 200 | |
| 299 | + | } else { | |
| 300 | + | px[i] = 200 | |
| 301 | + | } | |
| 302 | + | px[i+3] = 255 | |
| 303 | + | } | |
| 304 | + | c := shmContent(t, 40, 20, 160, px) | |
| 305 | + | c.ID = 1 | |
| 306 | + | s := ports.Scene{Scale: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 20, H: 10}, Preview: 0.5}}} | |
| 307 | + | if err := render(r, s, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil { | |
| 308 | + | t.Fatal(err) | |
| 309 | + | } | |
| 310 | + | out := r.Pixels() | |
| 311 | + | for p, want := range map[image.Point]color.RGBA{ | |
| 312 | + | {5, 5}: {200, 0, 0, 255}, | |
| 313 | + | {22, 12}: {0, 0, 200, 255}, | |
| 314 | + | {25, 5}: {0, 0, 0, 255}, | |
| 315 | + | {5, 15}: {0, 0, 0, 255}, | |
| 316 | + | } { | |
| 317 | + | if got := out.At(p.X, p.Y); got != want { | |
| 318 | + | t.Errorf("%v = %v, want %v", p, got, want) | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | // A buffer shrunk under half size averages its texels: one-pixel white | |
| 324 | + | // and black columns shrunk 4× come out an even gray, not a beat pattern. | |
| 325 | + | func TestRendererPreviewSmooth(t *testing.T) { | |
| 326 | + | r, err := New(64, 48) | |
| 327 | + | if err != nil { | |
| 328 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 329 | + | } | |
| 330 | + | defer r.Close() | |
| 331 | + | px := make([]byte, 80*40*4) | |
| 332 | + | for i := 0; i < len(px); i += 4 { | |
| 333 | + | if (i/4)%2 == 0 { | |
| 334 | + | px[i], px[i+1], px[i+2] = 255, 255, 255 | |
| 335 | + | } | |
| 336 | + | px[i+3] = 255 | |
| 337 | + | } | |
| 338 | + | c := shmContent(t, 80, 40, 320, px) | |
| 339 | + | c.ID = 1 | |
| 340 | + | s := ports.Scene{Scale: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 20, H: 10}, Preview: 0.25}}} | |
| 341 | + | if err := render(r, s, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil { | |
| 342 | + | t.Fatal(err) | |
| 343 | + | } | |
| 344 | + | out := r.Pixels() | |
| 345 | + | for x := 1; x < 19; x++ { | |
| 346 | + | if v := out.RGBAAt(x, 5).R; v < 100 || v > 155 { | |
| 347 | + | t.Fatalf("x=%d: %d, want mid gray", x, v) | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | ||
| 286 | 352 | // solidContent is a w×h B8G8R8A8 buffer of one color. | |
| 287 | 353 | func solidContent(t *testing.T, w, h int, c color.RGBA) ports.SurfaceContent { | |
| 288 | 354 | px := make([]byte, w*h*4) | |
| @@ -283,6 +283,72 @@ func TestRendererScale(t *testing.T) { | |||||
| 283 | } | 283 | } | ||
| 284 | } | 284 | } | ||
| 285 | 285 | ||||
| 286 | + | // An overview preview draws the whole buffer shrunk into its rect, not | |||
| 287 | + | // the buffer's corner. | |||
| 288 | + | func TestRendererPreview(t *testing.T) { | |||
| 289 | + | r, err := New(64, 48) | |||
| 290 | + | if err != nil { | |||
| 291 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 292 | + | } | |||
| 293 | + | defer r.Close() | |||
| 294 | + | // 40x20 buffer: left half red, right half blue, drawn at half size. | |||
| 295 | + | px := make([]byte, 40*20*4) | |||
| 296 | + | for i := 0; i < len(px); i += 4 { | |||
| 297 | + | if (i/4)%40 < 20 { | |||
| 298 | + | px[i+2] = 200 | |||
| 299 | + | } else { | |||
| 300 | + | px[i] = 200 | |||
| 301 | + | } | |||
| 302 | + | px[i+3] = 255 | |||
| 303 | + | } | |||
| 304 | + | c := shmContent(t, 40, 20, 160, px) | |||
| 305 | + | c.ID = 1 | |||
| 306 | + | s := ports.Scene{Scale: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 20, H: 10}, Preview: 0.5}}} | |||
| 307 | + | if err := render(r, s, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil { | |||
| 308 | + | t.Fatal(err) | |||
| 309 | + | } | |||
| 310 | + | out := r.Pixels() | |||
| 311 | + | for p, want := range map[image.Point]color.RGBA{ | |||
| 312 | + | {5, 5}: {200, 0, 0, 255}, | |||
| 313 | + | {22, 12}: {0, 0, 200, 255}, | |||
| 314 | + | {25, 5}: {0, 0, 0, 255}, | |||
| 315 | + | {5, 15}: {0, 0, 0, 255}, | |||
| 316 | + | } { | |||
| 317 | + | if got := out.At(p.X, p.Y); got != want { | |||
| 318 | + | t.Errorf("%v = %v, want %v", p, got, want) | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | // A buffer shrunk under half size averages its texels: one-pixel white | |||
| 324 | + | // and black columns shrunk 4× come out an even gray, not a beat pattern. | |||
| 325 | + | func TestRendererPreviewSmooth(t *testing.T) { | |||
| 326 | + | r, err := New(64, 48) | |||
| 327 | + | if err != nil { | |||
| 328 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 329 | + | } | |||
| 330 | + | defer r.Close() | |||
| 331 | + | px := make([]byte, 80*40*4) | |||
| 332 | + | for i := 0; i < len(px); i += 4 { | |||
| 333 | + | if (i/4)%2 == 0 { | |||
| 334 | + | px[i], px[i+1], px[i+2] = 255, 255, 255 | |||
| 335 | + | } | |||
| 336 | + | px[i+3] = 255 | |||
| 337 | + | } | |||
| 338 | + | c := shmContent(t, 80, 40, 320, px) | |||
| 339 | + | c.ID = 1 | |||
| 340 | + | s := ports.Scene{Scale: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 20, H: 10}, Preview: 0.25}}} | |||
| 341 | + | if err := render(r, s, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil { | |||
| 342 | + | t.Fatal(err) | |||
| 343 | + | } | |||
| 344 | + | out := r.Pixels() | |||
| 345 | + | for x := 1; x < 19; x++ { | |||
| 346 | + | if v := out.RGBAAt(x, 5).R; v < 100 || v > 155 { | |||
| 347 | + | t.Fatalf("x=%d: %d, want mid gray", x, v) | |||
| 348 | + | } | |||
| 349 | + | } | |||
| 350 | + | } | |||
| 351 | + | ||||
| 286 | // solidContent is a w×h B8G8R8A8 buffer of one color. | 352 | // solidContent is a w×h B8G8R8A8 buffer of one color. | ||
| 287 | func solidContent(t *testing.T, w, h int, c color.RGBA) ports.SurfaceContent { | 353 | func solidContent(t *testing.T, w, h int, c color.RGBA) ports.SurfaceContent { | ||
| 288 | px := make([]byte, w*h*4) | 354 | px := make([]byte, w*h*4) | ||
M internal/adapters/vulkan/scene.go +30 -12
| @@ -16,7 +16,10 @@ type sceneWalk struct { | |||
| 16 | 16 | contents map[ports.WindowID]ports.SurfaceContent | |
| 17 | 17 | dmg *damageRegion | |
| 18 | 18 | // scale maps the scene's logical pixels to the target's physical ones. | |
| 19 | - | scale float64 | |
| 19 | + | scale float64 | |
| 20 | + | // zoom shrinks the surfaces of the window being placed: an overview | |
| 21 | + | // preview (ports.SceneWindow.Preview); 1 otherwise. | |
| 22 | + | zoom float64 | |
| 20 | 23 | bounds image.Rectangle | |
| 21 | 24 | draws []draw | |
| 22 | 25 | } | |
| @@ -24,7 +27,7 @@ type sceneWalk struct { | |||
| 24 | 27 | // draws walks the scene into quads in paint order. | |
| 25 | 28 | func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, dmg *damageRegion) []draw { | |
| 26 | 29 | clear(r.scratchDraws) // release references from longer earlier scenes | |
| 27 | - | w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, bounds: image.Rect(0, 0, r.width, r.height), draws: r.scratchDraws[:0]} | |
| 30 | + | w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, zoom: 1, bounds: image.Rect(0, 0, r.width, r.height), draws: r.scratchDraws[:0]} | |
| 28 | 31 | r.scratchCovers = r.scratchCovers[:0] | |
| 29 | 32 | if w.scale <= 0 { | |
| 30 | 33 | w.scale = 1 | |
| @@ -44,6 +47,12 @@ func (w *sceneWalk) physRect(x, y, width, height int) image.Rectangle { | |||
| 44 | 47 | return image.Rect(w.phys(x), w.phys(y), w.phys(x+width), w.phys(y+height)) | |
| 45 | 48 | } | |
| 46 | 49 | ||
| 50 | + | // physRectF is physRect for fractional logical coordinates (zoomed surfaces). | |
| 51 | + | func (w *sceneWalk) physRectF(x, y, width, height float64) image.Rectangle { | |
| 52 | + | p := func(v float64) int { return int(math.Round(v * w.scale)) } | |
| 53 | + | return image.Rect(p(x), p(y), p(x+width), p(y+height)) | |
| 54 | + | } | |
| 55 | + | ||
| 47 | 56 | // fill draws a solid rect (physical pixels). | |
| 48 | 57 | func (w *sceneWalk) fill(rect image.Rectangle, c [3]uint8) { | |
| 49 | 58 | rect = rect.Intersect(w.bounds) | |
| @@ -103,7 +112,11 @@ func (w *sceneWalk) windows() { | |||
| 103 | 112 | // Until its first buffer, a window shows the background: no flash. | |
| 104 | 113 | w.fill(body, parseColor(w.s.Background)) | |
| 105 | 114 | if !content.Empty() { | |
| 115 | + | if win.Preview > 0 { | |
| 116 | + | w.zoom = win.Preview | |
| 117 | + | } | |
| 106 | 118 | w.place(win.ID, &content, c.X, c.Y, c.W, c.H) | |
| 119 | + | w.zoom = 1 | |
| 107 | 120 | } | |
| 108 | 121 | if win.Floating { | |
| 109 | 122 | w.separators(win.ID) | |
| @@ -152,17 +165,22 @@ func (w *sceneWalk) popups(overLayers bool) { | |||
| 152 | 165 | // surface of the tree is keyed by its stable surface identity. | |
| 153 | 166 | func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y, width, height int) { | |
| 154 | 167 | clip := image.Rect(x, y, x+width, y+height) | |
| 155 | - | ox, oy := x-content.Geometry.X, y-content.Geometry.Y | |
| 168 | + | // Offsets inside the tree shrink with a zoomed (preview) window. | |
| 169 | + | z := w.zoom | |
| 170 | + | ox, oy := float64(x)-float64(content.Geometry.X)*z, float64(y)-float64(content.Geometry.Y)*z | |
| 171 | + | at := func(ch *ports.Subsurface) (float64, float64) { return ox + float64(ch.X)*z, oy + float64(ch.Y)*z } | |
| 156 | 172 | covers := w.opaqueChildren(content, ox, oy, clip) | |
| 157 | 173 | for i := range content.Children { | |
| 158 | 174 | if ch := &content.Children[i]; ch.Below { | |
| 159 | - | w.surface(&ch.SurfaceContent, ox+ch.X, oy+ch.Y, clip, shmKey{id, ch.Surface}, ch.Version, covers, false) | |
| 175 | + | cx, cy := at(ch) | |
| 176 | + | w.surface(&ch.SurfaceContent, cx, cy, clip, shmKey{id, ch.Surface}, ch.Version, covers, false) | |
| 160 | 177 | } | |
| 161 | 178 | } | |
| 162 | 179 | w.surface(content, ox, oy, clip, shmKey{id, content.Surface}, content.Version, covers, true) | |
| 163 | 180 | for i := range content.Children { | |
| 164 | 181 | if ch := &content.Children[i]; !ch.Below { | |
| 165 | - | w.surface(&ch.SurfaceContent, ox+ch.X, oy+ch.Y, clip, shmKey{id, ch.Surface}, ch.Version, nil, false) | |
| 182 | + | cx, cy := at(ch) | |
| 183 | + | w.surface(&ch.SurfaceContent, cx, cy, clip, shmKey{id, ch.Surface}, ch.Version, nil, false) | |
| 166 | 184 | } | |
| 167 | 185 | } | |
| 168 | 186 | } | |
| @@ -172,14 +190,14 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y | |||
| 172 | 190 | // they hide the surfaces below them. A child whose buffer will not draw is | |
| 173 | 191 | // left out, so the surface below it still shows: a dmabuf that fails to | |
| 174 | 192 | // import (opaqueChildren pre-imports it) or an unreadable shm buffer. | |
| 175 | - | func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy int, clip image.Rectangle) []image.Rectangle { | |
| 193 | + | func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy float64, clip image.Rectangle) []image.Rectangle { | |
| 176 | 194 | covers := w.r.scratchCovers[:0] | |
| 177 | 195 | for i := range content.Children { | |
| 178 | 196 | ch := &content.Children[i] | |
| 179 | 197 | if ch.Below || !ch.Opaque { | |
| 180 | 198 | continue | |
| 181 | 199 | } | |
| 182 | - | _, dst, ok := w.surfaceRects(&ch.SurfaceContent, ox+ch.X, oy+ch.Y, clip) | |
| 200 | + | _, dst, ok := w.surfaceRects(&ch.SurfaceContent, ox+float64(ch.X)*w.zoom, oy+float64(ch.Y)*w.zoom, clip) | |
| 183 | 201 | if !ok || !w.drawable(&ch.SurfaceContent) { | |
| 184 | 202 | continue | |
| 185 | 203 | } | |
| @@ -205,9 +223,9 @@ func (w *sceneWalk) drawable(c *ports.SurfaceContent) bool { | |||
| 205 | 223 | } | |
| 206 | 224 | ||
| 207 | 225 | // surfaceRects gives the physical rect of a surface buffer with its origin | |
| 208 | - | // at (x, y) logical, and the part of it clip (logical) shows. ok is false | |
| 209 | - | // when there is nothing to draw. | |
| 210 | - | func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y int, clip image.Rectangle) (full, dst image.Rectangle, ok bool) { | |
| 226 | + | // at (x, y) logical, shrunk by the walk's zoom, and the part of it clip | |
| 227 | + | // (logical) shows. ok is false when there is nothing to draw. | |
| 228 | + | func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y float64, clip image.Rectangle) (full, dst image.Rectangle, ok bool) { | |
| 211 | 229 | lw, lh := content.LogicalW, content.LogicalH | |
| 212 | 230 | if lw <= 0 || lh <= 0 { | |
| 213 | 231 | lw, lh = content.Width, content.Height | |
| @@ -218,7 +236,7 @@ func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y int, clip i | |||
| 218 | 236 | if content.DMABuf == nil && (content.SHM.Stride < content.Width*4 || content.SHM.Offset < 0) { | |
| 219 | 237 | return full, dst, false | |
| 220 | 238 | } | |
| 221 | - | full = w.physRect(x, y, lw, lh) | |
| 239 | + | full = w.physRectF(x, y, float64(lw)*w.zoom, float64(lh)*w.zoom) | |
| 222 | 240 | // A buffer drawn at the physical size (fractional-scale clients round | |
| 223 | 241 | // w*scale, we round per edge) is copied 1:1, never resampled for 1px. | |
| 224 | 242 | near := func(a, b int) bool { return a-b <= 1 && b-a <= 1 } | |
| @@ -239,7 +257,7 @@ func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y int, clip i | |||
| 239 | 257 | // surface draws one surface buffer with its origin at (x, y) logical, | |
| 240 | 258 | // clipped to clip (logical), unless an opaque surface above it (covers, | |
| 241 | 259 | // physical) hides all of it: its buffer is then neither copied nor drawn. | |
| 242 | - | func (w *sceneWalk) surface(content *ports.SurfaceContent, x, y int, clip image.Rectangle, key shmKey, seq uint64, covers []image.Rectangle, root bool) { | |
| 260 | + | func (w *sceneWalk) surface(content *ports.SurfaceContent, x, y float64, clip image.Rectangle, key shmKey, seq uint64, covers []image.Rectangle, root bool) { | |
| 243 | 261 | full, dst, ok := w.surfaceRects(content, x, y, clip) | |
| 244 | 262 | if !ok { | |
| 245 | 263 | return | |
| @@ -16,7 +16,10 @@ type sceneWalk struct { | |||||
| 16 | contents map[ports.WindowID]ports.SurfaceContent | 16 | contents map[ports.WindowID]ports.SurfaceContent | ||
| 17 | dmg *damageRegion | 17 | dmg *damageRegion | ||
| 18 | // scale maps the scene's logical pixels to the target's physical ones. | 18 | // scale maps the scene's logical pixels to the target's physical ones. | ||
| 19 | - | scale float64 | 19 | + | scale float64 |
| 20 | + | // zoom shrinks the surfaces of the window being placed: an overview | |||
| 21 | + | // preview (ports.SceneWindow.Preview); 1 otherwise. | |||
| 22 | + | zoom float64 | |||
| 20 | bounds image.Rectangle | 23 | bounds image.Rectangle | ||
| 21 | draws []draw | 24 | draws []draw | ||
| 22 | } | 25 | } | ||
| @@ -24,7 +27,7 @@ type sceneWalk struct { | |||||
| 24 | // draws walks the scene into quads in paint order. | 27 | // draws walks the scene into quads in paint order. | ||
| 25 | func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, dmg *damageRegion) []draw { | 28 | func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, dmg *damageRegion) []draw { | ||
| 26 | clear(r.scratchDraws) // release references from longer earlier scenes | 29 | clear(r.scratchDraws) // release references from longer earlier scenes | ||
| 27 | - | w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, bounds: image.Rect(0, 0, r.width, r.height), draws: r.scratchDraws[:0]} | 30 | + | w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, zoom: 1, bounds: image.Rect(0, 0, r.width, r.height), draws: r.scratchDraws[:0]} |
| 28 | r.scratchCovers = r.scratchCovers[:0] | 31 | r.scratchCovers = r.scratchCovers[:0] | ||
| 29 | if w.scale <= 0 { | 32 | if w.scale <= 0 { | ||
| 30 | w.scale = 1 | 33 | w.scale = 1 | ||
| @@ -44,6 +47,12 @@ func (w *sceneWalk) physRect(x, y, width, height int) image.Rectangle { | |||||
| 44 | return image.Rect(w.phys(x), w.phys(y), w.phys(x+width), w.phys(y+height)) | 47 | return image.Rect(w.phys(x), w.phys(y), w.phys(x+width), w.phys(y+height)) | ||
| 45 | } | 48 | } | ||
| 46 | 49 | ||||
| 50 | + | // physRectF is physRect for fractional logical coordinates (zoomed surfaces). | |||
| 51 | + | func (w *sceneWalk) physRectF(x, y, width, height float64) image.Rectangle { | |||
| 52 | + | p := func(v float64) int { return int(math.Round(v * w.scale)) } | |||
| 53 | + | return image.Rect(p(x), p(y), p(x+width), p(y+height)) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 47 | // fill draws a solid rect (physical pixels). | 56 | // fill draws a solid rect (physical pixels). | ||
| 48 | func (w *sceneWalk) fill(rect image.Rectangle, c [3]uint8) { | 57 | func (w *sceneWalk) fill(rect image.Rectangle, c [3]uint8) { | ||
| 49 | rect = rect.Intersect(w.bounds) | 58 | rect = rect.Intersect(w.bounds) | ||
| @@ -103,7 +112,11 @@ func (w *sceneWalk) windows() { | |||||
| 103 | // Until its first buffer, a window shows the background: no flash. | 112 | // Until its first buffer, a window shows the background: no flash. | ||
| 104 | w.fill(body, parseColor(w.s.Background)) | 113 | w.fill(body, parseColor(w.s.Background)) | ||
| 105 | if !content.Empty() { | 114 | if !content.Empty() { | ||
| 115 | + | if win.Preview > 0 { | |||
| 116 | + | w.zoom = win.Preview | |||
| 117 | + | } | |||
| 106 | w.place(win.ID, &content, c.X, c.Y, c.W, c.H) | 118 | w.place(win.ID, &content, c.X, c.Y, c.W, c.H) | ||
| 119 | + | w.zoom = 1 | |||
| 107 | } | 120 | } | ||
| 108 | if win.Floating { | 121 | if win.Floating { | ||
| 109 | w.separators(win.ID) | 122 | w.separators(win.ID) | ||
| @@ -152,17 +165,22 @@ func (w *sceneWalk) popups(overLayers bool) { | |||||
| 152 | // surface of the tree is keyed by its stable surface identity. | 165 | // surface of the tree is keyed by its stable surface identity. | ||
| 153 | func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y, width, height int) { | 166 | func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y, width, height int) { | ||
| 154 | clip := image.Rect(x, y, x+width, y+height) | 167 | clip := image.Rect(x, y, x+width, y+height) | ||
| 155 | - | ox, oy := x-content.Geometry.X, y-content.Geometry.Y | 168 | + | // Offsets inside the tree shrink with a zoomed (preview) window. |
| 169 | + | z := w.zoom | |||
| 170 | + | ox, oy := float64(x)-float64(content.Geometry.X)*z, float64(y)-float64(content.Geometry.Y)*z | |||
| 171 | + | at := func(ch *ports.Subsurface) (float64, float64) { return ox + float64(ch.X)*z, oy + float64(ch.Y)*z } | |||
| 156 | covers := w.opaqueChildren(content, ox, oy, clip) | 172 | covers := w.opaqueChildren(content, ox, oy, clip) | ||
| 157 | for i := range content.Children { | 173 | for i := range content.Children { | ||
| 158 | if ch := &content.Children[i]; ch.Below { | 174 | if ch := &content.Children[i]; ch.Below { | ||
| 159 | - | w.surface(&ch.SurfaceContent, ox+ch.X, oy+ch.Y, clip, shmKey{id, ch.Surface}, ch.Version, covers, false) | 175 | + | cx, cy := at(ch) |
| 176 | + | w.surface(&ch.SurfaceContent, cx, cy, clip, shmKey{id, ch.Surface}, ch.Version, covers, false) | |||
| 160 | } | 177 | } | ||
| 161 | } | 178 | } | ||
| 162 | w.surface(content, ox, oy, clip, shmKey{id, content.Surface}, content.Version, covers, true) | 179 | w.surface(content, ox, oy, clip, shmKey{id, content.Surface}, content.Version, covers, true) | ||
| 163 | for i := range content.Children { | 180 | for i := range content.Children { | ||
| 164 | if ch := &content.Children[i]; !ch.Below { | 181 | if ch := &content.Children[i]; !ch.Below { | ||
| 165 | - | w.surface(&ch.SurfaceContent, ox+ch.X, oy+ch.Y, clip, shmKey{id, ch.Surface}, ch.Version, nil, false) | 182 | + | cx, cy := at(ch) |
| 183 | + | w.surface(&ch.SurfaceContent, cx, cy, clip, shmKey{id, ch.Surface}, ch.Version, nil, false) | |||
| 166 | } | 184 | } | ||
| 167 | } | 185 | } | ||
| 168 | } | 186 | } | ||
| @@ -172,14 +190,14 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y | |||||
| 172 | // they hide the surfaces below them. A child whose buffer will not draw is | 190 | // they hide the surfaces below them. A child whose buffer will not draw is | ||
| 173 | // left out, so the surface below it still shows: a dmabuf that fails to | 191 | // left out, so the surface below it still shows: a dmabuf that fails to | ||
| 174 | // import (opaqueChildren pre-imports it) or an unreadable shm buffer. | 192 | // import (opaqueChildren pre-imports it) or an unreadable shm buffer. | ||
| 175 | - | func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy int, clip image.Rectangle) []image.Rectangle { | 193 | + | func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy float64, clip image.Rectangle) []image.Rectangle { |
| 176 | covers := w.r.scratchCovers[:0] | 194 | covers := w.r.scratchCovers[:0] | ||
| 177 | for i := range content.Children { | 195 | for i := range content.Children { | ||
| 178 | ch := &content.Children[i] | 196 | ch := &content.Children[i] | ||
| 179 | if ch.Below || !ch.Opaque { | 197 | if ch.Below || !ch.Opaque { | ||
| 180 | continue | 198 | continue | ||
| 181 | } | 199 | } | ||
| 182 | - | _, dst, ok := w.surfaceRects(&ch.SurfaceContent, ox+ch.X, oy+ch.Y, clip) | 200 | + | _, dst, ok := w.surfaceRects(&ch.SurfaceContent, ox+float64(ch.X)*w.zoom, oy+float64(ch.Y)*w.zoom, clip) |
| 183 | if !ok || !w.drawable(&ch.SurfaceContent) { | 201 | if !ok || !w.drawable(&ch.SurfaceContent) { | ||
| 184 | continue | 202 | continue | ||
| 185 | } | 203 | } | ||
| @@ -205,9 +223,9 @@ func (w *sceneWalk) drawable(c *ports.SurfaceContent) bool { | |||||
| 205 | } | 223 | } | ||
| 206 | 224 | ||||
| 207 | // surfaceRects gives the physical rect of a surface buffer with its origin | 225 | // surfaceRects gives the physical rect of a surface buffer with its origin | ||
| 208 | - | // at (x, y) logical, and the part of it clip (logical) shows. ok is false | 226 | + | // at (x, y) logical, shrunk by the walk's zoom, and the part of it clip |
| 209 | - | // when there is nothing to draw. | 227 | + | // (logical) shows. ok is false when there is nothing to draw. |
| 210 | - | func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y int, clip image.Rectangle) (full, dst image.Rectangle, ok bool) { | 228 | + | func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y float64, clip image.Rectangle) (full, dst image.Rectangle, ok bool) { |
| 211 | lw, lh := content.LogicalW, content.LogicalH | 229 | lw, lh := content.LogicalW, content.LogicalH | ||
| 212 | if lw <= 0 || lh <= 0 { | 230 | if lw <= 0 || lh <= 0 { | ||
| 213 | lw, lh = content.Width, content.Height | 231 | lw, lh = content.Width, content.Height | ||
| @@ -218,7 +236,7 @@ func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y int, clip i | |||||
| 218 | if content.DMABuf == nil && (content.SHM.Stride < content.Width*4 || content.SHM.Offset < 0) { | 236 | if content.DMABuf == nil && (content.SHM.Stride < content.Width*4 || content.SHM.Offset < 0) { | ||
| 219 | return full, dst, false | 237 | return full, dst, false | ||
| 220 | } | 238 | } | ||
| 221 | - | full = w.physRect(x, y, lw, lh) | 239 | + | full = w.physRectF(x, y, float64(lw)*w.zoom, float64(lh)*w.zoom) |
| 222 | // A buffer drawn at the physical size (fractional-scale clients round | 240 | // A buffer drawn at the physical size (fractional-scale clients round | ||
| 223 | // w*scale, we round per edge) is copied 1:1, never resampled for 1px. | 241 | // w*scale, we round per edge) is copied 1:1, never resampled for 1px. | ||
| 224 | near := func(a, b int) bool { return a-b <= 1 && b-a <= 1 } | 242 | near := func(a, b int) bool { return a-b <= 1 && b-a <= 1 } | ||
| @@ -239,7 +257,7 @@ func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y int, clip i | |||||
| 239 | // surface draws one surface buffer with its origin at (x, y) logical, | 257 | // surface draws one surface buffer with its origin at (x, y) logical, | ||
| 240 | // clipped to clip (logical), unless an opaque surface above it (covers, | 258 | // clipped to clip (logical), unless an opaque surface above it (covers, | ||
| 241 | // physical) hides all of it: its buffer is then neither copied nor drawn. | 259 | // physical) hides all of it: its buffer is then neither copied nor drawn. | ||
| 242 | - | func (w *sceneWalk) surface(content *ports.SurfaceContent, x, y int, clip image.Rectangle, key shmKey, seq uint64, covers []image.Rectangle, root bool) { | 260 | + | func (w *sceneWalk) surface(content *ports.SurfaceContent, x, y float64, clip image.Rectangle, key shmKey, seq uint64, covers []image.Rectangle, root bool) { |
| 243 | full, dst, ok := w.surfaceRects(content, x, y, clip) | 261 | full, dst, ok := w.surfaceRects(content, x, y, clip) | ||
| 244 | if !ok { | 262 | if !ok { | ||
| 245 | return | 263 | return | ||
M internal/adapters/vulkan/shaders/compose.frag +29 -13
| @@ -29,6 +29,9 @@ const uint flagPQ = 4u; | |||
| 29 | 29 | const uint flagExtendedLinear = 8u; | |
| 30 | 30 | const uint flagYUV = 16u; | |
| 31 | 31 | const uint flagP010 = 32u; | |
| 32 | + | // flagSmooth averages four samples a quarter of a target pixel around the | |
| 33 | + | // point: a thumbnail shrunk under half size keeps thin lines and text. | |
| 34 | + | const uint flagSmooth = 64u; | |
| 32 | 35 | ||
| 33 | 36 | // PQ values are absolute nits. BT.2020 is converted in linear light to | |
| 34 | 37 | // BT.709, then clipped to the 8-bit sRGB intermediate. Highlights above | |
| @@ -84,26 +87,39 @@ vec4 texel(ivec2 p) { | |||
| 84 | 87 | return unpackUnorm4x8(pixels[d.buf.x + uint(p.y) * d.buf.y + uint(p.x)]).zyxw; | |
| 85 | 88 | } | |
| 86 | 89 | ||
| 90 | + | // sampleBuffer reads the client buffer at src (buffer pixels). | |
| 91 | + | vec4 sampleBuffer(vec2 src, bool exact) { | |
| 92 | + | if (d.misc.x == modeImage && (d.misc.y & flagYUV) != 0u) { | |
| 93 | + | return sampleYUV(src); | |
| 94 | + | } | |
| 95 | + | if (d.misc.x == modeImage) { | |
| 96 | + | ivec2 size = textureSize(tex, 0); | |
| 97 | + | vec2 p = clamp(src, d.crop.xy + 0.5, d.crop.zw - 0.5); | |
| 98 | + | return exact ? texelFetch(tex, clamp(ivec2(floor(p)), ivec2(0), size - 1), 0) : texture(tex, p / vec2(size)); | |
| 99 | + | } | |
| 100 | + | if (exact) { | |
| 101 | + | return texel(ivec2(floor(src))); | |
| 102 | + | } | |
| 103 | + | // Bilinear: four texel reads around the sample point. | |
| 104 | + | vec2 p = src - 0.5; | |
| 105 | + | ivec2 i = ivec2(floor(p)); | |
| 106 | + | vec2 f = p - vec2(i); | |
| 107 | + | return mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y); | |
| 108 | + | } | |
| 109 | + | ||
| 87 | 110 | void main() { | |
| 88 | 111 | vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy; | |
| 89 | 112 | bool exact = (d.misc.y & flagExact) != 0u; | |
| 90 | 113 | vec4 c; | |
| 91 | 114 | if (d.misc.x == modeSolid) { | |
| 92 | 115 | c = d.color; | |
| 93 | - | } else if (d.misc.x == modeImage && (d.misc.y & flagYUV) != 0u) { | |
| 94 | - | c = sampleYUV(src); | |
| 95 | - | } else if (d.misc.x == modeImage) { | |
| 96 | - | ivec2 size = textureSize(tex, 0); | |
| 97 | - | vec2 p = clamp(src, d.crop.xy + 0.5, d.crop.zw - 0.5); | |
| 98 | - | c = exact ? texelFetch(tex, clamp(ivec2(floor(p)), ivec2(0), size - 1), 0) : texture(tex, p / vec2(size)); | |
| 99 | - | } else if (exact) { | |
| 100 | - | c = texel(ivec2(floor(src))); | |
| 116 | + | } else if ((d.misc.y & flagSmooth) != 0u) { | |
| 117 | + | // Premultiplied samples average without fringes. | |
| 118 | + | vec2 dx = d.map.zw * 0.25, dy = d.mapy.xy * 0.25; | |
| 119 | + | c = (sampleBuffer(src - dx - dy, false) + sampleBuffer(src + dx - dy, false) + | |
| 120 | + | sampleBuffer(src - dx + dy, false) + sampleBuffer(src + dx + dy, false)) * 0.25; | |
| 101 | 121 | } else { | |
| 102 | - | // Bilinear: four texel reads around the sample point. | |
| 103 | - | vec2 p = src - 0.5; | |
| 104 | - | ivec2 i = ivec2(floor(p)); | |
| 105 | - | vec2 f = p - vec2(i); | |
| 106 | - | c = mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y); | |
| 122 | + | c = sampleBuffer(src, exact); | |
| 107 | 123 | } | |
| 108 | 124 | if ((d.misc.y & flagOpaque) != 0u) { | |
| 109 | 125 | c.a = 1.0; | |
| @@ -29,6 +29,9 @@ const uint flagPQ = 4u; | |||||
| 29 | const uint flagExtendedLinear = 8u; | 29 | const uint flagExtendedLinear = 8u; | ||
| 30 | const uint flagYUV = 16u; | 30 | const uint flagYUV = 16u; | ||
| 31 | const uint flagP010 = 32u; | 31 | const uint flagP010 = 32u; | ||
| 32 | + | // flagSmooth averages four samples a quarter of a target pixel around the | |||
| 33 | + | // point: a thumbnail shrunk under half size keeps thin lines and text. | |||
| 34 | + | const uint flagSmooth = 64u; | |||
| 32 | 35 | ||||
| 33 | // PQ values are absolute nits. BT.2020 is converted in linear light to | 36 | // PQ values are absolute nits. BT.2020 is converted in linear light to | ||
| 34 | // BT.709, then clipped to the 8-bit sRGB intermediate. Highlights above | 37 | // BT.709, then clipped to the 8-bit sRGB intermediate. Highlights above | ||
| @@ -84,26 +87,39 @@ vec4 texel(ivec2 p) { | |||||
| 84 | return unpackUnorm4x8(pixels[d.buf.x + uint(p.y) * d.buf.y + uint(p.x)]).zyxw; | 87 | return unpackUnorm4x8(pixels[d.buf.x + uint(p.y) * d.buf.y + uint(p.x)]).zyxw; | ||
| 85 | } | 88 | } | ||
| 86 | 89 | ||||
| 90 | + | // sampleBuffer reads the client buffer at src (buffer pixels). | |||
| 91 | + | vec4 sampleBuffer(vec2 src, bool exact) { | |||
| 92 | + | if (d.misc.x == modeImage && (d.misc.y & flagYUV) != 0u) { | |||
| 93 | + | return sampleYUV(src); | |||
| 94 | + | } | |||
| 95 | + | if (d.misc.x == modeImage) { | |||
| 96 | + | ivec2 size = textureSize(tex, 0); | |||
| 97 | + | vec2 p = clamp(src, d.crop.xy + 0.5, d.crop.zw - 0.5); | |||
| 98 | + | return exact ? texelFetch(tex, clamp(ivec2(floor(p)), ivec2(0), size - 1), 0) : texture(tex, p / vec2(size)); | |||
| 99 | + | } | |||
| 100 | + | if (exact) { | |||
| 101 | + | return texel(ivec2(floor(src))); | |||
| 102 | + | } | |||
| 103 | + | // Bilinear: four texel reads around the sample point. | |||
| 104 | + | vec2 p = src - 0.5; | |||
| 105 | + | ivec2 i = ivec2(floor(p)); | |||
| 106 | + | vec2 f = p - vec2(i); | |||
| 107 | + | return mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y); | |||
| 108 | + | } | |||
| 109 | + | ||||
| 87 | void main() { | 110 | void main() { | ||
| 88 | vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy; | 111 | vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy; | ||
| 89 | bool exact = (d.misc.y & flagExact) != 0u; | 112 | bool exact = (d.misc.y & flagExact) != 0u; | ||
| 90 | vec4 c; | 113 | vec4 c; | ||
| 91 | if (d.misc.x == modeSolid) { | 114 | if (d.misc.x == modeSolid) { | ||
| 92 | c = d.color; | 115 | c = d.color; | ||
| 93 | - | } else if (d.misc.x == modeImage && (d.misc.y & flagYUV) != 0u) { | 116 | + | } else if ((d.misc.y & flagSmooth) != 0u) { |
| 94 | - | c = sampleYUV(src); | 117 | + | // Premultiplied samples average without fringes. |
| 95 | - | } else if (d.misc.x == modeImage) { | 118 | + | vec2 dx = d.map.zw * 0.25, dy = d.mapy.xy * 0.25; |
| 96 | - | ivec2 size = textureSize(tex, 0); | 119 | + | c = (sampleBuffer(src - dx - dy, false) + sampleBuffer(src + dx - dy, false) + |
| 97 | - | vec2 p = clamp(src, d.crop.xy + 0.5, d.crop.zw - 0.5); | 120 | + | sampleBuffer(src - dx + dy, false) + sampleBuffer(src + dx + dy, false)) * 0.25; |
| 98 | - | c = exact ? texelFetch(tex, clamp(ivec2(floor(p)), ivec2(0), size - 1), 0) : texture(tex, p / vec2(size)); | |||
| 99 | - | } else if (exact) { | |||
| 100 | - | c = texel(ivec2(floor(src))); | |||
| 101 | } else { | 121 | } else { | ||
| 102 | - | // Bilinear: four texel reads around the sample point. | 122 | + | c = sampleBuffer(src, exact); |
| 103 | - | vec2 p = src - 0.5; | |||
| 104 | - | ivec2 i = ivec2(floor(p)); | |||
| 105 | - | vec2 f = p - vec2(i); | |||
| 106 | - | c = mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y); | |||
| 107 | } | 123 | } | ||
| 108 | if ((d.misc.y & flagOpaque) != 0u) { | 124 | if ((d.misc.y & flagOpaque) != 0u) { | ||
| 109 | c.a = 1.0; | 125 | c.a = 1.0; | ||
M internal/adapters/vulkan/shaders/compose.frag.spv +0 -0 binary file: no text diff
binary file: no text diff
M internal/adapters/vulkan/shaders/compose_hdr.frag +29 -13
| @@ -29,6 +29,9 @@ const uint flagPQ = 4u; | |||
| 29 | 29 | const uint flagExtendedLinear = 8u; | |
| 30 | 30 | const uint flagYUV = 16u; | |
| 31 | 31 | const uint flagP010 = 32u; | |
| 32 | + | // flagSmooth averages four samples a quarter of a target pixel around the | |
| 33 | + | // point: a thumbnail shrunk under half size keeps thin lines and text. | |
| 34 | + | const uint flagSmooth = 64u; | |
| 32 | 35 | ||
| 33 | 36 | // The HDR composition target holds linear BT.709 in SDR-white units. | |
| 34 | 37 | vec3 decodeSRGB(vec3 v) { | |
| @@ -83,26 +86,39 @@ vec4 texel(ivec2 p) { | |||
| 83 | 86 | return unpackUnorm4x8(pixels[d.buf.x + uint(p.y) * d.buf.y + uint(p.x)]).zyxw; | |
| 84 | 87 | } | |
| 85 | 88 | ||
| 89 | + | // sampleBuffer reads the client buffer at src (buffer pixels). | |
| 90 | + | vec4 sampleBuffer(vec2 src, bool exact) { | |
| 91 | + | if (d.misc.x == modeImage && (d.misc.y & flagYUV) != 0u) { | |
| 92 | + | return sampleYUV(src); | |
| 93 | + | } | |
| 94 | + | if (d.misc.x == modeImage) { | |
| 95 | + | ivec2 size = textureSize(tex, 0); | |
| 96 | + | vec2 p = clamp(src, d.crop.xy + 0.5, d.crop.zw - 0.5); | |
| 97 | + | return exact ? texelFetch(tex, clamp(ivec2(floor(p)), ivec2(0), size - 1), 0) : texture(tex, p / vec2(size)); | |
| 98 | + | } | |
| 99 | + | if (exact) { | |
| 100 | + | return texel(ivec2(floor(src))); | |
| 101 | + | } | |
| 102 | + | // Bilinear: four texel reads around the sample point. | |
| 103 | + | vec2 p = src - 0.5; | |
| 104 | + | ivec2 i = ivec2(floor(p)); | |
| 105 | + | vec2 f = p - vec2(i); | |
| 106 | + | return mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y); | |
| 107 | + | } | |
| 108 | + | ||
| 86 | 109 | void main() { | |
| 87 | 110 | vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy; | |
| 88 | 111 | bool exact = (d.misc.y & flagExact) != 0u; | |
| 89 | 112 | vec4 c; | |
| 90 | 113 | if (d.misc.x == modeSolid) { | |
| 91 | 114 | c = vec4(decodeSRGB(d.color.rgb), d.color.a); | |
| 92 | - | } else if (d.misc.x == modeImage && (d.misc.y & flagYUV) != 0u) { | |
| 93 | - | c = sampleYUV(src); | |
| 94 | - | } else if (d.misc.x == modeImage) { | |
| 95 | - | ivec2 size = textureSize(tex, 0); | |
| 96 | - | vec2 p = clamp(src, d.crop.xy + 0.5, d.crop.zw - 0.5); | |
| 97 | - | c = exact ? texelFetch(tex, clamp(ivec2(floor(p)), ivec2(0), size - 1), 0) : texture(tex, p / vec2(size)); | |
| 98 | - | } else if (exact) { | |
| 99 | - | c = texel(ivec2(floor(src))); | |
| 115 | + | } else if ((d.misc.y & flagSmooth) != 0u) { | |
| 116 | + | // Premultiplied samples average without fringes. | |
| 117 | + | vec2 dx = d.map.zw * 0.25, dy = d.mapy.xy * 0.25; | |
| 118 | + | c = (sampleBuffer(src - dx - dy, false) + sampleBuffer(src + dx - dy, false) + | |
| 119 | + | sampleBuffer(src - dx + dy, false) + sampleBuffer(src + dx + dy, false)) * 0.25; | |
| 100 | 120 | } else { | |
| 101 | - | // Bilinear: four texel reads around the sample point. | |
| 102 | - | vec2 p = src - 0.5; | |
| 103 | - | ivec2 i = ivec2(floor(p)); | |
| 104 | - | vec2 f = p - vec2(i); | |
| 105 | - | c = mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y); | |
| 121 | + | c = sampleBuffer(src, exact); | |
| 106 | 122 | } | |
| 107 | 123 | if ((d.misc.y & flagOpaque) != 0u) { | |
| 108 | 124 | c.a = 1.0; | |
| @@ -29,6 +29,9 @@ const uint flagPQ = 4u; | |||||
| 29 | const uint flagExtendedLinear = 8u; | 29 | const uint flagExtendedLinear = 8u; | ||
| 30 | const uint flagYUV = 16u; | 30 | const uint flagYUV = 16u; | ||
| 31 | const uint flagP010 = 32u; | 31 | const uint flagP010 = 32u; | ||
| 32 | + | // flagSmooth averages four samples a quarter of a target pixel around the | |||
| 33 | + | // point: a thumbnail shrunk under half size keeps thin lines and text. | |||
| 34 | + | const uint flagSmooth = 64u; | |||
| 32 | 35 | ||||
| 33 | // The HDR composition target holds linear BT.709 in SDR-white units. | 36 | // The HDR composition target holds linear BT.709 in SDR-white units. | ||
| 34 | vec3 decodeSRGB(vec3 v) { | 37 | vec3 decodeSRGB(vec3 v) { | ||
| @@ -83,26 +86,39 @@ vec4 texel(ivec2 p) { | |||||
| 83 | return unpackUnorm4x8(pixels[d.buf.x + uint(p.y) * d.buf.y + uint(p.x)]).zyxw; | 86 | return unpackUnorm4x8(pixels[d.buf.x + uint(p.y) * d.buf.y + uint(p.x)]).zyxw; | ||
| 84 | } | 87 | } | ||
| 85 | 88 | ||||
| 89 | + | // sampleBuffer reads the client buffer at src (buffer pixels). | |||
| 90 | + | vec4 sampleBuffer(vec2 src, bool exact) { | |||
| 91 | + | if (d.misc.x == modeImage && (d.misc.y & flagYUV) != 0u) { | |||
| 92 | + | return sampleYUV(src); | |||
| 93 | + | } | |||
| 94 | + | if (d.misc.x == modeImage) { | |||
| 95 | + | ivec2 size = textureSize(tex, 0); | |||
| 96 | + | vec2 p = clamp(src, d.crop.xy + 0.5, d.crop.zw - 0.5); | |||
| 97 | + | return exact ? texelFetch(tex, clamp(ivec2(floor(p)), ivec2(0), size - 1), 0) : texture(tex, p / vec2(size)); | |||
| 98 | + | } | |||
| 99 | + | if (exact) { | |||
| 100 | + | return texel(ivec2(floor(src))); | |||
| 101 | + | } | |||
| 102 | + | // Bilinear: four texel reads around the sample point. | |||
| 103 | + | vec2 p = src - 0.5; | |||
| 104 | + | ivec2 i = ivec2(floor(p)); | |||
| 105 | + | vec2 f = p - vec2(i); | |||
| 106 | + | return mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y); | |||
| 107 | + | } | |||
| 108 | + | ||||
| 86 | void main() { | 109 | void main() { | ||
| 87 | vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy; | 110 | vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy; | ||
| 88 | bool exact = (d.misc.y & flagExact) != 0u; | 111 | bool exact = (d.misc.y & flagExact) != 0u; | ||
| 89 | vec4 c; | 112 | vec4 c; | ||
| 90 | if (d.misc.x == modeSolid) { | 113 | if (d.misc.x == modeSolid) { | ||
| 91 | c = vec4(decodeSRGB(d.color.rgb), d.color.a); | 114 | c = vec4(decodeSRGB(d.color.rgb), d.color.a); | ||
| 92 | - | } else if (d.misc.x == modeImage && (d.misc.y & flagYUV) != 0u) { | 115 | + | } else if ((d.misc.y & flagSmooth) != 0u) { |
| 93 | - | c = sampleYUV(src); | 116 | + | // Premultiplied samples average without fringes. |
| 94 | - | } else if (d.misc.x == modeImage) { | 117 | + | vec2 dx = d.map.zw * 0.25, dy = d.mapy.xy * 0.25; |
| 95 | - | ivec2 size = textureSize(tex, 0); | 118 | + | c = (sampleBuffer(src - dx - dy, false) + sampleBuffer(src + dx - dy, false) + |
| 96 | - | vec2 p = clamp(src, d.crop.xy + 0.5, d.crop.zw - 0.5); | 119 | + | sampleBuffer(src - dx + dy, false) + sampleBuffer(src + dx + dy, false)) * 0.25; |
| 97 | - | c = exact ? texelFetch(tex, clamp(ivec2(floor(p)), ivec2(0), size - 1), 0) : texture(tex, p / vec2(size)); | |||
| 98 | - | } else if (exact) { | |||
| 99 | - | c = texel(ivec2(floor(src))); | |||
| 100 | } else { | 120 | } else { | ||
| 101 | - | // Bilinear: four texel reads around the sample point. | 121 | + | c = sampleBuffer(src, exact); |
| 102 | - | vec2 p = src - 0.5; | |||
| 103 | - | ivec2 i = ivec2(floor(p)); | |||
| 104 | - | vec2 f = p - vec2(i); | |||
| 105 | - | c = mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y); | |||
| 106 | } | 122 | } | ||
| 107 | if ((d.misc.y & flagOpaque) != 0u) { | 123 | if ((d.misc.y & flagOpaque) != 0u) { | ||
| 108 | c.a = 1.0; | 124 | c.a = 1.0; | ||
M internal/adapters/vulkan/shaders/compose_hdr.frag.spv +0 -0 binary file: no text diff
binary file: no text diff
M internal/core/action.go +8 -0
| @@ -20,6 +20,7 @@ const ( | |||
| 20 | 20 | ActionToggleFullscreen Action = "toggle-fullscreen" | |
| 21 | 21 | ActionToggleWindowStash Action = "toggle-window-stash" | |
| 22 | 22 | ActionToggleStashVisible Action = "toggle-stash-visible" | |
| 23 | + | ActionToggleOverview Action = "toggle-overview" | |
| 23 | 24 | ActionCloseWindow Action = "close-window" | |
| 24 | 25 | ActionQuit Action = "quit" | |
| 25 | 26 | // Workspaces stack vertically; up/down stop at the ends. | |
| @@ -200,6 +201,10 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 200 | 201 | ||
| 201 | 202 | // Apply runs a bind action on the monitor. | |
| 202 | 203 | func (m *Monitor) Apply(a Action) Effect { | |
| 204 | + | // In the overview, close-window closes the selected stash card. | |
| 205 | + | if id := m.card(); id != 0 && a == ActionCloseWindow { | |
| 206 | + | return Effect{Close: id} | |
| 207 | + | } | |
| 203 | 208 | if n, op, ok := WorkspaceArg(a); ok { | |
| 204 | 209 | if op == FocusWorkspace { | |
| 205 | 210 | m.FocusNumber(n) | |
| @@ -233,6 +238,9 @@ func (m *Monitor) Apply(a Action) Effect { | |||
| 233 | 238 | case ActionToggleStashVisible: | |
| 234 | 239 | m.Current().ToggleStashVisible() | |
| 235 | 240 | return Effect{} | |
| 241 | + | case ActionToggleOverview: | |
| 242 | + | m.ToggleOverview() | |
| 243 | + | return Effect{} | |
| 236 | 244 | case ActionMaximizeColumn: | |
| 237 | 245 | // A client that made itself fullscreen (Wine at monitor size) goes | |
| 238 | 246 | // back to its column width; the next press maximizes the column. | |
| @@ -20,6 +20,7 @@ const ( | |||||
| 20 | ActionToggleFullscreen Action = "toggle-fullscreen" | 20 | ActionToggleFullscreen Action = "toggle-fullscreen" | ||
| 21 | ActionToggleWindowStash Action = "toggle-window-stash" | 21 | ActionToggleWindowStash Action = "toggle-window-stash" | ||
| 22 | ActionToggleStashVisible Action = "toggle-stash-visible" | 22 | ActionToggleStashVisible Action = "toggle-stash-visible" | ||
| 23 | + | ActionToggleOverview Action = "toggle-overview" | |||
| 23 | ActionCloseWindow Action = "close-window" | 24 | ActionCloseWindow Action = "close-window" | ||
| 24 | ActionQuit Action = "quit" | 25 | ActionQuit Action = "quit" | ||
| 25 | // Workspaces stack vertically; up/down stop at the ends. | 26 | // Workspaces stack vertically; up/down stop at the ends. | ||
| @@ -200,6 +201,10 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 200 | 201 | ||||
| 201 | // Apply runs a bind action on the monitor. | 202 | // Apply runs a bind action on the monitor. | ||
| 202 | func (m *Monitor) Apply(a Action) Effect { | 203 | func (m *Monitor) Apply(a Action) Effect { | ||
| 204 | + | // In the overview, close-window closes the selected stash card. | |||
| 205 | + | if id := m.card(); id != 0 && a == ActionCloseWindow { | |||
| 206 | + | return Effect{Close: id} | |||
| 207 | + | } | |||
| 203 | if n, op, ok := WorkspaceArg(a); ok { | 208 | if n, op, ok := WorkspaceArg(a); ok { | ||
| 204 | if op == FocusWorkspace { | 209 | if op == FocusWorkspace { | ||
| 205 | m.FocusNumber(n) | 210 | m.FocusNumber(n) | ||
| @@ -233,6 +238,9 @@ func (m *Monitor) Apply(a Action) Effect { | |||||
| 233 | case ActionToggleStashVisible: | 238 | case ActionToggleStashVisible: | ||
| 234 | m.Current().ToggleStashVisible() | 239 | m.Current().ToggleStashVisible() | ||
| 235 | return Effect{} | 240 | return Effect{} | ||
| 241 | + | case ActionToggleOverview: | |||
| 242 | + | m.ToggleOverview() | |||
| 243 | + | return Effect{} | |||
| 236 | case ActionMaximizeColumn: | 244 | case ActionMaximizeColumn: | ||
| 237 | // A client that made itself fullscreen (Wine at monitor size) goes | 245 | // A client that made itself fullscreen (Wine at monitor size) goes | ||
| 238 | // back to its column width; the next press maximizes the column. | 246 | // back to its column width; the next press maximizes the column. | ||
M internal/core/core.go +65 -5
| @@ -213,7 +213,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 213 | 213 | continue | |
| 214 | 214 | } | |
| 215 | 215 | switch Action(a) { | |
| 216 | - | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight: | |
| 216 | + | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight: | |
| 217 | 217 | default: | |
| 218 | 218 | return fmt.Errorf("invalid action %q", a) | |
| 219 | 219 | } | |
| @@ -479,11 +479,15 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 479 | 479 | scene.Dim = floatDim(layout, o, c.cfg.Floating.Dim) | |
| 480 | 480 | // Only the focused output lights the focused window's lines. | |
| 481 | 481 | scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen) | |
| 482 | + | if sc.mon.overview { | |
| 483 | + | // Previews have no lines: the selected one is framed. | |
| 484 | + | scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2)) | |
| 485 | + | } | |
| 482 | 486 | for _, p := range layout { | |
| 483 | 487 | alive[p.ID] = true | |
| 484 | 488 | // Only the focused output has an activated window. | |
| 485 | 489 | focused := p.Focused && i == c.focusScreen | |
| 486 | - | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Inset: p.Inset} | |
| 490 | + | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Inset: p.Inset, Preview: p.Preview} | |
| 487 | 491 | if p.Peek { | |
| 488 | 492 | sw.Dim = c.cfg.Stash.Dim | |
| 489 | 493 | } | |
| @@ -508,6 +512,23 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 508 | 512 | c.sent[p.ID] = v | |
| 509 | 513 | continue | |
| 510 | 514 | } | |
| 515 | + | if p.Preview > 0 { | |
| 516 | + | // A preview keeps its client's size: only its focus changes. | |
| 517 | + | old, ok := c.sent[p.ID] | |
| 518 | + | v := old | |
| 519 | + | if !ok { | |
| 520 | + | v = ports.ConfigureWindow{ID: p.ID, Width: int(float64(p.Rect.W) / p.Preview), Height: int(float64(p.Rect.H) / p.Preview)} | |
| 521 | + | } | |
| 522 | + | v.Activated, v.Visible, v.Output = focused, onScreen(p, o), sc.name() | |
| 523 | + | if ok && v == old { | |
| 524 | + | continue | |
| 525 | + | } | |
| 526 | + | if err := c.command(ctx, v); err != nil { | |
| 527 | + | return err | |
| 528 | + | } | |
| 529 | + | c.sent[p.ID] = v | |
| 530 | + | continue | |
| 531 | + | } | |
| 511 | 532 | r := c.clientRect(p) | |
| 512 | 533 | v := ports.ConfigureWindow{ID: p.ID, Width: r.W, Height: r.H, Fullscreen: p.Fullscreen, Activated: focused, Floating: floating && !p.Fullscreen, Output: sc.name(), Visible: onScreen(p, o)} | |
| 513 | 534 | if v.Floating && !sc.mon.Current().imposedFloat(p.ID) { | |
| @@ -632,7 +653,7 @@ func (c *Core) resolveConstraint() { | |||
| 632 | 653 | if id := c.constrained.ID; id != 0 { | |
| 633 | 654 | if s, _ := c.screenOf(id); s != nil { | |
| 634 | 655 | for _, p := range s.mon.Layout() { | |
| 635 | - | if p.ID != id || p.Hidden { | |
| 656 | + | if p.ID != id || p.Hidden || p.Preview > 0 { | |
| 636 | 657 | continue | |
| 637 | 658 | } | |
| 638 | 659 | r := c.clientRect(p) | |
| @@ -728,6 +749,11 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||
| 728 | 749 | if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 { | |
| 729 | 750 | return id, px, py | |
| 730 | 751 | } | |
| 752 | + | if sc.mon.overview { | |
| 753 | + | // Previews and the layers under them take no input: a click | |
| 754 | + | // picks a preview (overviewClick). | |
| 755 | + | return 0, 0, 0 | |
| 756 | + | } | |
| 731 | 757 | var id WindowID | |
| 732 | 758 | var sx, sy float64 | |
| 733 | 759 | full := false | |
| @@ -998,6 +1024,13 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 998 | 1024 | } | |
| 999 | 1025 | continue | |
| 1000 | 1026 | case ports.PointerButton: | |
| 1027 | + | if v.Pressed && c.pointer == 0 && c.grab == 0 && len(c.buttons) == 0 { | |
| 1028 | + | if picked, err := c.overviewClick(ctx); err != nil { | |
| 1029 | + | return nil | |
| 1030 | + | } else if picked { | |
| 1031 | + | continue | |
| 1032 | + | } | |
| 1033 | + | } | |
| 1001 | 1034 | id := c.pointer | |
| 1002 | 1035 | if c.grab != 0 { | |
| 1003 | 1036 | id = c.grab | |
| @@ -1049,6 +1082,18 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1049 | 1082 | } | |
| 1050 | 1083 | continue | |
| 1051 | 1084 | case ports.PointerAxis: | |
| 1085 | + | // In the overview, scrolling moves the selection. | |
| 1086 | + | if c.cur().mon.overview && !c.overviewKeyboardTaken() { | |
| 1087 | + | if c.cur().mon.overviewScroll(v) { | |
| 1088 | + | if c.workspaceVisible(ctx, true) != nil { | |
| 1089 | + | return nil | |
| 1090 | + | } | |
| 1091 | + | if err := c.publish(ctx); err != nil { | |
| 1092 | + | return nil | |
| 1093 | + | } | |
| 1094 | + | } | |
| 1095 | + | continue | |
| 1096 | + | } | |
| 1052 | 1097 | // Scroll goes to the window under the pointer, which has the | |
| 1053 | 1098 | // pointer focus even mid-drag. | |
| 1054 | 1099 | if c.pointer != 0 { | |
| @@ -1058,7 +1103,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1058 | 1103 | } | |
| 1059 | 1104 | continue | |
| 1060 | 1105 | case ports.SwipeBegin: | |
| 1061 | - | if c.swipeBegin(v.Time) && c.slid(ctx, false) != nil { | |
| 1106 | + | if c.swipeBegin(v) && c.slid(ctx, false) != nil { | |
| 1062 | 1107 | return nil | |
| 1063 | 1108 | } | |
| 1064 | 1109 | continue | |
| @@ -1077,6 +1122,21 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1077 | 1122 | if !ok { | |
| 1078 | 1123 | continue | |
| 1079 | 1124 | } | |
| 1125 | + | // The overview takes its keys before any window; others still | |
| 1126 | + | // run binds, and are not forwarded. | |
| 1127 | + | // A launcher or a menu holding the keyboard gets them first. | |
| 1128 | + | if mon := c.cur().mon; mon.overview && !c.overviewKeyboardTaken() { | |
| 1129 | + | if key.Pressed && mon.overviewKey(key) { | |
| 1130 | + | c.pressed[heldKey(key)] = true | |
| 1131 | + | if c.workspaceVisible(ctx, true) != nil { | |
| 1132 | + | return nil | |
| 1133 | + | } | |
| 1134 | + | if err := c.publish(ctx); err != nil { | |
| 1135 | + | return nil | |
| 1136 | + | } | |
| 1137 | + | continue | |
| 1138 | + | } | |
| 1139 | + | } | |
| 1080 | 1140 | // A focused window inhibiting shortcuts gets every key: | |
| 1081 | 1141 | // no bind runs (emergency quit and VT switch are handled by | |
| 1082 | 1142 | // input before core). | |
| @@ -1171,7 +1231,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1171 | 1231 | } | |
| 1172 | 1232 | c.pressed[held] = false | |
| 1173 | 1233 | } | |
| 1174 | - | if id := c.keyboardFocus(); id != 0 { | |
| 1234 | + | if id := c.keyboardFocus(); id != 0 && (!c.cur().mon.overview || c.overviewKeyboardTaken()) { | |
| 1175 | 1235 | if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil { | |
| 1176 | 1236 | return nil | |
| 1177 | 1237 | } | |
| @@ -213,7 +213,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 213 | continue | 213 | continue | ||
| 214 | } | 214 | } | ||
| 215 | switch Action(a) { | 215 | switch Action(a) { | ||
| 216 | - | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight: | 216 | + | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight: |
| 217 | default: | 217 | default: | ||
| 218 | return fmt.Errorf("invalid action %q", a) | 218 | return fmt.Errorf("invalid action %q", a) | ||
| 219 | } | 219 | } | ||
| @@ -479,11 +479,15 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 479 | scene.Dim = floatDim(layout, o, c.cfg.Floating.Dim) | 479 | scene.Dim = floatDim(layout, o, c.cfg.Floating.Dim) | ||
| 480 | // Only the focused output lights the focused window's lines. | 480 | // Only the focused output lights the focused window's lines. | ||
| 481 | scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen) | 481 | scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen) | ||
| 482 | + | if sc.mon.overview { | |||
| 483 | + | // Previews have no lines: the selected one is framed. | |||
| 484 | + | scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2)) | |||
| 485 | + | } | |||
| 482 | for _, p := range layout { | 486 | for _, p := range layout { | ||
| 483 | alive[p.ID] = true | 487 | alive[p.ID] = true | ||
| 484 | // Only the focused output has an activated window. | 488 | // Only the focused output has an activated window. | ||
| 485 | focused := p.Focused && i == c.focusScreen | 489 | focused := p.Focused && i == c.focusScreen | ||
| 486 | - | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Inset: p.Inset} | 490 | + | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Inset: p.Inset, Preview: p.Preview} |
| 487 | if p.Peek { | 491 | if p.Peek { | ||
| 488 | sw.Dim = c.cfg.Stash.Dim | 492 | sw.Dim = c.cfg.Stash.Dim | ||
| 489 | } | 493 | } | ||
| @@ -508,6 +512,23 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 508 | c.sent[p.ID] = v | 512 | c.sent[p.ID] = v | ||
| 509 | continue | 513 | continue | ||
| 510 | } | 514 | } | ||
| 515 | + | if p.Preview > 0 { | |||
| 516 | + | // A preview keeps its client's size: only its focus changes. | |||
| 517 | + | old, ok := c.sent[p.ID] | |||
| 518 | + | v := old | |||
| 519 | + | if !ok { | |||
| 520 | + | v = ports.ConfigureWindow{ID: p.ID, Width: int(float64(p.Rect.W) / p.Preview), Height: int(float64(p.Rect.H) / p.Preview)} | |||
| 521 | + | } | |||
| 522 | + | v.Activated, v.Visible, v.Output = focused, onScreen(p, o), sc.name() | |||
| 523 | + | if ok && v == old { | |||
| 524 | + | continue | |||
| 525 | + | } | |||
| 526 | + | if err := c.command(ctx, v); err != nil { | |||
| 527 | + | return err | |||
| 528 | + | } | |||
| 529 | + | c.sent[p.ID] = v | |||
| 530 | + | continue | |||
| 531 | + | } | |||
| 511 | r := c.clientRect(p) | 532 | r := c.clientRect(p) | ||
| 512 | v := ports.ConfigureWindow{ID: p.ID, Width: r.W, Height: r.H, Fullscreen: p.Fullscreen, Activated: focused, Floating: floating && !p.Fullscreen, Output: sc.name(), Visible: onScreen(p, o)} | 533 | v := ports.ConfigureWindow{ID: p.ID, Width: r.W, Height: r.H, Fullscreen: p.Fullscreen, Activated: focused, Floating: floating && !p.Fullscreen, Output: sc.name(), Visible: onScreen(p, o)} | ||
| 513 | if v.Floating && !sc.mon.Current().imposedFloat(p.ID) { | 534 | if v.Floating && !sc.mon.Current().imposedFloat(p.ID) { | ||
| @@ -632,7 +653,7 @@ func (c *Core) resolveConstraint() { | |||||
| 632 | if id := c.constrained.ID; id != 0 { | 653 | if id := c.constrained.ID; id != 0 { | ||
| 633 | if s, _ := c.screenOf(id); s != nil { | 654 | if s, _ := c.screenOf(id); s != nil { | ||
| 634 | for _, p := range s.mon.Layout() { | 655 | for _, p := range s.mon.Layout() { | ||
| 635 | - | if p.ID != id || p.Hidden { | 656 | + | if p.ID != id || p.Hidden || p.Preview > 0 { |
| 636 | continue | 657 | continue | ||
| 637 | } | 658 | } | ||
| 638 | r := c.clientRect(p) | 659 | r := c.clientRect(p) | ||
| @@ -728,6 +749,11 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||||
| 728 | if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 { | 749 | if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 { | ||
| 729 | return id, px, py | 750 | return id, px, py | ||
| 730 | } | 751 | } | ||
| 752 | + | if sc.mon.overview { | |||
| 753 | + | // Previews and the layers under them take no input: a click | |||
| 754 | + | // picks a preview (overviewClick). | |||
| 755 | + | return 0, 0, 0 | |||
| 756 | + | } | |||
| 731 | var id WindowID | 757 | var id WindowID | ||
| 732 | var sx, sy float64 | 758 | var sx, sy float64 | ||
| 733 | full := false | 759 | full := false | ||
| @@ -998,6 +1024,13 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 998 | } | 1024 | } | ||
| 999 | continue | 1025 | continue | ||
| 1000 | case ports.PointerButton: | 1026 | case ports.PointerButton: | ||
| 1027 | + | if v.Pressed && c.pointer == 0 && c.grab == 0 && len(c.buttons) == 0 { | |||
| 1028 | + | if picked, err := c.overviewClick(ctx); err != nil { | |||
| 1029 | + | return nil | |||
| 1030 | + | } else if picked { | |||
| 1031 | + | continue | |||
| 1032 | + | } | |||
| 1033 | + | } | |||
| 1001 | id := c.pointer | 1034 | id := c.pointer | ||
| 1002 | if c.grab != 0 { | 1035 | if c.grab != 0 { | ||
| 1003 | id = c.grab | 1036 | id = c.grab | ||
| @@ -1049,6 +1082,18 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1049 | } | 1082 | } | ||
| 1050 | continue | 1083 | continue | ||
| 1051 | case ports.PointerAxis: | 1084 | case ports.PointerAxis: | ||
| 1085 | + | // In the overview, scrolling moves the selection. | |||
| 1086 | + | if c.cur().mon.overview && !c.overviewKeyboardTaken() { | |||
| 1087 | + | if c.cur().mon.overviewScroll(v) { | |||
| 1088 | + | if c.workspaceVisible(ctx, true) != nil { | |||
| 1089 | + | return nil | |||
| 1090 | + | } | |||
| 1091 | + | if err := c.publish(ctx); err != nil { | |||
| 1092 | + | return nil | |||
| 1093 | + | } | |||
| 1094 | + | } | |||
| 1095 | + | continue | |||
| 1096 | + | } | |||
| 1052 | // Scroll goes to the window under the pointer, which has the | 1097 | // Scroll goes to the window under the pointer, which has the | ||
| 1053 | // pointer focus even mid-drag. | 1098 | // pointer focus even mid-drag. | ||
| 1054 | if c.pointer != 0 { | 1099 | if c.pointer != 0 { | ||
| @@ -1058,7 +1103,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1058 | } | 1103 | } | ||
| 1059 | continue | 1104 | continue | ||
| 1060 | case ports.SwipeBegin: | 1105 | case ports.SwipeBegin: | ||
| 1061 | - | if c.swipeBegin(v.Time) && c.slid(ctx, false) != nil { | 1106 | + | if c.swipeBegin(v) && c.slid(ctx, false) != nil { |
| 1062 | return nil | 1107 | return nil | ||
| 1063 | } | 1108 | } | ||
| 1064 | continue | 1109 | continue | ||
| @@ -1077,6 +1122,21 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1077 | if !ok { | 1122 | if !ok { | ||
| 1078 | continue | 1123 | continue | ||
| 1079 | } | 1124 | } | ||
| 1125 | + | // The overview takes its keys before any window; others still | |||
| 1126 | + | // run binds, and are not forwarded. | |||
| 1127 | + | // A launcher or a menu holding the keyboard gets them first. | |||
| 1128 | + | if mon := c.cur().mon; mon.overview && !c.overviewKeyboardTaken() { | |||
| 1129 | + | if key.Pressed && mon.overviewKey(key) { | |||
| 1130 | + | c.pressed[heldKey(key)] = true | |||
| 1131 | + | if c.workspaceVisible(ctx, true) != nil { | |||
| 1132 | + | return nil | |||
| 1133 | + | } | |||
| 1134 | + | if err := c.publish(ctx); err != nil { | |||
| 1135 | + | return nil | |||
| 1136 | + | } | |||
| 1137 | + | continue | |||
| 1138 | + | } | |||
| 1139 | + | } | |||
| 1080 | // A focused window inhibiting shortcuts gets every key: | 1140 | // A focused window inhibiting shortcuts gets every key: | ||
| 1081 | // no bind runs (emergency quit and VT switch are handled by | 1141 | // no bind runs (emergency quit and VT switch are handled by | ||
| 1082 | // input before core). | 1142 | // input before core). | ||
| @@ -1171,7 +1231,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1171 | } | 1231 | } | ||
| 1172 | c.pressed[held] = false | 1232 | c.pressed[held] = false | ||
| 1173 | } | 1233 | } | ||
| 1174 | - | if id := c.keyboardFocus(); id != 0 { | 1234 | + | if id := c.keyboardFocus(); id != 0 && (!c.cur().mon.overview || c.overviewKeyboardTaken()) { |
| 1175 | if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil { | 1235 | if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil { | ||
| 1176 | return nil | 1236 | return nil | ||
| 1177 | } | 1237 | } | ||
M internal/core/gesture.go +47 -9
| @@ -25,7 +25,8 @@ const ( | |||
| 25 | 25 | // workspace. | |
| 26 | 26 | workspaceSwipeMovement = 300.0 | |
| 27 | 27 | // discreteSwipeMin is the projected distance a swipe needs to run a | |
| 28 | - | // focus action where the view cannot slide. | |
| 28 | + | // focus action where the view cannot slide, or a four-finger swipe to | |
| 29 | + | // open or close the overview (half of niri's 300). | |
| 29 | 30 | discreteSwipeMin = 150.0 | |
| 30 | 31 | ) | |
| 31 | 32 | ||
| @@ -36,6 +37,9 @@ const ( | |||
| 36 | 37 | swipeColumns | |
| 37 | 38 | swipeWorkspaces | |
| 38 | 39 | swipeDiscrete | |
| 40 | + | // swipeOverview is a vertical four-finger swipe: up opens the | |
| 41 | + | // overview, down closes it on the selection (niri's gesture). | |
| 42 | + | swipeOverview | |
| 39 | 43 | // swipeDropped ignores the rest of a swipe whose workspace changed. | |
| 40 | 44 | swipeDropped | |
| 41 | 45 | ) | |
| @@ -43,6 +47,7 @@ const ( | |||
| 43 | 47 | // swipeGesture is the swipe in progress, on the screen focused when it began. | |
| 44 | 48 | type swipeGesture struct { | |
| 45 | 49 | screen *screen | |
| 50 | + | fingers int | |
| 46 | 51 | mode swipeMode | |
| 47 | 52 | horizontal bool | |
| 48 | 53 | cx, cy float64 | |
| @@ -59,12 +64,16 @@ type swipeGesture struct { | |||
| 59 | 64 | // list is the numbered workspaces when a workspace slide began: start | |
| 60 | 65 | // indexes it, so the slide ends if the list changes. | |
| 61 | 66 | list []*Workspace | |
| 67 | + | // opens is the monitor's overviewOpens when the swipe picked its | |
| 68 | + | // mode: a slide the overview opened over is dropped, even once the | |
| 69 | + | // overview closed again. | |
| 70 | + | opens int | |
| 62 | 71 | } | |
| 63 | 72 | ||
| 64 | 73 | // listChanged reports whether the numbered workspaces or the active one | |
| 65 | - | // changed since a workspace slide began. | |
| 74 | + | // changed since a workspace slide began, or the overview opened over it. | |
| 66 | 75 | func (g *swipeGesture) listChanged(m *Monitor) bool { | |
| 67 | - | return m.shown != nil || m.Workspaces[m.Active] != g.ws || !slices.Equal(m.Workspaces, g.list) | |
| 76 | + | return m.overviewOpens != g.opens || m.shown != nil || m.Workspaces[m.Active] != g.ws || !slices.Equal(m.Workspaces, g.list) | |
| 68 | 77 | } | |
| 69 | 78 | ||
| 70 | 79 | // swipeSign turns finger movement into view movement: natural scroll moves | |
| @@ -78,13 +87,13 @@ func (c *Core) swipeSign() float64 { | |||
| 78 | 87 | ||
| 79 | 88 | // swipeBegin starts a swipe. Input streams one swipe at a time; one still | |
| 80 | 89 | // running (its end was lost) ends cancelled first: its slide settles back. | |
| 81 | - | func (c *Core) swipeBegin(at time.Duration) bool { | |
| 90 | + | func (c *Core) swipeBegin(b ports.SwipeBegin) bool { | |
| 82 | 91 | changed := false | |
| 83 | 92 | if c.swipe != nil { | |
| 84 | - | c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: at}) | |
| 93 | + | c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: b.Time}) | |
| 85 | 94 | changed = true | |
| 86 | 95 | } | |
| 87 | - | c.swipe = &swipeGesture{screen: c.cur()} | |
| 96 | + | c.swipe = &swipeGesture{screen: c.cur(), fingers: b.Fingers} | |
| 88 | 97 | return changed | |
| 89 | 98 | } | |
| 90 | 99 | ||
| @@ -107,11 +116,17 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool { | |||
| 107 | 116 | if g.horizontal { | |
| 108 | 117 | d = u.DX | |
| 109 | 118 | } | |
| 119 | + | if g.mode == swipeOverview { | |
| 120 | + | // Like niri, the overview gesture ignores natural scroll: the | |
| 121 | + | // fingers going up open it. | |
| 122 | + | g.tracker.push(d, u.Time) | |
| 123 | + | return false | |
| 124 | + | } | |
| 110 | 125 | g.tracker.push(d*c.swipeSign(), u.Time) | |
| 111 | 126 | m := g.screen.mon | |
| 112 | 127 | switch g.mode { | |
| 113 | 128 | case swipeColumns: | |
| 114 | - | if m.Current() != g.ws || !g.ws.slidable() { | |
| 129 | + | if m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() { | |
| 115 | 130 | g.ws.stopSlide() | |
| 116 | 131 | g.mode = swipeDropped | |
| 117 | 132 | return true | |
| @@ -136,8 +151,17 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool { | |||
| 136 | 151 | func (c *Core) decide(g *swipeGesture) { | |
| 137 | 152 | g.horizontal = math.Abs(g.cx) > math.Abs(g.cy) | |
| 138 | 153 | m := g.screen.mon | |
| 154 | + | g.opens = m.overviewOpens | |
| 139 | 155 | w := m.Current() | |
| 140 | 156 | switch { | |
| 157 | + | case g.fingers == 4 && !g.horizontal: | |
| 158 | + | g.mode = swipeOverview | |
| 159 | + | case g.fingers == 4: | |
| 160 | + | // Four fingers sideways do nothing. | |
| 161 | + | g.mode = swipeDropped | |
| 162 | + | case m.overview: | |
| 163 | + | // The overview does not slide: the swipe moves its selection. | |
| 164 | + | g.mode = swipeDiscrete | |
| 141 | 165 | case g.horizontal && w.slidable(): | |
| 142 | 166 | g.mode, g.ws = swipeColumns, w | |
| 143 | 167 | w.motion = nil | |
| @@ -171,7 +195,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 171 | 195 | switch g.mode { | |
| 172 | 196 | case swipeColumns: | |
| 173 | 197 | w := g.ws | |
| 174 | - | if m.Current() != w || !w.slidable() { | |
| 198 | + | if m.overviewOpens != g.opens || m.Current() != w || !w.slidable() { | |
| 175 | 199 | w.stopSlide() | |
| 176 | 200 | return false | |
| 177 | 201 | } | |
| @@ -212,6 +236,16 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 212 | 236 | } | |
| 213 | 237 | m.switchOff, m.switchList = off, g.list | |
| 214 | 238 | m.switchMotion = newMotion(workspaceSpring(off, velocity), now) | |
| 239 | + | case swipeOverview: | |
| 240 | + | p := g.tracker.projectedEnd() | |
| 241 | + | if e.Cancelled || math.Abs(p) < discreteSwipeMin || (p < 0) == m.overview { | |
| 242 | + | return false | |
| 243 | + | } | |
| 244 | + | before := m.Current() | |
| 245 | + | m.ToggleOverview() | |
| 246 | + | c.focusScreen = c.screenIndex(g.screen.name()) | |
| 247 | + | c.layerFocus = 0 | |
| 248 | + | return m.Current() != before | |
| 215 | 249 | case swipeDiscrete: | |
| 216 | 250 | p := g.tracker.projectedEnd() | |
| 217 | 251 | if e.Cancelled || math.Abs(p) < discreteSwipeMin { | |
| @@ -233,7 +267,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 233 | 267 | } | |
| 234 | 268 | before := c.cur().mon.Current() | |
| 235 | 269 | c.layerFocus = 0 | |
| 236 | - | c.applyAction(a) | |
| 270 | + | if mon := c.cur().mon; mon.overview { | |
| 271 | + | mon.overviewSwipe(a) | |
| 272 | + | } else { | |
| 273 | + | c.applyAction(a) | |
| 274 | + | } | |
| 237 | 275 | return c.cur().mon.Current() != before | |
| 238 | 276 | } | |
| 239 | 277 | return shown | |
| @@ -25,7 +25,8 @@ const ( | |||||
| 25 | // workspace. | 25 | // workspace. | ||
| 26 | workspaceSwipeMovement = 300.0 | 26 | workspaceSwipeMovement = 300.0 | ||
| 27 | // discreteSwipeMin is the projected distance a swipe needs to run a | 27 | // discreteSwipeMin is the projected distance a swipe needs to run a | ||
| 28 | - | // focus action where the view cannot slide. | 28 | + | // focus action where the view cannot slide, or a four-finger swipe to |
| 29 | + | // open or close the overview (half of niri's 300). | |||
| 29 | discreteSwipeMin = 150.0 | 30 | discreteSwipeMin = 150.0 | ||
| 30 | ) | 31 | ) | ||
| 31 | 32 | ||||
| @@ -36,6 +37,9 @@ const ( | |||||
| 36 | swipeColumns | 37 | swipeColumns | ||
| 37 | swipeWorkspaces | 38 | swipeWorkspaces | ||
| 38 | swipeDiscrete | 39 | swipeDiscrete | ||
| 40 | + | // swipeOverview is a vertical four-finger swipe: up opens the | |||
| 41 | + | // overview, down closes it on the selection (niri's gesture). | |||
| 42 | + | swipeOverview | |||
| 39 | // swipeDropped ignores the rest of a swipe whose workspace changed. | 43 | // swipeDropped ignores the rest of a swipe whose workspace changed. | ||
| 40 | swipeDropped | 44 | swipeDropped | ||
| 41 | ) | 45 | ) | ||
| @@ -43,6 +47,7 @@ const ( | |||||
| 43 | // swipeGesture is the swipe in progress, on the screen focused when it began. | 47 | // swipeGesture is the swipe in progress, on the screen focused when it began. | ||
| 44 | type swipeGesture struct { | 48 | type swipeGesture struct { | ||
| 45 | screen *screen | 49 | screen *screen | ||
| 50 | + | fingers int | |||
| 46 | mode swipeMode | 51 | mode swipeMode | ||
| 47 | horizontal bool | 52 | horizontal bool | ||
| 48 | cx, cy float64 | 53 | cx, cy float64 | ||
| @@ -59,12 +64,16 @@ type swipeGesture struct { | |||||
| 59 | // list is the numbered workspaces when a workspace slide began: start | 64 | // list is the numbered workspaces when a workspace slide began: start | ||
| 60 | // indexes it, so the slide ends if the list changes. | 65 | // indexes it, so the slide ends if the list changes. | ||
| 61 | list []*Workspace | 66 | list []*Workspace | ||
| 67 | + | // opens is the monitor's overviewOpens when the swipe picked its | |||
| 68 | + | // mode: a slide the overview opened over is dropped, even once the | |||
| 69 | + | // overview closed again. | |||
| 70 | + | opens int | |||
| 62 | } | 71 | } | ||
| 63 | 72 | ||||
| 64 | // listChanged reports whether the numbered workspaces or the active one | 73 | // listChanged reports whether the numbered workspaces or the active one | ||
| 65 | - | // changed since a workspace slide began. | 74 | + | // changed since a workspace slide began, or the overview opened over it. |
| 66 | func (g *swipeGesture) listChanged(m *Monitor) bool { | 75 | func (g *swipeGesture) listChanged(m *Monitor) bool { | ||
| 67 | - | return m.shown != nil || m.Workspaces[m.Active] != g.ws || !slices.Equal(m.Workspaces, g.list) | 76 | + | return m.overviewOpens != g.opens || m.shown != nil || m.Workspaces[m.Active] != g.ws || !slices.Equal(m.Workspaces, g.list) |
| 68 | } | 77 | } | ||
| 69 | 78 | ||||
| 70 | // swipeSign turns finger movement into view movement: natural scroll moves | 79 | // swipeSign turns finger movement into view movement: natural scroll moves | ||
| @@ -78,13 +87,13 @@ func (c *Core) swipeSign() float64 { | |||||
| 78 | 87 | ||||
| 79 | // swipeBegin starts a swipe. Input streams one swipe at a time; one still | 88 | // swipeBegin starts a swipe. Input streams one swipe at a time; one still | ||
| 80 | // running (its end was lost) ends cancelled first: its slide settles back. | 89 | // running (its end was lost) ends cancelled first: its slide settles back. | ||
| 81 | - | func (c *Core) swipeBegin(at time.Duration) bool { | 90 | + | func (c *Core) swipeBegin(b ports.SwipeBegin) bool { |
| 82 | changed := false | 91 | changed := false | ||
| 83 | if c.swipe != nil { | 92 | if c.swipe != nil { | ||
| 84 | - | c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: at}) | 93 | + | c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: b.Time}) |
| 85 | changed = true | 94 | changed = true | ||
| 86 | } | 95 | } | ||
| 87 | - | c.swipe = &swipeGesture{screen: c.cur()} | 96 | + | c.swipe = &swipeGesture{screen: c.cur(), fingers: b.Fingers} |
| 88 | return changed | 97 | return changed | ||
| 89 | } | 98 | } | ||
| 90 | 99 | ||||
| @@ -107,11 +116,17 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool { | |||||
| 107 | if g.horizontal { | 116 | if g.horizontal { | ||
| 108 | d = u.DX | 117 | d = u.DX | ||
| 109 | } | 118 | } | ||
| 119 | + | if g.mode == swipeOverview { | |||
| 120 | + | // Like niri, the overview gesture ignores natural scroll: the | |||
| 121 | + | // fingers going up open it. | |||
| 122 | + | g.tracker.push(d, u.Time) | |||
| 123 | + | return false | |||
| 124 | + | } | |||
| 110 | g.tracker.push(d*c.swipeSign(), u.Time) | 125 | g.tracker.push(d*c.swipeSign(), u.Time) | ||
| 111 | m := g.screen.mon | 126 | m := g.screen.mon | ||
| 112 | switch g.mode { | 127 | switch g.mode { | ||
| 113 | case swipeColumns: | 128 | case swipeColumns: | ||
| 114 | - | if m.Current() != g.ws || !g.ws.slidable() { | 129 | + | if m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() { |
| 115 | g.ws.stopSlide() | 130 | g.ws.stopSlide() | ||
| 116 | g.mode = swipeDropped | 131 | g.mode = swipeDropped | ||
| 117 | return true | 132 | return true | ||
| @@ -136,8 +151,17 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool { | |||||
| 136 | func (c *Core) decide(g *swipeGesture) { | 151 | func (c *Core) decide(g *swipeGesture) { | ||
| 137 | g.horizontal = math.Abs(g.cx) > math.Abs(g.cy) | 152 | g.horizontal = math.Abs(g.cx) > math.Abs(g.cy) | ||
| 138 | m := g.screen.mon | 153 | m := g.screen.mon | ||
| 154 | + | g.opens = m.overviewOpens | |||
| 139 | w := m.Current() | 155 | w := m.Current() | ||
| 140 | switch { | 156 | switch { | ||
| 157 | + | case g.fingers == 4 && !g.horizontal: | |||
| 158 | + | g.mode = swipeOverview | |||
| 159 | + | case g.fingers == 4: | |||
| 160 | + | // Four fingers sideways do nothing. | |||
| 161 | + | g.mode = swipeDropped | |||
| 162 | + | case m.overview: | |||
| 163 | + | // The overview does not slide: the swipe moves its selection. | |||
| 164 | + | g.mode = swipeDiscrete | |||
| 141 | case g.horizontal && w.slidable(): | 165 | case g.horizontal && w.slidable(): | ||
| 142 | g.mode, g.ws = swipeColumns, w | 166 | g.mode, g.ws = swipeColumns, w | ||
| 143 | w.motion = nil | 167 | w.motion = nil | ||
| @@ -171,7 +195,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 171 | switch g.mode { | 195 | switch g.mode { | ||
| 172 | case swipeColumns: | 196 | case swipeColumns: | ||
| 173 | w := g.ws | 197 | w := g.ws | ||
| 174 | - | if m.Current() != w || !w.slidable() { | 198 | + | if m.overviewOpens != g.opens || m.Current() != w || !w.slidable() { |
| 175 | w.stopSlide() | 199 | w.stopSlide() | ||
| 176 | return false | 200 | return false | ||
| 177 | } | 201 | } | ||
| @@ -212,6 +236,16 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 212 | } | 236 | } | ||
| 213 | m.switchOff, m.switchList = off, g.list | 237 | m.switchOff, m.switchList = off, g.list | ||
| 214 | m.switchMotion = newMotion(workspaceSpring(off, velocity), now) | 238 | m.switchMotion = newMotion(workspaceSpring(off, velocity), now) | ||
| 239 | + | case swipeOverview: | |||
| 240 | + | p := g.tracker.projectedEnd() | |||
| 241 | + | if e.Cancelled || math.Abs(p) < discreteSwipeMin || (p < 0) == m.overview { | |||
| 242 | + | return false | |||
| 243 | + | } | |||
| 244 | + | before := m.Current() | |||
| 245 | + | m.ToggleOverview() | |||
| 246 | + | c.focusScreen = c.screenIndex(g.screen.name()) | |||
| 247 | + | c.layerFocus = 0 | |||
| 248 | + | return m.Current() != before | |||
| 215 | case swipeDiscrete: | 249 | case swipeDiscrete: | ||
| 216 | p := g.tracker.projectedEnd() | 250 | p := g.tracker.projectedEnd() | ||
| 217 | if e.Cancelled || math.Abs(p) < discreteSwipeMin { | 251 | if e.Cancelled || math.Abs(p) < discreteSwipeMin { | ||
| @@ -233,7 +267,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 233 | } | 267 | } | ||
| 234 | before := c.cur().mon.Current() | 268 | before := c.cur().mon.Current() | ||
| 235 | c.layerFocus = 0 | 269 | c.layerFocus = 0 | ||
| 236 | - | c.applyAction(a) | 270 | + | if mon := c.cur().mon; mon.overview { |
| 271 | + | mon.overviewSwipe(a) | |||
| 272 | + | } else { | |||
| 273 | + | c.applyAction(a) | |||
| 274 | + | } | |||
| 237 | return c.cur().mon.Current() != before | 275 | return c.cur().mon.Current() != before | ||
| 238 | } | 276 | } | ||
| 239 | return shown | 277 | return shown | ||
M internal/core/layout.go +5 -1
| @@ -97,8 +97,12 @@ type Placement struct { | |||
| 97 | 97 | Fullscreen, Focused, Hidden bool | |
| 98 | 98 | // Floating windows sit over the columns at their own size. | |
| 99 | 99 | Floating bool | |
| 100 | - | // Peek is a stashed window peeking in beside the selected one: dimmed. | |
| 100 | + | // Peek is a stashed window peeking in beside the selected one, or an | |
| 101 | + | // overview preview of a neighbor workspace: dimmed. | |
| 101 | 102 | Peek bool | |
| 103 | + | // Preview is the scale of an overview preview: its buffer is drawn | |
| 104 | + | // that much smaller in Rect, the client keeps its size. 0 otherwise. | |
| 105 | + | Preview float64 | |
| 102 | 106 | // Neighbors are the sides touching a visible tile on this output. | |
| 103 | 107 | // Inset reserves room for drawn lines: all sides of a float; for | |
| 104 | 108 | // tiles without gaps, only the right and bottom shared sides. | |
| @@ -97,8 +97,12 @@ type Placement struct { | |||||
| 97 | Fullscreen, Focused, Hidden bool | 97 | Fullscreen, Focused, Hidden bool | ||
| 98 | // Floating windows sit over the columns at their own size. | 98 | // Floating windows sit over the columns at their own size. | ||
| 99 | Floating bool | 99 | Floating bool | ||
| 100 | - | // Peek is a stashed window peeking in beside the selected one: dimmed. | 100 | + | // Peek is a stashed window peeking in beside the selected one, or an |
| 101 | + | // overview preview of a neighbor workspace: dimmed. | |||
| 101 | Peek bool | 102 | Peek bool | ||
| 103 | + | // Preview is the scale of an overview preview: its buffer is drawn | |||
| 104 | + | // that much smaller in Rect, the client keeps its size. 0 otherwise. | |||
| 105 | + | Preview float64 | |||
| 102 | // Neighbors are the sides touching a visible tile on this output. | 106 | // Neighbors are the sides touching a visible tile on this output. | ||
| 103 | // Inset reserves room for drawn lines: all sides of a float; for | 107 | // Inset reserves room for drawn lines: all sides of a float; for | ||
| 104 | // tiles without gaps, only the right and bottom shared sides. | 108 | // tiles without gaps, only the right and bottom shared sides. | ||
M internal/core/monitor.go +27 -1
| @@ -34,6 +34,22 @@ type Monitor struct { | |||
| 34 | 34 | // one its swipe began on); switchOff is then from the current | |
| 35 | 35 | // workspace's place in it. | |
| 36 | 36 | switchList []*Workspace | |
| 37 | + | // overview is set while the overview is open (overview.go); | |
| 38 | + | // overviewFrom and overviewFromID are where it opened, for Escape. | |
| 39 | + | overview bool | |
| 40 | + | overviewFrom *Workspace | |
| 41 | + | overviewFromID WindowID | |
| 42 | + | // overviewCard is the selected stash card and overviewCardOf its | |
| 43 | + | // workspace; 0 while the selection is on a column. The stash itself | |
| 44 | + | // changes only when the overview closes on the card. | |
| 45 | + | overviewCard WindowID | |
| 46 | + | overviewCardOf *Workspace | |
| 47 | + | // scrollX and scrollY add up scrolling in the overview until it makes | |
| 48 | + | // a step (overviewScroll); opening the overview starts from zero. | |
| 49 | + | scrollX, scrollY float64 | |
| 50 | + | // overviewOpens counts the overview's openings: a swipe sliding when | |
| 51 | + | // one happens is dropped (gesture.go). | |
| 52 | + | overviewOpens int | |
| 37 | 53 | } | |
| 38 | 54 | ||
| 39 | 55 | // NamedWorkspace configures a named workspace. Zero MaxColumns and an empty | |
| @@ -622,10 +638,20 @@ func (m *Monitor) landing() *Workspace { | |||
| 622 | 638 | return m.Current() | |
| 623 | 639 | } | |
| 624 | 640 | ||
| 625 | - | func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() } | |
| 641 | + | // Focused is the focused window of the workspace on screen; in the | |
| 642 | + | // overview, the selected stash card when it is one. | |
| 643 | + | func (m *Monitor) Focused() (WindowID, bool) { | |
| 644 | + | if id := m.card(); id != 0 { | |
| 645 | + | return id, true | |
| 646 | + | } | |
| 647 | + | return m.Current().Focused() | |
| 648 | + | } | |
| 626 | 649 | ||
| 627 | 650 | // Layout places every window: the workspace on screen laid out, others hidden. | |
| 628 | 651 | func (m *Monitor) Layout() []Placement { | |
| 652 | + | if m.overview { | |
| 653 | + | return m.overviewLayout() | |
| 654 | + | } | |
| 629 | 655 | var result []Placement | |
| 630 | 656 | cur := m.Current() | |
| 631 | 657 | for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) { | |
| @@ -34,6 +34,22 @@ type Monitor struct { | |||||
| 34 | // one its swipe began on); switchOff is then from the current | 34 | // one its swipe began on); switchOff is then from the current | ||
| 35 | // workspace's place in it. | 35 | // workspace's place in it. | ||
| 36 | switchList []*Workspace | 36 | switchList []*Workspace | ||
| 37 | + | // overview is set while the overview is open (overview.go); | |||
| 38 | + | // overviewFrom and overviewFromID are where it opened, for Escape. | |||
| 39 | + | overview bool | |||
| 40 | + | overviewFrom *Workspace | |||
| 41 | + | overviewFromID WindowID | |||
| 42 | + | // overviewCard is the selected stash card and overviewCardOf its | |||
| 43 | + | // workspace; 0 while the selection is on a column. The stash itself | |||
| 44 | + | // changes only when the overview closes on the card. | |||
| 45 | + | overviewCard WindowID | |||
| 46 | + | overviewCardOf *Workspace | |||
| 47 | + | // scrollX and scrollY add up scrolling in the overview until it makes | |||
| 48 | + | // a step (overviewScroll); opening the overview starts from zero. | |||
| 49 | + | scrollX, scrollY float64 | |||
| 50 | + | // overviewOpens counts the overview's openings: a swipe sliding when | |||
| 51 | + | // one happens is dropped (gesture.go). | |||
| 52 | + | overviewOpens int | |||
| 37 | } | 53 | } | ||
| 38 | 54 | ||||
| 39 | // NamedWorkspace configures a named workspace. Zero MaxColumns and an empty | 55 | // NamedWorkspace configures a named workspace. Zero MaxColumns and an empty | ||
| @@ -622,10 +638,20 @@ func (m *Monitor) landing() *Workspace { | |||||
| 622 | return m.Current() | 638 | return m.Current() | ||
| 623 | } | 639 | } | ||
| 624 | 640 | ||||
| 625 | - | func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() } | 641 | + | // Focused is the focused window of the workspace on screen; in the |
| 642 | + | // overview, the selected stash card when it is one. | |||
| 643 | + | func (m *Monitor) Focused() (WindowID, bool) { | |||
| 644 | + | if id := m.card(); id != 0 { | |||
| 645 | + | return id, true | |||
| 646 | + | } | |||
| 647 | + | return m.Current().Focused() | |||
| 648 | + | } | |||
| 626 | 649 | ||||
| 627 | // Layout places every window: the workspace on screen laid out, others hidden. | 650 | // Layout places every window: the workspace on screen laid out, others hidden. | ||
| 628 | func (m *Monitor) Layout() []Placement { | 651 | func (m *Monitor) Layout() []Placement { | ||
| 652 | + | if m.overview { | |||
| 653 | + | return m.overviewLayout() | |||
| 654 | + | } | |||
| 629 | var result []Placement | 655 | var result []Placement | ||
| 630 | cur := m.Current() | 656 | cur := m.Current() | ||
| 631 | for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) { | 657 | for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) { | ||
A internal/core/overview.go +480 -0
| @@ -0,0 +1,480 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "math" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // The overview shows every column of the workspace on screen in one row, | |
| 11 | + | // each tile scaled by the same factor so windows keep their shape, with | |
| 12 | + | // the workspaces above and below it dimmed. The previews are the windows' | |
| 13 | + | // last buffers: clients are not resized. h/l (or left/right) select a | |
| 14 | + | // column, j/k (or down/up) a workspace, Return keeps the selection and | |
| 15 | + | // Escape returns to where the overview opened. | |
| 16 | + | ||
| 17 | + | // A workspace's stash shows as a pile of cards pinned to the left edge of | |
| 18 | + | // its row: the selected stashed window in front at overviewCardZoom, up to | |
| 19 | + | // overviewCards-1 others behind it, each overviewCardStep further up and | |
| 20 | + | // right, dimmed. The row takes the width the pile leaves. | |
| 21 | + | const ( | |
| 22 | + | overviewCardZoom = 0.2 | |
| 23 | + | overviewCards = 4 | |
| 24 | + | overviewCardStep = 8 | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | // overviewMaxZoom caps the current row so its neighbors show above and | |
| 28 | + | // below; overviewMinZoom floors the shrink of wide workspaces, which then | |
| 29 | + | // scroll to keep the selected column in view. | |
| 30 | + | const overviewMaxZoom, overviewMinZoom = 0.6, 0.25 | |
| 31 | + | ||
| 32 | + | // ToggleOverview opens the overview, or closes it on the selection. It | |
| 33 | + | // does not open under a covering fullscreen window (a game in scanout). | |
| 34 | + | func (m *Monitor) ToggleOverview() { | |
| 35 | + | if m.overview { | |
| 36 | + | m.closeOverview() | |
| 37 | + | return | |
| 38 | + | } | |
| 39 | + | w := m.Current() | |
| 40 | + | if w.cover() != 0 { | |
| 41 | + | return | |
| 42 | + | } | |
| 43 | + | // A slide in progress lands at once: the overview lays out the | |
| 44 | + | // settled state. | |
| 45 | + | m.stopSwitch() | |
| 46 | + | m.each(func(w *Workspace) { w.stopSlide() }) | |
| 47 | + | m.overview, m.overviewFrom = true, w | |
| 48 | + | m.overviewFromID, _ = w.Focused() | |
| 49 | + | m.scrollX, m.scrollY = 0, 0 | |
| 50 | + | m.overviewOpens++ | |
| 51 | + | m.selectRow() | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // selectRow selects the stash card of a workspace whose stash has the | |
| 55 | + | // focus (or only stashed windows), else its focused column. The overview | |
| 56 | + | // hides native floats, so a float's focus moves to its column; Escape | |
| 57 | + | // gives it back. | |
| 58 | + | func (m *Monitor) selectRow() { | |
| 59 | + | m.overviewCard, m.overviewCardOf = 0, nil | |
| 60 | + | w := m.Current() | |
| 61 | + | w.floatFocus = false | |
| 62 | + | if len(w.Stash) > 0 && (len(w.Columns) == 0 || w.stashFocused()) { | |
| 63 | + | m.selectCard(w, w.stashAt) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // selectCard selects stash entry i of w. | |
| 68 | + | func (m *Monitor) selectCard(w *Workspace, i int) { | |
| 69 | + | m.overviewCard, m.overviewCardOf = w.Stash[i].ID, w | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // card is the selected stash card, 0 when there is none: the overview is | |
| 73 | + | // closed, the selection is on a column, or its window left the stash on | |
| 74 | + | // screen (unmapped, unstashed, or another workspace shown by a bind). | |
| 75 | + | func (m *Monitor) card() WindowID { | |
| 76 | + | w := m.overviewCardOf | |
| 77 | + | if !m.overview || w == nil || w != m.Current() || w.stashIndex(m.overviewCard) < 0 { | |
| 78 | + | return 0 | |
| 79 | + | } | |
| 80 | + | return m.overviewCard | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // cardAt is the pile entry of w that is selected, -1 when none. | |
| 84 | + | func (m *Monitor) cardAt(w *Workspace) int { | |
| 85 | + | if id := m.card(); id != 0 && w == m.overviewCardOf { | |
| 86 | + | return w.stashIndex(id) | |
| 87 | + | } | |
| 88 | + | return -1 | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // closeOverview closes the overview on the selection: a card shows the | |
| 92 | + | // stash on it. A fullscreen request another stashed window made while | |
| 93 | + | // the stash was hidden is dropped: the picked card stays in front. | |
| 94 | + | func (m *Monitor) closeOverview() { | |
| 95 | + | w := m.Current() | |
| 96 | + | if i := m.cardAt(w); i >= 0 { | |
| 97 | + | if w.hiddenFullscreen != w.Stash[i].ID { | |
| 98 | + | w.hiddenFullscreen = 0 | |
| 99 | + | } | |
| 100 | + | w.stashAt = i | |
| 101 | + | w.showStash() | |
| 102 | + | } | |
| 103 | + | m.overview, m.overviewFrom = false, nil | |
| 104 | + | m.overviewCard, m.overviewCardOf = 0, nil | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // CancelOverview closes the overview and returns to the workspace and | |
| 108 | + | // window it opened on, if they are still there. | |
| 109 | + | func (m *Monitor) CancelOverview() { | |
| 110 | + | from, id := m.overviewFrom, m.overviewFromID | |
| 111 | + | m.overview, m.overviewFrom = false, nil | |
| 112 | + | m.overviewCard, m.overviewCardOf = 0, nil | |
| 113 | + | if from == nil || !m.has(from) { | |
| 114 | + | return | |
| 115 | + | } | |
| 116 | + | m.show(from) | |
| 117 | + | if id != 0 { | |
| 118 | + | from.FocusID(id) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // OverviewMove selects the neighbor column (dx) or numbered workspace | |
| 123 | + | // (dy). It stops at the ends and never enters an empty workspace, which | |
| 124 | + | // would open a terminal there. Left of the first column is the stash | |
| 125 | + | // pile; right of its last entry, the first column again. | |
| 126 | + | func (m *Monitor) OverviewMove(dx, dy int) { | |
| 127 | + | if dy != 0 { | |
| 128 | + | i := m.Active + dy | |
| 129 | + | if m.shown != nil || i < 0 || i >= len(m.Workspaces) || m.Workspaces[i].empty() { | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | m.Focus(i) | |
| 133 | + | m.selectRow() | |
| 134 | + | return | |
| 135 | + | } | |
| 136 | + | w := m.Current() | |
| 137 | + | if at := m.cardAt(w); at >= 0 { | |
| 138 | + | switch { | |
| 139 | + | case at+dx >= 0 && at+dx < len(w.Stash): | |
| 140 | + | m.selectCard(w, at+dx) | |
| 141 | + | case dx > 0 && len(w.Columns) > 0: | |
| 142 | + | m.overviewCard, m.overviewCardOf = 0, nil | |
| 143 | + | w.FocusID(w.Columns[0].Windows[w.Columns[0].Focus]) | |
| 144 | + | } | |
| 145 | + | return | |
| 146 | + | } | |
| 147 | + | if dx < 0 && (len(w.Columns) == 0 || w.Focus == 0) && len(w.Stash) > 0 { | |
| 148 | + | m.selectCard(w, w.stashAt) | |
| 149 | + | return | |
| 150 | + | } | |
| 151 | + | if len(w.Columns) == 0 { | |
| 152 | + | return | |
| 153 | + | } | |
| 154 | + | c := w.Columns[min(max(w.Focus+dx, 0), len(w.Columns)-1)] | |
| 155 | + | w.FocusID(c.Windows[c.Focus]) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // OverviewPick closes the overview on the clicked window. | |
| 159 | + | func (m *Monitor) OverviewPick(id WindowID) { | |
| 160 | + | w, _ := m.find(id) | |
| 161 | + | if w == nil { | |
| 162 | + | return | |
| 163 | + | } | |
| 164 | + | m.show(w) | |
| 165 | + | m.overviewCard, m.overviewCardOf = 0, nil | |
| 166 | + | if i := w.stashIndex(id); i >= 0 { | |
| 167 | + | m.selectCard(w, i) | |
| 168 | + | } else { | |
| 169 | + | w.FocusID(id) | |
| 170 | + | } | |
| 171 | + | m.closeOverview() | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // overviewLayout places the current workspace's row in the middle of the | |
| 175 | + | // usable area and its numbered neighbors above and below, dimmed, each | |
| 176 | + | // with its stash pile. Other workspaces and native floats are hidden. | |
| 177 | + | func (m *Monitor) overviewLayout() []Placement { | |
| 178 | + | cur := m.Current() | |
| 179 | + | u := cur.Usable | |
| 180 | + | gap := u.H * 3 / 100 | |
| 181 | + | var result []Placement | |
| 182 | + | zc := cur.overviewZoom() | |
| 183 | + | hc := int(math.Round(float64(u.H) * zc)) | |
| 184 | + | y := u.Y + (u.H-hc)/2 | |
| 185 | + | rows := map[*Workspace]int{} | |
| 186 | + | rows[cur] = y | |
| 187 | + | if i := indexOf(m.Workspaces, cur); i >= 0 && m.shown == nil { | |
| 188 | + | if i > 0 { | |
| 189 | + | up := m.Workspaces[i-1] | |
| 190 | + | rows[up] = y - gap - int(math.Round(float64(u.H)*up.overviewZoom())) | |
| 191 | + | } | |
| 192 | + | if i+1 < len(m.Workspaces) { | |
| 193 | + | rows[m.Workspaces[i+1]] = y + hc + gap | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | for _, w := range m.all() { | |
| 197 | + | ry, shown := rows[w] | |
| 198 | + | if shown { | |
| 199 | + | at := m.cardAt(w) | |
| 200 | + | result = append(result, w.previewRow(ry, w != cur, at < 0)...) | |
| 201 | + | result = append(result, w.pile(ry, w != cur, at)...) | |
| 202 | + | } | |
| 203 | + | for _, f := range w.Floats { | |
| 204 | + | result = append(result, Placement{ID: f.ID, Hidden: true}) | |
| 205 | + | } | |
| 206 | + | if !shown { | |
| 207 | + | for _, id := range w.windows() { | |
| 208 | + | if w.floatIndex(id) < 0 { | |
| 209 | + | result = append(result, Placement{ID: id, Hidden: true}) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | return result | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // pileWidth is the room the stash pile of w takes on the left of its row, | |
| 218 | + | // its margin included; 0 without a stash. | |
| 219 | + | func (w *Workspace) pileWidth() int { | |
| 220 | + | if len(w.Stash) == 0 { | |
| 221 | + | return 0 | |
| 222 | + | } | |
| 223 | + | card := int(math.Round(float64(w.stashRect().W) * overviewCardZoom)) | |
| 224 | + | // Never more than a third of the width: the columns keep the rest. | |
| 225 | + | return min(card+(min(len(w.Stash), overviewCards)-1)*w.cardStep()+w.Usable.W*2/100, w.Usable.W/3) | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // cardStep is the offset between stacked cards, smaller on tiny outputs. | |
| 229 | + | func (w *Workspace) cardStep() int { | |
| 230 | + | return min(overviewCardStep, w.Usable.W/100, w.Usable.H/50) | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // pile places the stash of w as cards on the left of the row whose top is | |
| 234 | + | // at y: entry front (the stash selection when -1) in front, the next ones | |
| 235 | + | // behind it, dimmed, the rest hidden. Only a front card of the current row | |
| 236 | + | // (dim false) with front set is selected. | |
| 237 | + | func (w *Workspace) pile(y int, dim bool, front int) []Placement { | |
| 238 | + | n := len(w.Stash) | |
| 239 | + | if n == 0 { | |
| 240 | + | return nil | |
| 241 | + | } | |
| 242 | + | selected := front >= 0 && !dim | |
| 243 | + | if front < 0 { | |
| 244 | + | front = w.stashAt | |
| 245 | + | } | |
| 246 | + | front = min(max(front, 0), n-1) | |
| 247 | + | r := w.stashRect() | |
| 248 | + | u := w.Usable | |
| 249 | + | cw := int(math.Round(float64(r.W) * overviewCardZoom)) | |
| 250 | + | ch := int(math.Round(float64(r.H) * overviewCardZoom)) | |
| 251 | + | rowH := int(math.Round(float64(u.H) * w.overviewZoom())) | |
| 252 | + | x0, y0 := u.X+u.W/100, y+(rowH-ch)/2 | |
| 253 | + | shown := min(n, overviewCards) | |
| 254 | + | out := make([]Placement, 0, n) | |
| 255 | + | // Farthest card first: the front one is drawn and hit on top. | |
| 256 | + | for k := shown - 1; k >= 0; k-- { | |
| 257 | + | i := (front + k) % n | |
| 258 | + | step := k * w.cardStep() | |
| 259 | + | out = append(out, Placement{ID: w.Stash[i].ID, Rect: Rect{X: x0 + step, Y: y0 - step, W: cw, H: ch}, Preview: overviewCardZoom, Peek: dim || k > 0, Focused: k == 0 && selected}) | |
| 260 | + | } | |
| 261 | + | for k := shown; k < n; k++ { | |
| 262 | + | out = append(out, Placement{ID: w.Stash[(front+k)%n].ID, Hidden: true}) | |
| 263 | + | } | |
| 264 | + | return out | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // overviewZoom is the scale that fits every column of w in the usable | |
| 268 | + | // width, within the overview bounds. | |
| 269 | + | func (w *Workspace) overviewZoom() float64 { | |
| 270 | + | _, span, _ := w.previewTiles() | |
| 271 | + | if span <= 0 { | |
| 272 | + | return overviewMaxZoom | |
| 273 | + | } | |
| 274 | + | room := float64(w.Usable.W-w.pileWidth()) * 0.96 | |
| 275 | + | return min(max(room/float64(span), overviewMinZoom), overviewMaxZoom) | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | // previewTiles lays the tiles of w out unscrolled, relative to the | |
| 279 | + | // usable area's corner: every column side by side at its width, as if | |
| 280 | + | // the output were wide enough (fixed overflow's spiral included). span is | |
| 281 | + | // the row's width; sel is the focused column. | |
| 282 | + | func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) { | |
| 283 | + | g := w.gap() | |
| 284 | + | h := max(w.Usable.H-2*g, 0) | |
| 285 | + | for i, c := range w.Columns { | |
| 286 | + | r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h} | |
| 287 | + | if w.fullscreenColumn(i) { | |
| 288 | + | // A fullscreen column shows at the width it had in the row. | |
| 289 | + | r.W = max(w.Usable.W-2*g, 0) | |
| 290 | + | } | |
| 291 | + | for j, t := range stackRects(r, len(c.Windows), g) { | |
| 292 | + | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus}) | |
| 293 | + | } | |
| 294 | + | span = max(span, r.X+r.W+g) | |
| 295 | + | if i == w.Focus { | |
| 296 | + | sel = r | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | return tiles, span, sel | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // previewRow scales the tiles of w into a row whose top is at y, centred | |
| 303 | + | // in the usable width, or scrolled so the focused column shows when the | |
| 304 | + | // row is wider, right of the stash pile. Dimmed rows (neighbor | |
| 305 | + | // workspaces) have no focus; neither has a row whose pile has it (lit | |
| 306 | + | // false). | |
| 307 | + | func (w *Workspace) previewRow(y int, dim, lit bool) []Placement { | |
| 308 | + | tiles, span, sel := w.previewTiles() | |
| 309 | + | u := w.Usable | |
| 310 | + | left := w.pileWidth() | |
| 311 | + | u.X, u.W = u.X+left, u.W-left | |
| 312 | + | z := w.overviewZoom() | |
| 313 | + | scale := func(v int) int { return int(math.Round(float64(v) * z)) } | |
| 314 | + | width := scale(span) | |
| 315 | + | x := u.X + (u.W-width)/2 | |
| 316 | + | if width > u.W { | |
| 317 | + | x = u.X + u.W/2 - scale(sel.X+sel.W/2) | |
| 318 | + | x = min(max(x, u.X+u.W-width), u.X) | |
| 319 | + | } | |
| 320 | + | for i := range tiles { | |
| 321 | + | r := tiles[i].Rect | |
| 322 | + | x0, y0 := scale(r.X), scale(r.Y) | |
| 323 | + | tiles[i].Rect = Rect{X: x + x0, Y: y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0} | |
| 324 | + | tiles[i].Preview, tiles[i].Peek = z, dim | |
| 325 | + | tiles[i].Focused = tiles[i].Focused && !dim && lit | |
| 326 | + | } | |
| 327 | + | return tiles | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | // overviewOutline frames the selected preview with active lines of width | |
| 331 | + | // b, just outside it. | |
| 332 | + | func overviewOutline(layout []Placement, b int) []ports.Separator { | |
| 333 | + | for _, p := range layout { | |
| 334 | + | if p.Preview > 0 && p.Focused && !p.Hidden { | |
| 335 | + | r := p.Rect | |
| 336 | + | return []ports.Separator{ | |
| 337 | + | {Rect: Rect{X: r.X - b, Y: r.Y - b, W: r.W + 2*b, H: b}, Active: true}, | |
| 338 | + | {Rect: Rect{X: r.X - b, Y: r.Y + r.H, W: r.W + 2*b, H: b}, Active: true}, | |
| 339 | + | {Rect: Rect{X: r.X - b, Y: r.Y, W: b, H: r.H}, Active: true}, | |
| 340 | + | {Rect: Rect{X: r.X + r.W, Y: r.Y, W: b, H: r.H}, Active: true}, | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | return nil | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | // overviewAt is the preview under the output-local point, if any. | |
| 348 | + | func (m *Monitor) overviewAt(x, y float64) WindowID { | |
| 349 | + | layout := m.Layout() | |
| 350 | + | for i := len(layout) - 1; i >= 0; i-- { | |
| 351 | + | p := layout[i] | |
| 352 | + | r := p.Rect | |
| 353 | + | if p.Preview > 0 && !p.Hidden && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) { | |
| 354 | + | return p.ID | |
| 355 | + | } | |
| 356 | + | } | |
| 357 | + | return 0 | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | // overviewClick closes the overview on the preview under the pointer, on | |
| 361 | + | // the output it shows on. picked is false when no preview is there. | |
| 362 | + | func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) { | |
| 363 | + | o, ok := c.layout().At(c.cursorX, c.cursorY) | |
| 364 | + | if !ok { | |
| 365 | + | return false, nil | |
| 366 | + | } | |
| 367 | + | i := c.screenIndex(o.Info.Name) | |
| 368 | + | sc := c.screens[i] | |
| 369 | + | if !sc.mon.overview { | |
| 370 | + | return false, nil | |
| 371 | + | } | |
| 372 | + | id := sc.mon.overviewAt(c.cursorX-float64(o.X), c.cursorY-float64(o.Y)) | |
| 373 | + | if id == 0 { | |
| 374 | + | return false, nil | |
| 375 | + | } | |
| 376 | + | sc.mon.OverviewPick(id) | |
| 377 | + | c.focusScreen, c.layerFocus = i, 0 | |
| 378 | + | if err := c.workspaceVisible(ctx, true); err != nil { | |
| 379 | + | return true, err | |
| 380 | + | } | |
| 381 | + | return true, c.publish(ctx) | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | // overviewSwipe moves the selection like the keys: a workspace swipe | |
| 385 | + | // must not enter the empty workspace below the last one. | |
| 386 | + | func (m *Monitor) overviewSwipe(a Action) { | |
| 387 | + | switch a { | |
| 388 | + | case ActionFocusColumnLeft: | |
| 389 | + | m.OverviewMove(-1, 0) | |
| 390 | + | case ActionFocusColumnRight: | |
| 391 | + | m.OverviewMove(1, 0) | |
| 392 | + | case ActionFocusWorkspaceUp: | |
| 393 | + | m.OverviewMove(0, -1) | |
| 394 | + | case ActionFocusWorkspaceDown: | |
| 395 | + | m.OverviewMove(0, 1) | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | // overviewScrollStep is the two-finger scroll distance, in libinput's | |
| 400 | + | // pointer units, that moves the selection one column or workspace. | |
| 401 | + | const overviewScrollStep = 60 | |
| 402 | + | ||
| 403 | + | // overviewScroll moves the overview selection with a scroll frame: two | |
| 404 | + | // fingers step once per overviewScrollStep on the axis they move most | |
| 405 | + | // along, a wheel once per notch (high-resolution wheels add up their | |
| 406 | + | // fractions of a notch). Scrolling down or right selects the workspace | |
| 407 | + | // below or the column on the right; touchpad.natural-scroll flips both, | |
| 408 | + | // as libinput reports. changed is false while the distance adds up. | |
| 409 | + | func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |
| 410 | + | if a.Vertical.Stop || a.Horizontal.Stop { | |
| 411 | + | // Fingers lifted: the next scroll starts from zero. | |
| 412 | + | m.scrollX, m.scrollY = 0, 0 | |
| 413 | + | return false | |
| 414 | + | } | |
| 415 | + | add := func(acc *float64, ax ports.ScrollAxis) { | |
| 416 | + | switch { | |
| 417 | + | case !ax.Set: | |
| 418 | + | case a.Source == ports.AxisWheel: | |
| 419 | + | *acc += float64(ax.V120) / 120 * overviewScrollStep | |
| 420 | + | default: | |
| 421 | + | *acc += ax.Value | |
| 422 | + | } | |
| 423 | + | } | |
| 424 | + | add(&m.scrollY, a.Vertical) | |
| 425 | + | add(&m.scrollX, a.Horizontal) | |
| 426 | + | for { | |
| 427 | + | switch { | |
| 428 | + | case math.Abs(m.scrollY) >= overviewScrollStep && math.Abs(m.scrollY) >= math.Abs(m.scrollX): | |
| 429 | + | m.OverviewMove(0, sign(m.scrollY)) | |
| 430 | + | m.scrollY -= float64(sign(m.scrollY)) * overviewScrollStep | |
| 431 | + | m.scrollX = 0 | |
| 432 | + | case math.Abs(m.scrollX) >= overviewScrollStep: | |
| 433 | + | m.OverviewMove(sign(m.scrollX), 0) | |
| 434 | + | m.scrollX -= float64(sign(m.scrollX)) * overviewScrollStep | |
| 435 | + | m.scrollY = 0 | |
| 436 | + | default: | |
| 437 | + | return changed | |
| 438 | + | } | |
| 439 | + | changed = true | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | func sign(v float64) int { | |
| 444 | + | if v < 0 { | |
| 445 | + | return -1 | |
| 446 | + | } | |
| 447 | + | return 1 | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | // overviewKeyboardTaken reports whether a layer surface (a launcher) or a | |
| 451 | + | // grabbing menu has the keyboard: it gets the keys, not the overview. | |
| 452 | + | func (c *Core) overviewKeyboardTaken() bool { | |
| 453 | + | id := c.keyboardFocus() | |
| 454 | + | _, _, layer := c.layerOf(id) | |
| 455 | + | return layer || c.popups[id] != nil | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | // overviewKey runs an overview key: true when the key was one. | |
| 459 | + | func (m *Monitor) overviewKey(key ports.KeyEvent) bool { | |
| 460 | + | if key.Mods != 0 { | |
| 461 | + | return false | |
| 462 | + | } | |
| 463 | + | switch keyName(key.Keysym) { | |
| 464 | + | case "h", "Left": | |
| 465 | + | m.OverviewMove(-1, 0) | |
| 466 | + | case "l", "Right": | |
| 467 | + | m.OverviewMove(1, 0) | |
| 468 | + | case "k", "Up": | |
| 469 | + | m.OverviewMove(0, -1) | |
| 470 | + | case "j", "Down": | |
| 471 | + | m.OverviewMove(0, 1) | |
| 472 | + | case "Return", "KP_Enter": | |
| 473 | + | m.ToggleOverview() | |
| 474 | + | case "Escape": | |
| 475 | + | m.CancelOverview() | |
| 476 | + | default: | |
| 477 | + | return false | |
| 478 | + | } | |
| 479 | + | return true | |
| 480 | + | } | |
| @@ -0,0 +1,480 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "math" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // The overview shows every column of the workspace on screen in one row, | |||
| 11 | + | // each tile scaled by the same factor so windows keep their shape, with | |||
| 12 | + | // the workspaces above and below it dimmed. The previews are the windows' | |||
| 13 | + | // last buffers: clients are not resized. h/l (or left/right) select a | |||
| 14 | + | // column, j/k (or down/up) a workspace, Return keeps the selection and | |||
| 15 | + | // Escape returns to where the overview opened. | |||
| 16 | + | ||||
| 17 | + | // A workspace's stash shows as a pile of cards pinned to the left edge of | |||
| 18 | + | // its row: the selected stashed window in front at overviewCardZoom, up to | |||
| 19 | + | // overviewCards-1 others behind it, each overviewCardStep further up and | |||
| 20 | + | // right, dimmed. The row takes the width the pile leaves. | |||
| 21 | + | const ( | |||
| 22 | + | overviewCardZoom = 0.2 | |||
| 23 | + | overviewCards = 4 | |||
| 24 | + | overviewCardStep = 8 | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | // overviewMaxZoom caps the current row so its neighbors show above and | |||
| 28 | + | // below; overviewMinZoom floors the shrink of wide workspaces, which then | |||
| 29 | + | // scroll to keep the selected column in view. | |||
| 30 | + | const overviewMaxZoom, overviewMinZoom = 0.6, 0.25 | |||
| 31 | + | ||||
| 32 | + | // ToggleOverview opens the overview, or closes it on the selection. It | |||
| 33 | + | // does not open under a covering fullscreen window (a game in scanout). | |||
| 34 | + | func (m *Monitor) ToggleOverview() { | |||
| 35 | + | if m.overview { | |||
| 36 | + | m.closeOverview() | |||
| 37 | + | return | |||
| 38 | + | } | |||
| 39 | + | w := m.Current() | |||
| 40 | + | if w.cover() != 0 { | |||
| 41 | + | return | |||
| 42 | + | } | |||
| 43 | + | // A slide in progress lands at once: the overview lays out the | |||
| 44 | + | // settled state. | |||
| 45 | + | m.stopSwitch() | |||
| 46 | + | m.each(func(w *Workspace) { w.stopSlide() }) | |||
| 47 | + | m.overview, m.overviewFrom = true, w | |||
| 48 | + | m.overviewFromID, _ = w.Focused() | |||
| 49 | + | m.scrollX, m.scrollY = 0, 0 | |||
| 50 | + | m.overviewOpens++ | |||
| 51 | + | m.selectRow() | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // selectRow selects the stash card of a workspace whose stash has the | |||
| 55 | + | // focus (or only stashed windows), else its focused column. The overview | |||
| 56 | + | // hides native floats, so a float's focus moves to its column; Escape | |||
| 57 | + | // gives it back. | |||
| 58 | + | func (m *Monitor) selectRow() { | |||
| 59 | + | m.overviewCard, m.overviewCardOf = 0, nil | |||
| 60 | + | w := m.Current() | |||
| 61 | + | w.floatFocus = false | |||
| 62 | + | if len(w.Stash) > 0 && (len(w.Columns) == 0 || w.stashFocused()) { | |||
| 63 | + | m.selectCard(w, w.stashAt) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // selectCard selects stash entry i of w. | |||
| 68 | + | func (m *Monitor) selectCard(w *Workspace, i int) { | |||
| 69 | + | m.overviewCard, m.overviewCardOf = w.Stash[i].ID, w | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // card is the selected stash card, 0 when there is none: the overview is | |||
| 73 | + | // closed, the selection is on a column, or its window left the stash on | |||
| 74 | + | // screen (unmapped, unstashed, or another workspace shown by a bind). | |||
| 75 | + | func (m *Monitor) card() WindowID { | |||
| 76 | + | w := m.overviewCardOf | |||
| 77 | + | if !m.overview || w == nil || w != m.Current() || w.stashIndex(m.overviewCard) < 0 { | |||
| 78 | + | return 0 | |||
| 79 | + | } | |||
| 80 | + | return m.overviewCard | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // cardAt is the pile entry of w that is selected, -1 when none. | |||
| 84 | + | func (m *Monitor) cardAt(w *Workspace) int { | |||
| 85 | + | if id := m.card(); id != 0 && w == m.overviewCardOf { | |||
| 86 | + | return w.stashIndex(id) | |||
| 87 | + | } | |||
| 88 | + | return -1 | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // closeOverview closes the overview on the selection: a card shows the | |||
| 92 | + | // stash on it. A fullscreen request another stashed window made while | |||
| 93 | + | // the stash was hidden is dropped: the picked card stays in front. | |||
| 94 | + | func (m *Monitor) closeOverview() { | |||
| 95 | + | w := m.Current() | |||
| 96 | + | if i := m.cardAt(w); i >= 0 { | |||
| 97 | + | if w.hiddenFullscreen != w.Stash[i].ID { | |||
| 98 | + | w.hiddenFullscreen = 0 | |||
| 99 | + | } | |||
| 100 | + | w.stashAt = i | |||
| 101 | + | w.showStash() | |||
| 102 | + | } | |||
| 103 | + | m.overview, m.overviewFrom = false, nil | |||
| 104 | + | m.overviewCard, m.overviewCardOf = 0, nil | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // CancelOverview closes the overview and returns to the workspace and | |||
| 108 | + | // window it opened on, if they are still there. | |||
| 109 | + | func (m *Monitor) CancelOverview() { | |||
| 110 | + | from, id := m.overviewFrom, m.overviewFromID | |||
| 111 | + | m.overview, m.overviewFrom = false, nil | |||
| 112 | + | m.overviewCard, m.overviewCardOf = 0, nil | |||
| 113 | + | if from == nil || !m.has(from) { | |||
| 114 | + | return | |||
| 115 | + | } | |||
| 116 | + | m.show(from) | |||
| 117 | + | if id != 0 { | |||
| 118 | + | from.FocusID(id) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // OverviewMove selects the neighbor column (dx) or numbered workspace | |||
| 123 | + | // (dy). It stops at the ends and never enters an empty workspace, which | |||
| 124 | + | // would open a terminal there. Left of the first column is the stash | |||
| 125 | + | // pile; right of its last entry, the first column again. | |||
| 126 | + | func (m *Monitor) OverviewMove(dx, dy int) { | |||
| 127 | + | if dy != 0 { | |||
| 128 | + | i := m.Active + dy | |||
| 129 | + | if m.shown != nil || i < 0 || i >= len(m.Workspaces) || m.Workspaces[i].empty() { | |||
| 130 | + | return | |||
| 131 | + | } | |||
| 132 | + | m.Focus(i) | |||
| 133 | + | m.selectRow() | |||
| 134 | + | return | |||
| 135 | + | } | |||
| 136 | + | w := m.Current() | |||
| 137 | + | if at := m.cardAt(w); at >= 0 { | |||
| 138 | + | switch { | |||
| 139 | + | case at+dx >= 0 && at+dx < len(w.Stash): | |||
| 140 | + | m.selectCard(w, at+dx) | |||
| 141 | + | case dx > 0 && len(w.Columns) > 0: | |||
| 142 | + | m.overviewCard, m.overviewCardOf = 0, nil | |||
| 143 | + | w.FocusID(w.Columns[0].Windows[w.Columns[0].Focus]) | |||
| 144 | + | } | |||
| 145 | + | return | |||
| 146 | + | } | |||
| 147 | + | if dx < 0 && (len(w.Columns) == 0 || w.Focus == 0) && len(w.Stash) > 0 { | |||
| 148 | + | m.selectCard(w, w.stashAt) | |||
| 149 | + | return | |||
| 150 | + | } | |||
| 151 | + | if len(w.Columns) == 0 { | |||
| 152 | + | return | |||
| 153 | + | } | |||
| 154 | + | c := w.Columns[min(max(w.Focus+dx, 0), len(w.Columns)-1)] | |||
| 155 | + | w.FocusID(c.Windows[c.Focus]) | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // OverviewPick closes the overview on the clicked window. | |||
| 159 | + | func (m *Monitor) OverviewPick(id WindowID) { | |||
| 160 | + | w, _ := m.find(id) | |||
| 161 | + | if w == nil { | |||
| 162 | + | return | |||
| 163 | + | } | |||
| 164 | + | m.show(w) | |||
| 165 | + | m.overviewCard, m.overviewCardOf = 0, nil | |||
| 166 | + | if i := w.stashIndex(id); i >= 0 { | |||
| 167 | + | m.selectCard(w, i) | |||
| 168 | + | } else { | |||
| 169 | + | w.FocusID(id) | |||
| 170 | + | } | |||
| 171 | + | m.closeOverview() | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | // overviewLayout places the current workspace's row in the middle of the | |||
| 175 | + | // usable area and its numbered neighbors above and below, dimmed, each | |||
| 176 | + | // with its stash pile. Other workspaces and native floats are hidden. | |||
| 177 | + | func (m *Monitor) overviewLayout() []Placement { | |||
| 178 | + | cur := m.Current() | |||
| 179 | + | u := cur.Usable | |||
| 180 | + | gap := u.H * 3 / 100 | |||
| 181 | + | var result []Placement | |||
| 182 | + | zc := cur.overviewZoom() | |||
| 183 | + | hc := int(math.Round(float64(u.H) * zc)) | |||
| 184 | + | y := u.Y + (u.H-hc)/2 | |||
| 185 | + | rows := map[*Workspace]int{} | |||
| 186 | + | rows[cur] = y | |||
| 187 | + | if i := indexOf(m.Workspaces, cur); i >= 0 && m.shown == nil { | |||
| 188 | + | if i > 0 { | |||
| 189 | + | up := m.Workspaces[i-1] | |||
| 190 | + | rows[up] = y - gap - int(math.Round(float64(u.H)*up.overviewZoom())) | |||
| 191 | + | } | |||
| 192 | + | if i+1 < len(m.Workspaces) { | |||
| 193 | + | rows[m.Workspaces[i+1]] = y + hc + gap | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | for _, w := range m.all() { | |||
| 197 | + | ry, shown := rows[w] | |||
| 198 | + | if shown { | |||
| 199 | + | at := m.cardAt(w) | |||
| 200 | + | result = append(result, w.previewRow(ry, w != cur, at < 0)...) | |||
| 201 | + | result = append(result, w.pile(ry, w != cur, at)...) | |||
| 202 | + | } | |||
| 203 | + | for _, f := range w.Floats { | |||
| 204 | + | result = append(result, Placement{ID: f.ID, Hidden: true}) | |||
| 205 | + | } | |||
| 206 | + | if !shown { | |||
| 207 | + | for _, id := range w.windows() { | |||
| 208 | + | if w.floatIndex(id) < 0 { | |||
| 209 | + | result = append(result, Placement{ID: id, Hidden: true}) | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | return result | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // pileWidth is the room the stash pile of w takes on the left of its row, | |||
| 218 | + | // its margin included; 0 without a stash. | |||
| 219 | + | func (w *Workspace) pileWidth() int { | |||
| 220 | + | if len(w.Stash) == 0 { | |||
| 221 | + | return 0 | |||
| 222 | + | } | |||
| 223 | + | card := int(math.Round(float64(w.stashRect().W) * overviewCardZoom)) | |||
| 224 | + | // Never more than a third of the width: the columns keep the rest. | |||
| 225 | + | return min(card+(min(len(w.Stash), overviewCards)-1)*w.cardStep()+w.Usable.W*2/100, w.Usable.W/3) | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | // cardStep is the offset between stacked cards, smaller on tiny outputs. | |||
| 229 | + | func (w *Workspace) cardStep() int { | |||
| 230 | + | return min(overviewCardStep, w.Usable.W/100, w.Usable.H/50) | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | // pile places the stash of w as cards on the left of the row whose top is | |||
| 234 | + | // at y: entry front (the stash selection when -1) in front, the next ones | |||
| 235 | + | // behind it, dimmed, the rest hidden. Only a front card of the current row | |||
| 236 | + | // (dim false) with front set is selected. | |||
| 237 | + | func (w *Workspace) pile(y int, dim bool, front int) []Placement { | |||
| 238 | + | n := len(w.Stash) | |||
| 239 | + | if n == 0 { | |||
| 240 | + | return nil | |||
| 241 | + | } | |||
| 242 | + | selected := front >= 0 && !dim | |||
| 243 | + | if front < 0 { | |||
| 244 | + | front = w.stashAt | |||
| 245 | + | } | |||
| 246 | + | front = min(max(front, 0), n-1) | |||
| 247 | + | r := w.stashRect() | |||
| 248 | + | u := w.Usable | |||
| 249 | + | cw := int(math.Round(float64(r.W) * overviewCardZoom)) | |||
| 250 | + | ch := int(math.Round(float64(r.H) * overviewCardZoom)) | |||
| 251 | + | rowH := int(math.Round(float64(u.H) * w.overviewZoom())) | |||
| 252 | + | x0, y0 := u.X+u.W/100, y+(rowH-ch)/2 | |||
| 253 | + | shown := min(n, overviewCards) | |||
| 254 | + | out := make([]Placement, 0, n) | |||
| 255 | + | // Farthest card first: the front one is drawn and hit on top. | |||
| 256 | + | for k := shown - 1; k >= 0; k-- { | |||
| 257 | + | i := (front + k) % n | |||
| 258 | + | step := k * w.cardStep() | |||
| 259 | + | out = append(out, Placement{ID: w.Stash[i].ID, Rect: Rect{X: x0 + step, Y: y0 - step, W: cw, H: ch}, Preview: overviewCardZoom, Peek: dim || k > 0, Focused: k == 0 && selected}) | |||
| 260 | + | } | |||
| 261 | + | for k := shown; k < n; k++ { | |||
| 262 | + | out = append(out, Placement{ID: w.Stash[(front+k)%n].ID, Hidden: true}) | |||
| 263 | + | } | |||
| 264 | + | return out | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | // overviewZoom is the scale that fits every column of w in the usable | |||
| 268 | + | // width, within the overview bounds. | |||
| 269 | + | func (w *Workspace) overviewZoom() float64 { | |||
| 270 | + | _, span, _ := w.previewTiles() | |||
| 271 | + | if span <= 0 { | |||
| 272 | + | return overviewMaxZoom | |||
| 273 | + | } | |||
| 274 | + | room := float64(w.Usable.W-w.pileWidth()) * 0.96 | |||
| 275 | + | return min(max(room/float64(span), overviewMinZoom), overviewMaxZoom) | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | // previewTiles lays the tiles of w out unscrolled, relative to the | |||
| 279 | + | // usable area's corner: every column side by side at its width, as if | |||
| 280 | + | // the output were wide enough (fixed overflow's spiral included). span is | |||
| 281 | + | // the row's width; sel is the focused column. | |||
| 282 | + | func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) { | |||
| 283 | + | g := w.gap() | |||
| 284 | + | h := max(w.Usable.H-2*g, 0) | |||
| 285 | + | for i, c := range w.Columns { | |||
| 286 | + | r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h} | |||
| 287 | + | if w.fullscreenColumn(i) { | |||
| 288 | + | // A fullscreen column shows at the width it had in the row. | |||
| 289 | + | r.W = max(w.Usable.W-2*g, 0) | |||
| 290 | + | } | |||
| 291 | + | for j, t := range stackRects(r, len(c.Windows), g) { | |||
| 292 | + | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus}) | |||
| 293 | + | } | |||
| 294 | + | span = max(span, r.X+r.W+g) | |||
| 295 | + | if i == w.Focus { | |||
| 296 | + | sel = r | |||
| 297 | + | } | |||
| 298 | + | } | |||
| 299 | + | return tiles, span, sel | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | // previewRow scales the tiles of w into a row whose top is at y, centred | |||
| 303 | + | // in the usable width, or scrolled so the focused column shows when the | |||
| 304 | + | // row is wider, right of the stash pile. Dimmed rows (neighbor | |||
| 305 | + | // workspaces) have no focus; neither has a row whose pile has it (lit | |||
| 306 | + | // false). | |||
| 307 | + | func (w *Workspace) previewRow(y int, dim, lit bool) []Placement { | |||
| 308 | + | tiles, span, sel := w.previewTiles() | |||
| 309 | + | u := w.Usable | |||
| 310 | + | left := w.pileWidth() | |||
| 311 | + | u.X, u.W = u.X+left, u.W-left | |||
| 312 | + | z := w.overviewZoom() | |||
| 313 | + | scale := func(v int) int { return int(math.Round(float64(v) * z)) } | |||
| 314 | + | width := scale(span) | |||
| 315 | + | x := u.X + (u.W-width)/2 | |||
| 316 | + | if width > u.W { | |||
| 317 | + | x = u.X + u.W/2 - scale(sel.X+sel.W/2) | |||
| 318 | + | x = min(max(x, u.X+u.W-width), u.X) | |||
| 319 | + | } | |||
| 320 | + | for i := range tiles { | |||
| 321 | + | r := tiles[i].Rect | |||
| 322 | + | x0, y0 := scale(r.X), scale(r.Y) | |||
| 323 | + | tiles[i].Rect = Rect{X: x + x0, Y: y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0} | |||
| 324 | + | tiles[i].Preview, tiles[i].Peek = z, dim | |||
| 325 | + | tiles[i].Focused = tiles[i].Focused && !dim && lit | |||
| 326 | + | } | |||
| 327 | + | return tiles | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | // overviewOutline frames the selected preview with active lines of width | |||
| 331 | + | // b, just outside it. | |||
| 332 | + | func overviewOutline(layout []Placement, b int) []ports.Separator { | |||
| 333 | + | for _, p := range layout { | |||
| 334 | + | if p.Preview > 0 && p.Focused && !p.Hidden { | |||
| 335 | + | r := p.Rect | |||
| 336 | + | return []ports.Separator{ | |||
| 337 | + | {Rect: Rect{X: r.X - b, Y: r.Y - b, W: r.W + 2*b, H: b}, Active: true}, | |||
| 338 | + | {Rect: Rect{X: r.X - b, Y: r.Y + r.H, W: r.W + 2*b, H: b}, Active: true}, | |||
| 339 | + | {Rect: Rect{X: r.X - b, Y: r.Y, W: b, H: r.H}, Active: true}, | |||
| 340 | + | {Rect: Rect{X: r.X + r.W, Y: r.Y, W: b, H: r.H}, Active: true}, | |||
| 341 | + | } | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | return nil | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | // overviewAt is the preview under the output-local point, if any. | |||
| 348 | + | func (m *Monitor) overviewAt(x, y float64) WindowID { | |||
| 349 | + | layout := m.Layout() | |||
| 350 | + | for i := len(layout) - 1; i >= 0; i-- { | |||
| 351 | + | p := layout[i] | |||
| 352 | + | r := p.Rect | |||
| 353 | + | if p.Preview > 0 && !p.Hidden && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) { | |||
| 354 | + | return p.ID | |||
| 355 | + | } | |||
| 356 | + | } | |||
| 357 | + | return 0 | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | // overviewClick closes the overview on the preview under the pointer, on | |||
| 361 | + | // the output it shows on. picked is false when no preview is there. | |||
| 362 | + | func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) { | |||
| 363 | + | o, ok := c.layout().At(c.cursorX, c.cursorY) | |||
| 364 | + | if !ok { | |||
| 365 | + | return false, nil | |||
| 366 | + | } | |||
| 367 | + | i := c.screenIndex(o.Info.Name) | |||
| 368 | + | sc := c.screens[i] | |||
| 369 | + | if !sc.mon.overview { | |||
| 370 | + | return false, nil | |||
| 371 | + | } | |||
| 372 | + | id := sc.mon.overviewAt(c.cursorX-float64(o.X), c.cursorY-float64(o.Y)) | |||
| 373 | + | if id == 0 { | |||
| 374 | + | return false, nil | |||
| 375 | + | } | |||
| 376 | + | sc.mon.OverviewPick(id) | |||
| 377 | + | c.focusScreen, c.layerFocus = i, 0 | |||
| 378 | + | if err := c.workspaceVisible(ctx, true); err != nil { | |||
| 379 | + | return true, err | |||
| 380 | + | } | |||
| 381 | + | return true, c.publish(ctx) | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | // overviewSwipe moves the selection like the keys: a workspace swipe | |||
| 385 | + | // must not enter the empty workspace below the last one. | |||
| 386 | + | func (m *Monitor) overviewSwipe(a Action) { | |||
| 387 | + | switch a { | |||
| 388 | + | case ActionFocusColumnLeft: | |||
| 389 | + | m.OverviewMove(-1, 0) | |||
| 390 | + | case ActionFocusColumnRight: | |||
| 391 | + | m.OverviewMove(1, 0) | |||
| 392 | + | case ActionFocusWorkspaceUp: | |||
| 393 | + | m.OverviewMove(0, -1) | |||
| 394 | + | case ActionFocusWorkspaceDown: | |||
| 395 | + | m.OverviewMove(0, 1) | |||
| 396 | + | } | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | // overviewScrollStep is the two-finger scroll distance, in libinput's | |||
| 400 | + | // pointer units, that moves the selection one column or workspace. | |||
| 401 | + | const overviewScrollStep = 60 | |||
| 402 | + | ||||
| 403 | + | // overviewScroll moves the overview selection with a scroll frame: two | |||
| 404 | + | // fingers step once per overviewScrollStep on the axis they move most | |||
| 405 | + | // along, a wheel once per notch (high-resolution wheels add up their | |||
| 406 | + | // fractions of a notch). Scrolling down or right selects the workspace | |||
| 407 | + | // below or the column on the right; touchpad.natural-scroll flips both, | |||
| 408 | + | // as libinput reports. changed is false while the distance adds up. | |||
| 409 | + | func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |||
| 410 | + | if a.Vertical.Stop || a.Horizontal.Stop { | |||
| 411 | + | // Fingers lifted: the next scroll starts from zero. | |||
| 412 | + | m.scrollX, m.scrollY = 0, 0 | |||
| 413 | + | return false | |||
| 414 | + | } | |||
| 415 | + | add := func(acc *float64, ax ports.ScrollAxis) { | |||
| 416 | + | switch { | |||
| 417 | + | case !ax.Set: | |||
| 418 | + | case a.Source == ports.AxisWheel: | |||
| 419 | + | *acc += float64(ax.V120) / 120 * overviewScrollStep | |||
| 420 | + | default: | |||
| 421 | + | *acc += ax.Value | |||
| 422 | + | } | |||
| 423 | + | } | |||
| 424 | + | add(&m.scrollY, a.Vertical) | |||
| 425 | + | add(&m.scrollX, a.Horizontal) | |||
| 426 | + | for { | |||
| 427 | + | switch { | |||
| 428 | + | case math.Abs(m.scrollY) >= overviewScrollStep && math.Abs(m.scrollY) >= math.Abs(m.scrollX): | |||
| 429 | + | m.OverviewMove(0, sign(m.scrollY)) | |||
| 430 | + | m.scrollY -= float64(sign(m.scrollY)) * overviewScrollStep | |||
| 431 | + | m.scrollX = 0 | |||
| 432 | + | case math.Abs(m.scrollX) >= overviewScrollStep: | |||
| 433 | + | m.OverviewMove(sign(m.scrollX), 0) | |||
| 434 | + | m.scrollX -= float64(sign(m.scrollX)) * overviewScrollStep | |||
| 435 | + | m.scrollY = 0 | |||
| 436 | + | default: | |||
| 437 | + | return changed | |||
| 438 | + | } | |||
| 439 | + | changed = true | |||
| 440 | + | } | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | func sign(v float64) int { | |||
| 444 | + | if v < 0 { | |||
| 445 | + | return -1 | |||
| 446 | + | } | |||
| 447 | + | return 1 | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | // overviewKeyboardTaken reports whether a layer surface (a launcher) or a | |||
| 451 | + | // grabbing menu has the keyboard: it gets the keys, not the overview. | |||
| 452 | + | func (c *Core) overviewKeyboardTaken() bool { | |||
| 453 | + | id := c.keyboardFocus() | |||
| 454 | + | _, _, layer := c.layerOf(id) | |||
| 455 | + | return layer || c.popups[id] != nil | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | // overviewKey runs an overview key: true when the key was one. | |||
| 459 | + | func (m *Monitor) overviewKey(key ports.KeyEvent) bool { | |||
| 460 | + | if key.Mods != 0 { | |||
| 461 | + | return false | |||
| 462 | + | } | |||
| 463 | + | switch keyName(key.Keysym) { | |||
| 464 | + | case "h", "Left": | |||
| 465 | + | m.OverviewMove(-1, 0) | |||
| 466 | + | case "l", "Right": | |||
| 467 | + | m.OverviewMove(1, 0) | |||
| 468 | + | case "k", "Up": | |||
| 469 | + | m.OverviewMove(0, -1) | |||
| 470 | + | case "j", "Down": | |||
| 471 | + | m.OverviewMove(0, 1) | |||
| 472 | + | case "Return", "KP_Enter": | |||
| 473 | + | m.ToggleOverview() | |||
| 474 | + | case "Escape": | |||
| 475 | + | m.CancelOverview() | |||
| 476 | + | default: | |||
| 477 | + | return false | |||
| 478 | + | } | |||
| 479 | + | return true | |||
| 480 | + | } | |||
A internal/core/overview_input_test.go +121 -0
| @@ -0,0 +1,121 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/adapters/config" | |
| 8 | + | "github.com/bnema/neferwl/internal/core" | |
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // A launcher holding the keyboard over the overview gets its keys; the | |
| 13 | + | // overview takes them back once it closes. | |
| 14 | + | func TestOverviewLauncherKeepsKeys(t *testing.T) { | |
| 15 | + | cfg := config.Defaults() | |
| 16 | + | client := make(chan ports.ClientEvent, 8) | |
| 17 | + | input := make(chan ports.InputEvent, 8) | |
| 18 | + | output := make(chan ports.OutputEvent, 8) | |
| 19 | + | commands := make(chan ports.ClientCommand, 256) | |
| 20 | + | scenes := make(chan []ports.Scene, 1) | |
| 21 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 22 | + | if err != nil { | |
| 23 | + | t.Fatal(err) | |
| 24 | + | } | |
| 25 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 26 | + | defer cancel() | |
| 27 | + | go c.Run(ctx) | |
| 28 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200}} | |
| 29 | + | scene(t, scenes) | |
| 30 | + | client <- ports.WindowMapped{ID: 1} | |
| 31 | + | scene(t, scenes) | |
| 32 | + | client <- ports.WindowMapped{ID: 2} | |
| 33 | + | scene(t, scenes) | |
| 34 | + | input <- ports.KeyEvent{Keysym: "o", Mods: ports.ModSuper, Pressed: true} | |
| 35 | + | sceneMatch(t, scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 && s.Windows[0].Preview > 0 }) | |
| 36 | + | input <- ports.KeyEvent{Keysym: "o", Mods: ports.ModSuper} | |
| 37 | + | ||
| 38 | + | launcher := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Width: 100, Height: 40, Keyboard: 1} | |
| 39 | + | client <- ports.LayerChanged{Layers: []ports.LayerSurface{launcher}} | |
| 40 | + | scene(t, scenes) | |
| 41 | + | for len(commands) > 0 { | |
| 42 | + | <-commands | |
| 43 | + | } | |
| 44 | + | input <- ports.KeyEvent{Keysym: "h", Pressed: true} | |
| 45 | + | if v := next(t, commands, anyOf[ports.ForwardKey]); v.ID != 7 || v.Key.Keysym != "h" { | |
| 46 | + | t.Fatalf("forward %+v, want the launcher", v) | |
| 47 | + | } | |
| 48 | + | input <- ports.KeyEvent{Keysym: "h"} | |
| 49 | + | next(t, commands, anyOf[ports.ForwardKey]) | |
| 50 | + | ||
| 51 | + | client <- ports.LayerChanged{} | |
| 52 | + | scene(t, scenes) | |
| 53 | + | input <- ports.KeyEvent{Keysym: "h", Pressed: true} | |
| 54 | + | sceneMatch(t, scenes, func(s ports.Scene) bool { | |
| 55 | + | for _, w := range s.Windows { | |
| 56 | + | if w.ID == 1 && w.Focused && w.Preview > 0 { | |
| 57 | + | return true | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | return false | |
| 61 | + | }) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // Two-finger scrolling moves the overview selection one column per step, | |
| 65 | + | // along the axis the fingers move most; a small scroll does nothing and | |
| 66 | + | // lifting the fingers starts over. Outside the overview it goes to the | |
| 67 | + | // window. | |
| 68 | + | func TestOverviewTwoFingerScroll(t *testing.T) { | |
| 69 | + | cfg := config.Defaults() | |
| 70 | + | client := make(chan ports.ClientEvent, 8) | |
| 71 | + | input := make(chan ports.InputEvent, 8) | |
| 72 | + | output := make(chan ports.OutputEvent, 8) | |
| 73 | + | commands := make(chan ports.ClientCommand, 256) | |
| 74 | + | scenes := make(chan []ports.Scene, 1) | |
| 75 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 76 | + | if err != nil { | |
| 77 | + | t.Fatal(err) | |
| 78 | + | } | |
| 79 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 80 | + | defer cancel() | |
| 81 | + | go c.Run(ctx) | |
| 82 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200}} | |
| 83 | + | scene(t, scenes) | |
| 84 | + | for id := ports.WindowID(1); id <= 3; id++ { | |
| 85 | + | client <- ports.WindowMapped{ID: id} | |
| 86 | + | scene(t, scenes) | |
| 87 | + | } | |
| 88 | + | focused := func(id ports.WindowID) func(ports.Scene) bool { | |
| 89 | + | return func(s ports.Scene) bool { | |
| 90 | + | for _, w := range s.Windows { | |
| 91 | + | if w.ID == id && w.Focused && w.Preview > 0 { | |
| 92 | + | return true | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | return false | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | input <- ports.KeyEvent{Keysym: "o", Mods: ports.ModSuper, Pressed: true} | |
| 99 | + | sceneMatch(t, scenes, focused(3)) | |
| 100 | + | input <- ports.KeyEvent{Keysym: "o", Mods: ports.ModSuper} | |
| 101 | + | finger := func(dx, dy float64) ports.PointerAxis { | |
| 102 | + | return ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: dx}, Vertical: ports.ScrollAxis{Set: true, Value: dy}} | |
| 103 | + | } | |
| 104 | + | // 50 left, lifted, then 50 and 50 left: one step (to 2). Without the | |
| 105 | + | // reset it would be two (to 1), and the l below would land on 2. | |
| 106 | + | input <- finger(-50, 5) | |
| 107 | + | input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}} | |
| 108 | + | input <- finger(-50, 5) | |
| 109 | + | input <- finger(-50, 0) | |
| 110 | + | sceneMatch(t, scenes, focused(2)) | |
| 111 | + | input <- ports.KeyEvent{Keysym: "l", Pressed: true} | |
| 112 | + | sceneMatch(t, scenes, focused(3)) | |
| 113 | + | input <- ports.KeyEvent{Keysym: "l"} | |
| 114 | + | input <- finger(-60, 0) | |
| 115 | + | sceneMatch(t, scenes, focused(2)) | |
| 116 | + | // A wheel notch down has nowhere to go (no workspace below with | |
| 117 | + | // windows); a finger scroll left moves on. | |
| 118 | + | input <- ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, Value: 15, V120: 120}} | |
| 119 | + | input <- finger(-70, 0) | |
| 120 | + | sceneMatch(t, scenes, focused(1)) | |
| 121 | + | } | |
| @@ -0,0 +1,121 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/adapters/config" | |||
| 8 | + | "github.com/bnema/neferwl/internal/core" | |||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // A launcher holding the keyboard over the overview gets its keys; the | |||
| 13 | + | // overview takes them back once it closes. | |||
| 14 | + | func TestOverviewLauncherKeepsKeys(t *testing.T) { | |||
| 15 | + | cfg := config.Defaults() | |||
| 16 | + | client := make(chan ports.ClientEvent, 8) | |||
| 17 | + | input := make(chan ports.InputEvent, 8) | |||
| 18 | + | output := make(chan ports.OutputEvent, 8) | |||
| 19 | + | commands := make(chan ports.ClientCommand, 256) | |||
| 20 | + | scenes := make(chan []ports.Scene, 1) | |||
| 21 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |||
| 22 | + | if err != nil { | |||
| 23 | + | t.Fatal(err) | |||
| 24 | + | } | |||
| 25 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 26 | + | defer cancel() | |||
| 27 | + | go c.Run(ctx) | |||
| 28 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200}} | |||
| 29 | + | scene(t, scenes) | |||
| 30 | + | client <- ports.WindowMapped{ID: 1} | |||
| 31 | + | scene(t, scenes) | |||
| 32 | + | client <- ports.WindowMapped{ID: 2} | |||
| 33 | + | scene(t, scenes) | |||
| 34 | + | input <- ports.KeyEvent{Keysym: "o", Mods: ports.ModSuper, Pressed: true} | |||
| 35 | + | sceneMatch(t, scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 && s.Windows[0].Preview > 0 }) | |||
| 36 | + | input <- ports.KeyEvent{Keysym: "o", Mods: ports.ModSuper} | |||
| 37 | + | ||||
| 38 | + | launcher := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Width: 100, Height: 40, Keyboard: 1} | |||
| 39 | + | client <- ports.LayerChanged{Layers: []ports.LayerSurface{launcher}} | |||
| 40 | + | scene(t, scenes) | |||
| 41 | + | for len(commands) > 0 { | |||
| 42 | + | <-commands | |||
| 43 | + | } | |||
| 44 | + | input <- ports.KeyEvent{Keysym: "h", Pressed: true} | |||
| 45 | + | if v := next(t, commands, anyOf[ports.ForwardKey]); v.ID != 7 || v.Key.Keysym != "h" { | |||
| 46 | + | t.Fatalf("forward %+v, want the launcher", v) | |||
| 47 | + | } | |||
| 48 | + | input <- ports.KeyEvent{Keysym: "h"} | |||
| 49 | + | next(t, commands, anyOf[ports.ForwardKey]) | |||
| 50 | + | ||||
| 51 | + | client <- ports.LayerChanged{} | |||
| 52 | + | scene(t, scenes) | |||
| 53 | + | input <- ports.KeyEvent{Keysym: "h", Pressed: true} | |||
| 54 | + | sceneMatch(t, scenes, func(s ports.Scene) bool { | |||
| 55 | + | for _, w := range s.Windows { | |||
| 56 | + | if w.ID == 1 && w.Focused && w.Preview > 0 { | |||
| 57 | + | return true | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | return false | |||
| 61 | + | }) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // Two-finger scrolling moves the overview selection one column per step, | |||
| 65 | + | // along the axis the fingers move most; a small scroll does nothing and | |||
| 66 | + | // lifting the fingers starts over. Outside the overview it goes to the | |||
| 67 | + | // window. | |||
| 68 | + | func TestOverviewTwoFingerScroll(t *testing.T) { | |||
| 69 | + | cfg := config.Defaults() | |||
| 70 | + | client := make(chan ports.ClientEvent, 8) | |||
| 71 | + | input := make(chan ports.InputEvent, 8) | |||
| 72 | + | output := make(chan ports.OutputEvent, 8) | |||
| 73 | + | commands := make(chan ports.ClientCommand, 256) | |||
| 74 | + | scenes := make(chan []ports.Scene, 1) | |||
| 75 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |||
| 76 | + | if err != nil { | |||
| 77 | + | t.Fatal(err) | |||
| 78 | + | } | |||
| 79 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 80 | + | defer cancel() | |||
| 81 | + | go c.Run(ctx) | |||
| 82 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200}} | |||
| 83 | + | scene(t, scenes) | |||
| 84 | + | for id := ports.WindowID(1); id <= 3; id++ { | |||
| 85 | + | client <- ports.WindowMapped{ID: id} | |||
| 86 | + | scene(t, scenes) | |||
| 87 | + | } | |||
| 88 | + | focused := func(id ports.WindowID) func(ports.Scene) bool { | |||
| 89 | + | return func(s ports.Scene) bool { | |||
| 90 | + | for _, w := range s.Windows { | |||
| 91 | + | if w.ID == id && w.Focused && w.Preview > 0 { | |||
| 92 | + | return true | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | return false | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | input <- ports.KeyEvent{Keysym: "o", Mods: ports.ModSuper, Pressed: true} | |||
| 99 | + | sceneMatch(t, scenes, focused(3)) | |||
| 100 | + | input <- ports.KeyEvent{Keysym: "o", Mods: ports.ModSuper} | |||
| 101 | + | finger := func(dx, dy float64) ports.PointerAxis { | |||
| 102 | + | return ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: dx}, Vertical: ports.ScrollAxis{Set: true, Value: dy}} | |||
| 103 | + | } | |||
| 104 | + | // 50 left, lifted, then 50 and 50 left: one step (to 2). Without the | |||
| 105 | + | // reset it would be two (to 1), and the l below would land on 2. | |||
| 106 | + | input <- finger(-50, 5) | |||
| 107 | + | input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}} | |||
| 108 | + | input <- finger(-50, 5) | |||
| 109 | + | input <- finger(-50, 0) | |||
| 110 | + | sceneMatch(t, scenes, focused(2)) | |||
| 111 | + | input <- ports.KeyEvent{Keysym: "l", Pressed: true} | |||
| 112 | + | sceneMatch(t, scenes, focused(3)) | |||
| 113 | + | input <- ports.KeyEvent{Keysym: "l"} | |||
| 114 | + | input <- finger(-60, 0) | |||
| 115 | + | sceneMatch(t, scenes, focused(2)) | |||
| 116 | + | // A wheel notch down has nowhere to go (no workspace below with | |||
| 117 | + | // windows); a finger scroll left moves on. | |||
| 118 | + | input <- ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, Value: 15, V120: 120}} | |||
| 119 | + | input <- finger(-70, 0) | |||
| 120 | + | sceneMatch(t, scenes, focused(1)) | |||
| 121 | + | } | |||
A internal/core/overview_test.go +419 -0
| @@ -0,0 +1,419 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // overviewMonitor has workspace 1 with columns 1, 2, 3 (a third of the | |
| 11 | + | // width each) on a 300x200 output, and workspace 2 with column 4. | |
| 12 | + | func overviewMonitor() *Monitor { | |
| 13 | + | m := newMonitor("", "") | |
| 14 | + | m.SetOutput(300, 200) | |
| 15 | + | m.SetMaxColumns(3) | |
| 16 | + | for id := WindowID(1); id <= 3; id++ { | |
| 17 | + | m.AddWindow(id) | |
| 18 | + | } | |
| 19 | + | m.Focus(1) | |
| 20 | + | m.AddWindow(4) | |
| 21 | + | m.Focus(0) | |
| 22 | + | return m | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func previewOf(t *testing.T, ps []Placement, id WindowID) Placement { | |
| 26 | + | t.Helper() | |
| 27 | + | for _, p := range ps { | |
| 28 | + | if p.ID == id { | |
| 29 | + | return p | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | t.Fatalf("no placement for %d", id) | |
| 33 | + | return Placement{} | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // Previews keep each tile's shape: width and height shrink by the same | |
| 37 | + | // factor, and the row fits the usable width. | |
| 38 | + | func TestOverviewPreviewsKeepAspect(t *testing.T) { | |
| 39 | + | m := overviewMonitor() | |
| 40 | + | normal := previewOf(t, m.Layout(), 2).Rect | |
| 41 | + | m.ToggleOverview() | |
| 42 | + | ps := m.Layout() | |
| 43 | + | for id := WindowID(1); id <= 3; id++ { | |
| 44 | + | p := previewOf(t, ps, id) | |
| 45 | + | if p.Hidden || p.Preview <= 0 || p.Peek { | |
| 46 | + | t.Fatalf("%d: %+v", id, p) | |
| 47 | + | } | |
| 48 | + | if p.Rect.X < 0 || p.Rect.X+p.Rect.W > 300 { | |
| 49 | + | t.Fatalf("%d off screen: %+v", id, p.Rect) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | p := previewOf(t, ps, 2) | |
| 53 | + | got, want := float64(p.Rect.W)/float64(p.Rect.H), float64(normal.W)/float64(normal.H) | |
| 54 | + | if math.Abs(got-want) > 0.02 { | |
| 55 | + | t.Fatalf("aspect %v, want %v (%+v)", got, want, p.Rect) | |
| 56 | + | } | |
| 57 | + | if math.Abs(float64(p.Rect.W)-float64(normal.W)*p.Preview) > 1 { | |
| 58 | + | t.Fatalf("width %d, zoom %v of %d", p.Rect.W, p.Preview, normal.W) | |
| 59 | + | } | |
| 60 | + | // Workspace 2 is below, dimmed and unfocused. | |
| 61 | + | below := previewOf(t, ps, 4) | |
| 62 | + | if below.Hidden || !below.Peek || below.Focused || below.Rect.Y <= p.Rect.Y+p.Rect.H { | |
| 63 | + | t.Fatalf("below %+v, current %+v", below, p) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Many columns shrink down to the floor, then the row scrolls to keep | |
| 68 | + | // the selected column on screen. | |
| 69 | + | func TestOverviewWideWorkspaceScrolls(t *testing.T) { | |
| 70 | + | m := newMonitor("", "") | |
| 71 | + | m.SetOutput(300, 200) | |
| 72 | + | m.SetMaxColumns(1) | |
| 73 | + | for id := WindowID(1); id <= 10; id++ { | |
| 74 | + | m.AddWindow(id) | |
| 75 | + | } | |
| 76 | + | m.ToggleOverview() | |
| 77 | + | ps := m.Layout() | |
| 78 | + | p := previewOf(t, ps, 10) | |
| 79 | + | if p.Preview != overviewMinZoom || !p.Focused || p.Rect.X < 0 || p.Rect.X+p.Rect.W > 300 { | |
| 80 | + | t.Fatalf("%+v", p) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // h/l and j/k move the selection; Escape returns, Return keeps it. | |
| 85 | + | func TestOverviewNavigation(t *testing.T) { | |
| 86 | + | m := overviewMonitor() | |
| 87 | + | m.Current().FocusID(3) | |
| 88 | + | m.ToggleOverview() | |
| 89 | + | m.OverviewMove(-1, 0) | |
| 90 | + | if id, _ := m.Focused(); id != 2 { | |
| 91 | + | t.Fatalf("left: %d", id) | |
| 92 | + | } | |
| 93 | + | m.OverviewMove(0, 1) | |
| 94 | + | if id, _ := m.Focused(); id != 4 || m.Active != 1 { | |
| 95 | + | t.Fatalf("down: %d on %d", id, m.Active) | |
| 96 | + | } | |
| 97 | + | // The empty workspace below the last one is not entered. | |
| 98 | + | m.OverviewMove(0, 1) | |
| 99 | + | if m.Active != 1 { | |
| 100 | + | t.Fatalf("entered empty workspace %d", m.Active) | |
| 101 | + | } | |
| 102 | + | m.CancelOverview() | |
| 103 | + | if id, _ := m.Focused(); id != 3 || m.Active != 0 || m.overview { | |
| 104 | + | t.Fatalf("cancel: %d on %d", id, m.Active) | |
| 105 | + | } | |
| 106 | + | m.ToggleOverview() | |
| 107 | + | m.OverviewMove(0, 1) | |
| 108 | + | m.ToggleOverview() | |
| 109 | + | if id, _ := m.Focused(); id != 4 || m.Active != 1 || m.overview { | |
| 110 | + | t.Fatalf("confirm: %d on %d", id, m.Active) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // A click picks the preview under it. | |
| 115 | + | func TestOverviewPick(t *testing.T) { | |
| 116 | + | m := overviewMonitor() | |
| 117 | + | m.ToggleOverview() | |
| 118 | + | below := previewOf(t, m.Layout(), 4).Rect | |
| 119 | + | id := m.overviewAt(float64(below.X+1), float64(below.Y+1)) | |
| 120 | + | if id != 4 { | |
| 121 | + | t.Fatalf("hit %d", id) | |
| 122 | + | } | |
| 123 | + | m.OverviewPick(id) | |
| 124 | + | if f, _ := m.Focused(); f != 4 || m.overview { | |
| 125 | + | t.Fatalf("pick: %d", f) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // pileMonitor is overviewMonitor with 5, 6 and 7 stashed on workspace 1 | |
| 130 | + | // (7 selected) and the stash hidden. | |
| 131 | + | func pileMonitor() *Monitor { | |
| 132 | + | m := overviewMonitor() | |
| 133 | + | w := m.Current() | |
| 134 | + | for id := WindowID(5); id <= 7; id++ { | |
| 135 | + | w.AddWindow(id) | |
| 136 | + | w.FocusID(id) | |
| 137 | + | w.ToggleWindowStash() | |
| 138 | + | } | |
| 139 | + | w.ToggleStashVisible() | |
| 140 | + | w.FocusID(1) | |
| 141 | + | return m | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // The stash shows as a pile on the left of its row, even hidden: its | |
| 145 | + | // selected window in front, the others behind, dimmed. The row starts | |
| 146 | + | // right of it. | |
| 147 | + | func TestOverviewPileLayout(t *testing.T) { | |
| 148 | + | m := pileMonitor() | |
| 149 | + | m.ToggleOverview() | |
| 150 | + | ps := m.Layout() | |
| 151 | + | front, back := previewOf(t, ps, 7), previewOf(t, ps, 5) | |
| 152 | + | if front.Hidden || front.Peek || front.Focused || front.Preview != overviewCardZoom { | |
| 153 | + | t.Fatalf("front %+v", front) | |
| 154 | + | } | |
| 155 | + | if back.Hidden || !back.Peek || back.Rect.X <= front.Rect.X || back.Rect.Y >= front.Rect.Y { | |
| 156 | + | t.Fatalf("back %+v, front %+v", back, front) | |
| 157 | + | } | |
| 158 | + | first := previewOf(t, ps, 1) | |
| 159 | + | if !first.Focused || first.Rect.X < back.Rect.X+back.Rect.W { | |
| 160 | + | t.Fatalf("row %+v overlaps the pile %+v", first.Rect, back.Rect) | |
| 161 | + | } | |
| 162 | + | // Front last: drawn and hit on top. | |
| 163 | + | idx := func(id WindowID) int { | |
| 164 | + | for i, p := range ps { | |
| 165 | + | if p.ID == id { | |
| 166 | + | return i | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | return -1 | |
| 170 | + | } | |
| 171 | + | if idx(7) < idx(5) || idx(7) < idx(6) { | |
| 172 | + | t.Fatal("front card drawn under the others") | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // h from column 1 enters the pile on the stash's selection, h/l browse | |
| 177 | + | // it, l past its end returns to column 1; Return shows the stash on the | |
| 178 | + | // chosen window. | |
| 179 | + | func TestOverviewPileNavigation(t *testing.T) { | |
| 180 | + | m := pileMonitor() | |
| 181 | + | w := m.Current() | |
| 182 | + | m.ToggleOverview() | |
| 183 | + | m.OverviewMove(-1, 0) | |
| 184 | + | if at := m.cardAt(w); at != 2 { | |
| 185 | + | t.Fatalf("entered at %d", at) | |
| 186 | + | } | |
| 187 | + | if f := previewOf(t, m.Layout(), 7); !f.Focused { | |
| 188 | + | t.Fatalf("front card not selected: %+v", f) | |
| 189 | + | } | |
| 190 | + | if previewOf(t, m.Layout(), 1).Focused { | |
| 191 | + | t.Fatal("column still selected") | |
| 192 | + | } | |
| 193 | + | m.OverviewMove(-1, 0) | |
| 194 | + | m.OverviewMove(-1, 0) | |
| 195 | + | m.OverviewMove(-1, 0) // stops at the first entry | |
| 196 | + | if at := m.cardAt(w); at != 0 { | |
| 197 | + | t.Fatalf("left end %d", at) | |
| 198 | + | } | |
| 199 | + | m.OverviewMove(1, 0) | |
| 200 | + | m.ToggleOverview() | |
| 201 | + | if f, _ := m.Focused(); f != 6 || w.stashHidden || m.overview { | |
| 202 | + | t.Fatalf("confirm on %d, hidden %v", f, w.stashHidden) | |
| 203 | + | } | |
| 204 | + | // Back in on the stash's selection (6), then l past the last entry | |
| 205 | + | // (7) returns to column 1. | |
| 206 | + | w.FocusID(1) | |
| 207 | + | m.ToggleOverview() | |
| 208 | + | m.OverviewMove(-1, 0) | |
| 209 | + | if at := m.cardAt(w); at != 1 { | |
| 210 | + | t.Fatalf("entered at %d", at) | |
| 211 | + | } | |
| 212 | + | m.OverviewMove(1, 0) | |
| 213 | + | m.OverviewMove(1, 0) | |
| 214 | + | if m.cardAt(w) >= 0 { | |
| 215 | + | t.Fatal("still in the pile") | |
| 216 | + | } | |
| 217 | + | if f, _ := m.Focused(); f != 1 { | |
| 218 | + | t.Fatalf("back on %d", f) | |
| 219 | + | } | |
| 220 | + | // Escape leaves the stash as it was. | |
| 221 | + | m.OverviewMove(-1, 0) | |
| 222 | + | m.CancelOverview() | |
| 223 | + | if f, _ := m.Focused(); f != 1 { | |
| 224 | + | t.Fatalf("cancel on %d", f) | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // A click on a card shows the stash on it. | |
| 229 | + | func TestOverviewPilePick(t *testing.T) { | |
| 230 | + | m := pileMonitor() | |
| 231 | + | m.ToggleOverview() | |
| 232 | + | r := previewOf(t, m.Layout(), 7).Rect | |
| 233 | + | id := m.overviewAt(float64(r.X+1), float64(r.Y+r.H-1)) | |
| 234 | + | if id != 7 { | |
| 235 | + | t.Fatalf("hit %d", id) | |
| 236 | + | } | |
| 237 | + | m.OverviewPick(id) | |
| 238 | + | if f, _ := m.Focused(); f != 7 || m.Current().stashHidden { | |
| 239 | + | t.Fatalf("pick on %d", f) | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // A workspace with only stashed windows opens with the pile selected, and | |
| 244 | + | // its neighbor's pile is dimmed and unselected. | |
| 245 | + | func TestOverviewPileOnly(t *testing.T) { | |
| 246 | + | m := newMonitor("", "") | |
| 247 | + | m.SetOutput(300, 200) | |
| 248 | + | m.AddWindow(1) | |
| 249 | + | w := m.Current() | |
| 250 | + | w.ToggleWindowStash() | |
| 251 | + | m.ToggleOverview() | |
| 252 | + | if p := previewOf(t, m.Layout(), 1); !p.Focused || p.Hidden { | |
| 253 | + | t.Fatalf("%+v", p) | |
| 254 | + | } | |
| 255 | + | m.CancelOverview() | |
| 256 | + | m.Focus(1) | |
| 257 | + | m.AddWindow(2) | |
| 258 | + | m.Focus(0) | |
| 259 | + | m.ToggleOverview() | |
| 260 | + | m.OverviewMove(0, 1) | |
| 261 | + | if p := previewOf(t, m.Layout(), 1); p.Focused || !p.Peek || p.Hidden { | |
| 262 | + | t.Fatalf("neighbor pile %+v", p) | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // Fixed overflow's spiral is laid out as a row: no preview overlaps. | |
| 267 | + | func TestOverviewFixedOverflowRow(t *testing.T) { | |
| 268 | + | m := newMonitor("", "") | |
| 269 | + | m.SetOutput(300, 200) | |
| 270 | + | m.SetOverflow(OverflowFixed) | |
| 271 | + | m.SetMaxColumns(2) | |
| 272 | + | for id := WindowID(1); id <= 4; id++ { | |
| 273 | + | m.AddWindow(id) | |
| 274 | + | } | |
| 275 | + | m.ToggleOverview() | |
| 276 | + | ps := m.Layout() | |
| 277 | + | for a := WindowID(1); a <= 4; a++ { | |
| 278 | + | for b := a + 1; b <= 4; b++ { | |
| 279 | + | if ra, rb := previewOf(t, ps, a).Rect, previewOf(t, ps, b).Rect; ra.Overlaps(rb) { | |
| 280 | + | t.Fatalf("%d %+v overlaps %d %+v", a, ra, b, rb) | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | // The selected card follows its window: removing an earlier stash entry | |
| 287 | + | // keeps it, removing the card itself drops the selection to the column. | |
| 288 | + | // The card is what Focused reports, and close-window closes it. | |
| 289 | + | func TestOverviewCardFollowsWindow(t *testing.T) { | |
| 290 | + | m := pileMonitor() | |
| 291 | + | w := m.Current() | |
| 292 | + | m.ToggleOverview() | |
| 293 | + | m.OverviewMove(-1, 0) // on 7 | |
| 294 | + | if id, _ := m.Focused(); id != 7 { | |
| 295 | + | t.Fatalf("focused %d, want the card", id) | |
| 296 | + | } | |
| 297 | + | if e := m.Apply(ActionCloseWindow); e.Close != 7 { | |
| 298 | + | t.Fatalf("close %d, want the card", e.Close) | |
| 299 | + | } | |
| 300 | + | m.RemoveWindow(5) | |
| 301 | + | if id, _ := m.Focused(); id != 7 { | |
| 302 | + | t.Fatalf("after removing 5: %d", id) | |
| 303 | + | } | |
| 304 | + | m.RemoveWindow(7) | |
| 305 | + | if id, _ := m.Focused(); id != 1 || m.cardAt(w) >= 0 { | |
| 306 | + | t.Fatalf("after removing the card: %d", id) | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | // A workspace switched by a bind does not inherit the card selection. | |
| 311 | + | func TestOverviewCardAfterBindSwitch(t *testing.T) { | |
| 312 | + | m := pileMonitor() | |
| 313 | + | m.Workspaces[1].AddWindow(8) | |
| 314 | + | m.Workspaces[1].FocusID(8) | |
| 315 | + | m.Workspaces[1].ToggleWindowStash() | |
| 316 | + | m.Workspaces[1].ToggleStashVisible() | |
| 317 | + | m.ToggleOverview() | |
| 318 | + | m.OverviewMove(-1, 0) | |
| 319 | + | m.FocusNumber(2) | |
| 320 | + | if id, _ := m.Focused(); id != 4 { | |
| 321 | + | t.Fatalf("focused %d after the switch", id) | |
| 322 | + | } | |
| 323 | + | m.ToggleOverview() | |
| 324 | + | if !m.Workspaces[1].stashHidden { | |
| 325 | + | t.Fatal("the other workspace's stash was shown") | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | // Picking a card wins over another stashed window's pending fullscreen. | |
| 330 | + | func TestOverviewCardOverPendingFullscreen(t *testing.T) { | |
| 331 | + | m := pileMonitor() | |
| 332 | + | w := m.Current() | |
| 333 | + | w.SetFullscreen(5, true) // hidden stash: pending | |
| 334 | + | m.ToggleOverview() | |
| 335 | + | m.OverviewMove(-1, 0) | |
| 336 | + | m.ToggleOverview() | |
| 337 | + | if id, _ := m.Focused(); id != 7 || w.fullscreen != 0 { | |
| 338 | + | t.Fatalf("focused %d, fullscreen %d", id, w.fullscreen) | |
| 339 | + | } | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | // On a tiny output the pile leaves room for the row. | |
| 343 | + | func TestOverviewPileTinyOutput(t *testing.T) { | |
| 344 | + | m := pileMonitor() | |
| 345 | + | m.SetOutput(30, 20) | |
| 346 | + | m.ToggleOverview() | |
| 347 | + | for _, p := range m.Layout() { | |
| 348 | + | if !p.Hidden && (p.Rect.W < 0 || p.Rect.X < 0 || p.Rect.X > 30) { | |
| 349 | + | t.Fatalf("%+v", p) | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | if w := m.Current(); w.pileWidth() > 10 { | |
| 353 | + | t.Fatalf("pile %d of 30", w.pileWidth()) | |
| 354 | + | } | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | // The overview opens on what has the focus: a shown stash's card, else | |
| 358 | + | // the column under a focused native float (hidden in the overview), and | |
| 359 | + | // Return keeps it. Escape gives the float its focus back. | |
| 360 | + | func TestOverviewOpensOnFocus(t *testing.T) { | |
| 361 | + | m := pileMonitor() | |
| 362 | + | w := m.Current() | |
| 363 | + | w.ToggleStashVisible() | |
| 364 | + | m.ToggleOverview() | |
| 365 | + | if at := m.cardAt(w); at != w.stashAt { | |
| 366 | + | t.Fatalf("opened on card %d, want the stash's %d", at, w.stashAt) | |
| 367 | + | } | |
| 368 | + | m.ToggleOverview() | |
| 369 | + | ||
| 370 | + | m = overviewMonitor() | |
| 371 | + | w = m.Current() | |
| 372 | + | col, _ := w.Focused() | |
| 373 | + | w.AddFloating(9, 10, 10) | |
| 374 | + | w.FocusID(9) | |
| 375 | + | m.ToggleOverview() | |
| 376 | + | if f, _ := m.Focused(); f != col || !previewOf(t, m.Layout(), col).Focused { | |
| 377 | + | t.Fatalf("opened on %d, want column %d", f, col) | |
| 378 | + | } | |
| 379 | + | m.CancelOverview() | |
| 380 | + | if f, _ := m.Focused(); f != 9 { | |
| 381 | + | t.Fatalf("escape on %d, want the float", f) | |
| 382 | + | } | |
| 383 | + | w.FocusID(9) | |
| 384 | + | m.ToggleOverview() | |
| 385 | + | m.ToggleOverview() | |
| 386 | + | if f, _ := m.Focused(); f != col { | |
| 387 | + | t.Fatalf("return on %d, want column %d", f, col) | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | // Scrolling adds up to steps: what is left when the overview closes does | |
| 392 | + | // not count when it opens again, and a wheel frame of two notches moves | |
| 393 | + | // twice. | |
| 394 | + | func TestOverviewScrollSteps(t *testing.T) { | |
| 395 | + | m := overviewMonitor() | |
| 396 | + | w := m.Current() | |
| 397 | + | finger := func(dx float64) ports.PointerAxis { | |
| 398 | + | return ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: dx}} | |
| 399 | + | } | |
| 400 | + | m.ToggleOverview() | |
| 401 | + | start, _ := m.Focused() | |
| 402 | + | if m.overviewScroll(finger(-50)) { | |
| 403 | + | t.Fatal("a partial scroll moved") | |
| 404 | + | } | |
| 405 | + | m.CancelOverview() | |
| 406 | + | m.ToggleOverview() | |
| 407 | + | if m.overviewScroll(finger(-50)) { | |
| 408 | + | t.Fatal("the last session's scroll carried over") | |
| 409 | + | } | |
| 410 | + | m.CancelOverview() | |
| 411 | + | w.FocusID(3) | |
| 412 | + | m.ToggleOverview() | |
| 413 | + | if !m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel, Horizontal: ports.ScrollAxis{Set: true, V120: -240}}) { | |
| 414 | + | t.Fatal("wheel did not move") | |
| 415 | + | } | |
| 416 | + | if f, _ := m.Focused(); f != 1 { | |
| 417 | + | t.Fatalf("two notches from 3 landed on %d, want 1 (started on %d)", f, start) | |
| 418 | + | } | |
| 419 | + | } | |
| @@ -0,0 +1,419 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // overviewMonitor has workspace 1 with columns 1, 2, 3 (a third of the | |||
| 11 | + | // width each) on a 300x200 output, and workspace 2 with column 4. | |||
| 12 | + | func overviewMonitor() *Monitor { | |||
| 13 | + | m := newMonitor("", "") | |||
| 14 | + | m.SetOutput(300, 200) | |||
| 15 | + | m.SetMaxColumns(3) | |||
| 16 | + | for id := WindowID(1); id <= 3; id++ { | |||
| 17 | + | m.AddWindow(id) | |||
| 18 | + | } | |||
| 19 | + | m.Focus(1) | |||
| 20 | + | m.AddWindow(4) | |||
| 21 | + | m.Focus(0) | |||
| 22 | + | return m | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func previewOf(t *testing.T, ps []Placement, id WindowID) Placement { | |||
| 26 | + | t.Helper() | |||
| 27 | + | for _, p := range ps { | |||
| 28 | + | if p.ID == id { | |||
| 29 | + | return p | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | t.Fatalf("no placement for %d", id) | |||
| 33 | + | return Placement{} | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // Previews keep each tile's shape: width and height shrink by the same | |||
| 37 | + | // factor, and the row fits the usable width. | |||
| 38 | + | func TestOverviewPreviewsKeepAspect(t *testing.T) { | |||
| 39 | + | m := overviewMonitor() | |||
| 40 | + | normal := previewOf(t, m.Layout(), 2).Rect | |||
| 41 | + | m.ToggleOverview() | |||
| 42 | + | ps := m.Layout() | |||
| 43 | + | for id := WindowID(1); id <= 3; id++ { | |||
| 44 | + | p := previewOf(t, ps, id) | |||
| 45 | + | if p.Hidden || p.Preview <= 0 || p.Peek { | |||
| 46 | + | t.Fatalf("%d: %+v", id, p) | |||
| 47 | + | } | |||
| 48 | + | if p.Rect.X < 0 || p.Rect.X+p.Rect.W > 300 { | |||
| 49 | + | t.Fatalf("%d off screen: %+v", id, p.Rect) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | p := previewOf(t, ps, 2) | |||
| 53 | + | got, want := float64(p.Rect.W)/float64(p.Rect.H), float64(normal.W)/float64(normal.H) | |||
| 54 | + | if math.Abs(got-want) > 0.02 { | |||
| 55 | + | t.Fatalf("aspect %v, want %v (%+v)", got, want, p.Rect) | |||
| 56 | + | } | |||
| 57 | + | if math.Abs(float64(p.Rect.W)-float64(normal.W)*p.Preview) > 1 { | |||
| 58 | + | t.Fatalf("width %d, zoom %v of %d", p.Rect.W, p.Preview, normal.W) | |||
| 59 | + | } | |||
| 60 | + | // Workspace 2 is below, dimmed and unfocused. | |||
| 61 | + | below := previewOf(t, ps, 4) | |||
| 62 | + | if below.Hidden || !below.Peek || below.Focused || below.Rect.Y <= p.Rect.Y+p.Rect.H { | |||
| 63 | + | t.Fatalf("below %+v, current %+v", below, p) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Many columns shrink down to the floor, then the row scrolls to keep | |||
| 68 | + | // the selected column on screen. | |||
| 69 | + | func TestOverviewWideWorkspaceScrolls(t *testing.T) { | |||
| 70 | + | m := newMonitor("", "") | |||
| 71 | + | m.SetOutput(300, 200) | |||
| 72 | + | m.SetMaxColumns(1) | |||
| 73 | + | for id := WindowID(1); id <= 10; id++ { | |||
| 74 | + | m.AddWindow(id) | |||
| 75 | + | } | |||
| 76 | + | m.ToggleOverview() | |||
| 77 | + | ps := m.Layout() | |||
| 78 | + | p := previewOf(t, ps, 10) | |||
| 79 | + | if p.Preview != overviewMinZoom || !p.Focused || p.Rect.X < 0 || p.Rect.X+p.Rect.W > 300 { | |||
| 80 | + | t.Fatalf("%+v", p) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // h/l and j/k move the selection; Escape returns, Return keeps it. | |||
| 85 | + | func TestOverviewNavigation(t *testing.T) { | |||
| 86 | + | m := overviewMonitor() | |||
| 87 | + | m.Current().FocusID(3) | |||
| 88 | + | m.ToggleOverview() | |||
| 89 | + | m.OverviewMove(-1, 0) | |||
| 90 | + | if id, _ := m.Focused(); id != 2 { | |||
| 91 | + | t.Fatalf("left: %d", id) | |||
| 92 | + | } | |||
| 93 | + | m.OverviewMove(0, 1) | |||
| 94 | + | if id, _ := m.Focused(); id != 4 || m.Active != 1 { | |||
| 95 | + | t.Fatalf("down: %d on %d", id, m.Active) | |||
| 96 | + | } | |||
| 97 | + | // The empty workspace below the last one is not entered. | |||
| 98 | + | m.OverviewMove(0, 1) | |||
| 99 | + | if m.Active != 1 { | |||
| 100 | + | t.Fatalf("entered empty workspace %d", m.Active) | |||
| 101 | + | } | |||
| 102 | + | m.CancelOverview() | |||
| 103 | + | if id, _ := m.Focused(); id != 3 || m.Active != 0 || m.overview { | |||
| 104 | + | t.Fatalf("cancel: %d on %d", id, m.Active) | |||
| 105 | + | } | |||
| 106 | + | m.ToggleOverview() | |||
| 107 | + | m.OverviewMove(0, 1) | |||
| 108 | + | m.ToggleOverview() | |||
| 109 | + | if id, _ := m.Focused(); id != 4 || m.Active != 1 || m.overview { | |||
| 110 | + | t.Fatalf("confirm: %d on %d", id, m.Active) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // A click picks the preview under it. | |||
| 115 | + | func TestOverviewPick(t *testing.T) { | |||
| 116 | + | m := overviewMonitor() | |||
| 117 | + | m.ToggleOverview() | |||
| 118 | + | below := previewOf(t, m.Layout(), 4).Rect | |||
| 119 | + | id := m.overviewAt(float64(below.X+1), float64(below.Y+1)) | |||
| 120 | + | if id != 4 { | |||
| 121 | + | t.Fatalf("hit %d", id) | |||
| 122 | + | } | |||
| 123 | + | m.OverviewPick(id) | |||
| 124 | + | if f, _ := m.Focused(); f != 4 || m.overview { | |||
| 125 | + | t.Fatalf("pick: %d", f) | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // pileMonitor is overviewMonitor with 5, 6 and 7 stashed on workspace 1 | |||
| 130 | + | // (7 selected) and the stash hidden. | |||
| 131 | + | func pileMonitor() *Monitor { | |||
| 132 | + | m := overviewMonitor() | |||
| 133 | + | w := m.Current() | |||
| 134 | + | for id := WindowID(5); id <= 7; id++ { | |||
| 135 | + | w.AddWindow(id) | |||
| 136 | + | w.FocusID(id) | |||
| 137 | + | w.ToggleWindowStash() | |||
| 138 | + | } | |||
| 139 | + | w.ToggleStashVisible() | |||
| 140 | + | w.FocusID(1) | |||
| 141 | + | return m | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // The stash shows as a pile on the left of its row, even hidden: its | |||
| 145 | + | // selected window in front, the others behind, dimmed. The row starts | |||
| 146 | + | // right of it. | |||
| 147 | + | func TestOverviewPileLayout(t *testing.T) { | |||
| 148 | + | m := pileMonitor() | |||
| 149 | + | m.ToggleOverview() | |||
| 150 | + | ps := m.Layout() | |||
| 151 | + | front, back := previewOf(t, ps, 7), previewOf(t, ps, 5) | |||
| 152 | + | if front.Hidden || front.Peek || front.Focused || front.Preview != overviewCardZoom { | |||
| 153 | + | t.Fatalf("front %+v", front) | |||
| 154 | + | } | |||
| 155 | + | if back.Hidden || !back.Peek || back.Rect.X <= front.Rect.X || back.Rect.Y >= front.Rect.Y { | |||
| 156 | + | t.Fatalf("back %+v, front %+v", back, front) | |||
| 157 | + | } | |||
| 158 | + | first := previewOf(t, ps, 1) | |||
| 159 | + | if !first.Focused || first.Rect.X < back.Rect.X+back.Rect.W { | |||
| 160 | + | t.Fatalf("row %+v overlaps the pile %+v", first.Rect, back.Rect) | |||
| 161 | + | } | |||
| 162 | + | // Front last: drawn and hit on top. | |||
| 163 | + | idx := func(id WindowID) int { | |||
| 164 | + | for i, p := range ps { | |||
| 165 | + | if p.ID == id { | |||
| 166 | + | return i | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | return -1 | |||
| 170 | + | } | |||
| 171 | + | if idx(7) < idx(5) || idx(7) < idx(6) { | |||
| 172 | + | t.Fatal("front card drawn under the others") | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // h from column 1 enters the pile on the stash's selection, h/l browse | |||
| 177 | + | // it, l past its end returns to column 1; Return shows the stash on the | |||
| 178 | + | // chosen window. | |||
| 179 | + | func TestOverviewPileNavigation(t *testing.T) { | |||
| 180 | + | m := pileMonitor() | |||
| 181 | + | w := m.Current() | |||
| 182 | + | m.ToggleOverview() | |||
| 183 | + | m.OverviewMove(-1, 0) | |||
| 184 | + | if at := m.cardAt(w); at != 2 { | |||
| 185 | + | t.Fatalf("entered at %d", at) | |||
| 186 | + | } | |||
| 187 | + | if f := previewOf(t, m.Layout(), 7); !f.Focused { | |||
| 188 | + | t.Fatalf("front card not selected: %+v", f) | |||
| 189 | + | } | |||
| 190 | + | if previewOf(t, m.Layout(), 1).Focused { | |||
| 191 | + | t.Fatal("column still selected") | |||
| 192 | + | } | |||
| 193 | + | m.OverviewMove(-1, 0) | |||
| 194 | + | m.OverviewMove(-1, 0) | |||
| 195 | + | m.OverviewMove(-1, 0) // stops at the first entry | |||
| 196 | + | if at := m.cardAt(w); at != 0 { | |||
| 197 | + | t.Fatalf("left end %d", at) | |||
| 198 | + | } | |||
| 199 | + | m.OverviewMove(1, 0) | |||
| 200 | + | m.ToggleOverview() | |||
| 201 | + | if f, _ := m.Focused(); f != 6 || w.stashHidden || m.overview { | |||
| 202 | + | t.Fatalf("confirm on %d, hidden %v", f, w.stashHidden) | |||
| 203 | + | } | |||
| 204 | + | // Back in on the stash's selection (6), then l past the last entry | |||
| 205 | + | // (7) returns to column 1. | |||
| 206 | + | w.FocusID(1) | |||
| 207 | + | m.ToggleOverview() | |||
| 208 | + | m.OverviewMove(-1, 0) | |||
| 209 | + | if at := m.cardAt(w); at != 1 { | |||
| 210 | + | t.Fatalf("entered at %d", at) | |||
| 211 | + | } | |||
| 212 | + | m.OverviewMove(1, 0) | |||
| 213 | + | m.OverviewMove(1, 0) | |||
| 214 | + | if m.cardAt(w) >= 0 { | |||
| 215 | + | t.Fatal("still in the pile") | |||
| 216 | + | } | |||
| 217 | + | if f, _ := m.Focused(); f != 1 { | |||
| 218 | + | t.Fatalf("back on %d", f) | |||
| 219 | + | } | |||
| 220 | + | // Escape leaves the stash as it was. | |||
| 221 | + | m.OverviewMove(-1, 0) | |||
| 222 | + | m.CancelOverview() | |||
| 223 | + | if f, _ := m.Focused(); f != 1 { | |||
| 224 | + | t.Fatalf("cancel on %d", f) | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | // A click on a card shows the stash on it. | |||
| 229 | + | func TestOverviewPilePick(t *testing.T) { | |||
| 230 | + | m := pileMonitor() | |||
| 231 | + | m.ToggleOverview() | |||
| 232 | + | r := previewOf(t, m.Layout(), 7).Rect | |||
| 233 | + | id := m.overviewAt(float64(r.X+1), float64(r.Y+r.H-1)) | |||
| 234 | + | if id != 7 { | |||
| 235 | + | t.Fatalf("hit %d", id) | |||
| 236 | + | } | |||
| 237 | + | m.OverviewPick(id) | |||
| 238 | + | if f, _ := m.Focused(); f != 7 || m.Current().stashHidden { | |||
| 239 | + | t.Fatalf("pick on %d", f) | |||
| 240 | + | } | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // A workspace with only stashed windows opens with the pile selected, and | |||
| 244 | + | // its neighbor's pile is dimmed and unselected. | |||
| 245 | + | func TestOverviewPileOnly(t *testing.T) { | |||
| 246 | + | m := newMonitor("", "") | |||
| 247 | + | m.SetOutput(300, 200) | |||
| 248 | + | m.AddWindow(1) | |||
| 249 | + | w := m.Current() | |||
| 250 | + | w.ToggleWindowStash() | |||
| 251 | + | m.ToggleOverview() | |||
| 252 | + | if p := previewOf(t, m.Layout(), 1); !p.Focused || p.Hidden { | |||
| 253 | + | t.Fatalf("%+v", p) | |||
| 254 | + | } | |||
| 255 | + | m.CancelOverview() | |||
| 256 | + | m.Focus(1) | |||
| 257 | + | m.AddWindow(2) | |||
| 258 | + | m.Focus(0) | |||
| 259 | + | m.ToggleOverview() | |||
| 260 | + | m.OverviewMove(0, 1) | |||
| 261 | + | if p := previewOf(t, m.Layout(), 1); p.Focused || !p.Peek || p.Hidden { | |||
| 262 | + | t.Fatalf("neighbor pile %+v", p) | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | // Fixed overflow's spiral is laid out as a row: no preview overlaps. | |||
| 267 | + | func TestOverviewFixedOverflowRow(t *testing.T) { | |||
| 268 | + | m := newMonitor("", "") | |||
| 269 | + | m.SetOutput(300, 200) | |||
| 270 | + | m.SetOverflow(OverflowFixed) | |||
| 271 | + | m.SetMaxColumns(2) | |||
| 272 | + | for id := WindowID(1); id <= 4; id++ { | |||
| 273 | + | m.AddWindow(id) | |||
| 274 | + | } | |||
| 275 | + | m.ToggleOverview() | |||
| 276 | + | ps := m.Layout() | |||
| 277 | + | for a := WindowID(1); a <= 4; a++ { | |||
| 278 | + | for b := a + 1; b <= 4; b++ { | |||
| 279 | + | if ra, rb := previewOf(t, ps, a).Rect, previewOf(t, ps, b).Rect; ra.Overlaps(rb) { | |||
| 280 | + | t.Fatalf("%d %+v overlaps %d %+v", a, ra, b, rb) | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | // The selected card follows its window: removing an earlier stash entry | |||
| 287 | + | // keeps it, removing the card itself drops the selection to the column. | |||
| 288 | + | // The card is what Focused reports, and close-window closes it. | |||
| 289 | + | func TestOverviewCardFollowsWindow(t *testing.T) { | |||
| 290 | + | m := pileMonitor() | |||
| 291 | + | w := m.Current() | |||
| 292 | + | m.ToggleOverview() | |||
| 293 | + | m.OverviewMove(-1, 0) // on 7 | |||
| 294 | + | if id, _ := m.Focused(); id != 7 { | |||
| 295 | + | t.Fatalf("focused %d, want the card", id) | |||
| 296 | + | } | |||
| 297 | + | if e := m.Apply(ActionCloseWindow); e.Close != 7 { | |||
| 298 | + | t.Fatalf("close %d, want the card", e.Close) | |||
| 299 | + | } | |||
| 300 | + | m.RemoveWindow(5) | |||
| 301 | + | if id, _ := m.Focused(); id != 7 { | |||
| 302 | + | t.Fatalf("after removing 5: %d", id) | |||
| 303 | + | } | |||
| 304 | + | m.RemoveWindow(7) | |||
| 305 | + | if id, _ := m.Focused(); id != 1 || m.cardAt(w) >= 0 { | |||
| 306 | + | t.Fatalf("after removing the card: %d", id) | |||
| 307 | + | } | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | // A workspace switched by a bind does not inherit the card selection. | |||
| 311 | + | func TestOverviewCardAfterBindSwitch(t *testing.T) { | |||
| 312 | + | m := pileMonitor() | |||
| 313 | + | m.Workspaces[1].AddWindow(8) | |||
| 314 | + | m.Workspaces[1].FocusID(8) | |||
| 315 | + | m.Workspaces[1].ToggleWindowStash() | |||
| 316 | + | m.Workspaces[1].ToggleStashVisible() | |||
| 317 | + | m.ToggleOverview() | |||
| 318 | + | m.OverviewMove(-1, 0) | |||
| 319 | + | m.FocusNumber(2) | |||
| 320 | + | if id, _ := m.Focused(); id != 4 { | |||
| 321 | + | t.Fatalf("focused %d after the switch", id) | |||
| 322 | + | } | |||
| 323 | + | m.ToggleOverview() | |||
| 324 | + | if !m.Workspaces[1].stashHidden { | |||
| 325 | + | t.Fatal("the other workspace's stash was shown") | |||
| 326 | + | } | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | // Picking a card wins over another stashed window's pending fullscreen. | |||
| 330 | + | func TestOverviewCardOverPendingFullscreen(t *testing.T) { | |||
| 331 | + | m := pileMonitor() | |||
| 332 | + | w := m.Current() | |||
| 333 | + | w.SetFullscreen(5, true) // hidden stash: pending | |||
| 334 | + | m.ToggleOverview() | |||
| 335 | + | m.OverviewMove(-1, 0) | |||
| 336 | + | m.ToggleOverview() | |||
| 337 | + | if id, _ := m.Focused(); id != 7 || w.fullscreen != 0 { | |||
| 338 | + | t.Fatalf("focused %d, fullscreen %d", id, w.fullscreen) | |||
| 339 | + | } | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | // On a tiny output the pile leaves room for the row. | |||
| 343 | + | func TestOverviewPileTinyOutput(t *testing.T) { | |||
| 344 | + | m := pileMonitor() | |||
| 345 | + | m.SetOutput(30, 20) | |||
| 346 | + | m.ToggleOverview() | |||
| 347 | + | for _, p := range m.Layout() { | |||
| 348 | + | if !p.Hidden && (p.Rect.W < 0 || p.Rect.X < 0 || p.Rect.X > 30) { | |||
| 349 | + | t.Fatalf("%+v", p) | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | if w := m.Current(); w.pileWidth() > 10 { | |||
| 353 | + | t.Fatalf("pile %d of 30", w.pileWidth()) | |||
| 354 | + | } | |||
| 355 | + | } | |||
| 356 | + | ||||
| 357 | + | // The overview opens on what has the focus: a shown stash's card, else | |||
| 358 | + | // the column under a focused native float (hidden in the overview), and | |||
| 359 | + | // Return keeps it. Escape gives the float its focus back. | |||
| 360 | + | func TestOverviewOpensOnFocus(t *testing.T) { | |||
| 361 | + | m := pileMonitor() | |||
| 362 | + | w := m.Current() | |||
| 363 | + | w.ToggleStashVisible() | |||
| 364 | + | m.ToggleOverview() | |||
| 365 | + | if at := m.cardAt(w); at != w.stashAt { | |||
| 366 | + | t.Fatalf("opened on card %d, want the stash's %d", at, w.stashAt) | |||
| 367 | + | } | |||
| 368 | + | m.ToggleOverview() | |||
| 369 | + | ||||
| 370 | + | m = overviewMonitor() | |||
| 371 | + | w = m.Current() | |||
| 372 | + | col, _ := w.Focused() | |||
| 373 | + | w.AddFloating(9, 10, 10) | |||
| 374 | + | w.FocusID(9) | |||
| 375 | + | m.ToggleOverview() | |||
| 376 | + | if f, _ := m.Focused(); f != col || !previewOf(t, m.Layout(), col).Focused { | |||
| 377 | + | t.Fatalf("opened on %d, want column %d", f, col) | |||
| 378 | + | } | |||
| 379 | + | m.CancelOverview() | |||
| 380 | + | if f, _ := m.Focused(); f != 9 { | |||
| 381 | + | t.Fatalf("escape on %d, want the float", f) | |||
| 382 | + | } | |||
| 383 | + | w.FocusID(9) | |||
| 384 | + | m.ToggleOverview() | |||
| 385 | + | m.ToggleOverview() | |||
| 386 | + | if f, _ := m.Focused(); f != col { | |||
| 387 | + | t.Fatalf("return on %d, want column %d", f, col) | |||
| 388 | + | } | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | // Scrolling adds up to steps: what is left when the overview closes does | |||
| 392 | + | // not count when it opens again, and a wheel frame of two notches moves | |||
| 393 | + | // twice. | |||
| 394 | + | func TestOverviewScrollSteps(t *testing.T) { | |||
| 395 | + | m := overviewMonitor() | |||
| 396 | + | w := m.Current() | |||
| 397 | + | finger := func(dx float64) ports.PointerAxis { | |||
| 398 | + | return ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: dx}} | |||
| 399 | + | } | |||
| 400 | + | m.ToggleOverview() | |||
| 401 | + | start, _ := m.Focused() | |||
| 402 | + | if m.overviewScroll(finger(-50)) { | |||
| 403 | + | t.Fatal("a partial scroll moved") | |||
| 404 | + | } | |||
| 405 | + | m.CancelOverview() | |||
| 406 | + | m.ToggleOverview() | |||
| 407 | + | if m.overviewScroll(finger(-50)) { | |||
| 408 | + | t.Fatal("the last session's scroll carried over") | |||
| 409 | + | } | |||
| 410 | + | m.CancelOverview() | |||
| 411 | + | w.FocusID(3) | |||
| 412 | + | m.ToggleOverview() | |||
| 413 | + | if !m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel, Horizontal: ports.ScrollAxis{Set: true, V120: -240}}) { | |||
| 414 | + | t.Fatal("wheel did not move") | |||
| 415 | + | } | |||
| 416 | + | if f, _ := m.Focused(); f != 1 { | |||
| 417 | + | t.Fatalf("two notches from 3 landed on %d, want 1 (started on %d)", f, start) | |||
| 418 | + | } | |||
| 419 | + | } | |||
M internal/core/popup.go +2 -1
| @@ -147,7 +147,8 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | |||
| 147 | 147 | for _, pl := range sc.mon.Layout() { | |
| 148 | 148 | if pl.ID == id { | |
| 149 | 149 | // Hidden or scrolled off: not on screen, like its popups. | |
| 150 | - | if !onScreen(pl, sc.mon.Output()) { | |
| 150 | + | // A preview takes no input and shows no popups. | |
| 151 | + | if !onScreen(pl, sc.mon.Output()) || pl.Preview > 0 { | |
| 151 | 152 | return nil, Rect{}, false | |
| 152 | 153 | } | |
| 153 | 154 | return sc, c.clientRect(pl), true | |
| @@ -147,7 +147,8 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | |||||
| 147 | for _, pl := range sc.mon.Layout() { | 147 | for _, pl := range sc.mon.Layout() { | ||
| 148 | if pl.ID == id { | 148 | if pl.ID == id { | ||
| 149 | // Hidden or scrolled off: not on screen, like its popups. | 149 | // Hidden or scrolled off: not on screen, like its popups. | ||
| 150 | - | if !onScreen(pl, sc.mon.Output()) { | 150 | + | // A preview takes no input and shows no popups. |
| 151 | + | if !onScreen(pl, sc.mon.Output()) || pl.Preview > 0 { | |||
| 151 | return nil, Rect{}, false | 152 | return nil, Rect{}, false | ||
| 152 | } | 153 | } | ||
| 153 | return sc, c.clientRect(pl), true | 154 | return sc, c.clientRect(pl), true | ||
M internal/core/stash.go +10 -3
| @@ -144,15 +144,22 @@ func (w *Workspace) Click(id WindowID) { | |||
| 144 | 144 | w.FocusID(id) | |
| 145 | 145 | } | |
| 146 | 146 | ||
| 147 | + | // stashRect is where the selected stashed window is, border included. | |
| 148 | + | func (w *Workspace) stashRect() Rect { | |
| 149 | + | u := w.Usable | |
| 150 | + | fw := min(max(u.W*cmp.Or(w.stashWidth, stashWidth)/100-2*w.border, 0)+2*w.border, u.W) | |
| 151 | + | fh := min(max(u.H*stashHeight/100-2*w.border, 0)+2*w.border, u.H) | |
| 152 | + | return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh} | |
| 153 | + | } | |
| 154 | + | ||
| 147 | 155 | // stashLayout places the stash: the selected window centred, its | |
| 148 | 156 | // neighbors gap% of the usable width away, showing what the margins | |
| 149 | 157 | // leave of them, the others hidden. cover is the covering fullscreen | |
| 150 | 158 | // window, if any. | |
| 151 | 159 | func (w *Workspace) stashLayout(focusedID, cover WindowID) []Placement { | |
| 152 | 160 | u := w.Usable | |
| 153 | - | fw := min(max(u.W*cmp.Or(w.stashWidth, stashWidth)/100-2*w.border, 0)+2*w.border, u.W) | |
| 154 | - | fh := min(max(u.H*stashHeight/100-2*w.border, 0)+2*w.border, u.H) | |
| 155 | - | center := Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh} | |
| 161 | + | center := w.stashRect() | |
| 162 | + | fw, fh := center.W, center.H | |
| 156 | 163 | // Peeks stay in the margins: they never overlap the selected window. | |
| 157 | 164 | gap := u.W * w.stashGap / 100 | |
| 158 | 165 | peek := max((u.W-fw)/2-gap, 0) | |
| @@ -144,15 +144,22 @@ func (w *Workspace) Click(id WindowID) { | |||||
| 144 | w.FocusID(id) | 144 | w.FocusID(id) | ||
| 145 | } | 145 | } | ||
| 146 | 146 | ||||
| 147 | + | // stashRect is where the selected stashed window is, border included. | |||
| 148 | + | func (w *Workspace) stashRect() Rect { | |||
| 149 | + | u := w.Usable | |||
| 150 | + | fw := min(max(u.W*cmp.Or(w.stashWidth, stashWidth)/100-2*w.border, 0)+2*w.border, u.W) | |||
| 151 | + | fh := min(max(u.H*stashHeight/100-2*w.border, 0)+2*w.border, u.H) | |||
| 152 | + | return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh} | |||
| 153 | + | } | |||
| 154 | + | ||||
| 147 | // stashLayout places the stash: the selected window centred, its | 155 | // stashLayout places the stash: the selected window centred, its | ||
| 148 | // neighbors gap% of the usable width away, showing what the margins | 156 | // neighbors gap% of the usable width away, showing what the margins | ||
| 149 | // leave of them, the others hidden. cover is the covering fullscreen | 157 | // leave of them, the others hidden. cover is the covering fullscreen | ||
| 150 | // window, if any. | 158 | // window, if any. | ||
| 151 | func (w *Workspace) stashLayout(focusedID, cover WindowID) []Placement { | 159 | func (w *Workspace) stashLayout(focusedID, cover WindowID) []Placement { | ||
| 152 | u := w.Usable | 160 | u := w.Usable | ||
| 153 | - | fw := min(max(u.W*cmp.Or(w.stashWidth, stashWidth)/100-2*w.border, 0)+2*w.border, u.W) | 161 | + | center := w.stashRect() |
| 154 | - | fh := min(max(u.H*stashHeight/100-2*w.border, 0)+2*w.border, u.H) | 162 | + | fw, fh := center.W, center.H |
| 155 | - | center := Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh} | |||
| 156 | // Peeks stay in the margins: they never overlap the selected window. | 163 | // Peeks stay in the margins: they never overlap the selected window. | ||
| 157 | gap := u.W * w.stashGap / 100 | 164 | gap := u.W * w.stashGap / 100 | ||
| 158 | peek := max((u.W-fw)/2-gap, 0) | 165 | peek := max((u.W-fw)/2-gap, 0) | ||
M internal/core/swipe_test.go +130 -1
| @@ -76,7 +76,7 @@ func (r *swipeRig) advance(d time.Duration) { | |||
| 76 | 76 | // begin starts a swipe; nothing moves until it picks an axis. | |
| 77 | 77 | func (r *swipeRig) begin() { | |
| 78 | 78 | r.at += time.Second | |
| 79 | - | r.input <- ports.SwipeBegin{Time: r.at} | |
| 79 | + | r.input <- ports.SwipeBegin{Fingers: 3, Time: r.at} | |
| 80 | 80 | } | |
| 81 | 81 | ||
| 82 | 82 | // move sends one swipe update and returns the scene it produced. | |
| @@ -557,3 +557,132 @@ func TestKeyWithoutSlideMovesAtOnce(t *testing.T) { | |||
| 557 | 557 | t.Fatalf("column 1 at %d", got.X) | |
| 558 | 558 | } | |
| 559 | 559 | } | |
| 560 | + | ||
| 561 | + | // In the overview a three-finger swipe does not slide the view: nothing | |
| 562 | + | // is drawn while the fingers move, and when they lift the selection moves | |
| 563 | + | // one column, the previews where they were. | |
| 564 | + | func TestSwipeInOverview(t *testing.T) { | |
| 565 | + | r := startSwipe(t, nil) | |
| 566 | + | threeColumns(t, r) | |
| 567 | + | opened := r.key(t, "o", ports.ModAlt)[0] | |
| 568 | + | selected := func(s ports.Scene) ports.WindowID { | |
| 569 | + | for _, w := range s.Windows { | |
| 570 | + | if w.Focused && w.Preview > 0 { | |
| 571 | + | return w.ID | |
| 572 | + | } | |
| 573 | + | } | |
| 574 | + | return 0 | |
| 575 | + | } | |
| 576 | + | r.begin() | |
| 577 | + | for range 3 { | |
| 578 | + | r.at += 8 * time.Millisecond | |
| 579 | + | r.input <- ports.SwipeUpdate{DX: -40, Time: r.at} | |
| 580 | + | } | |
| 581 | + | s := r.end(t, false) | |
| 582 | + | if id := selected(s); id != 2 { | |
| 583 | + | t.Fatalf("selected %d after a left swipe, want 2", id) | |
| 584 | + | } | |
| 585 | + | before, _ := rectOf(opened, 2) | |
| 586 | + | if after, _ := rectOf(s, 2); before != after { | |
| 587 | + | t.Fatalf("preview moved: %+v then %+v", before, after) | |
| 588 | + | } | |
| 589 | + | // A small swipe does nothing: the l after it moves from 2 to 3. | |
| 590 | + | r.begin() | |
| 591 | + | r.at += 8 * time.Millisecond | |
| 592 | + | r.input <- ports.SwipeUpdate{DX: -20, Time: r.at} | |
| 593 | + | if id := selected(r.end(t, false)); id != 2 { | |
| 594 | + | t.Fatalf("selected %d after a small swipe", id) | |
| 595 | + | } | |
| 596 | + | if id := selected(r.key(t, "l", 0)[0]); id != 3 { | |
| 597 | + | t.Fatalf("selected %d after a small swipe and l, want 3", id) | |
| 598 | + | } | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | // A four-finger swipe up opens the overview and down closes it on the | |
| 602 | + | // selection, whatever natural-scroll says (as niri). A short one or one | |
| 603 | + | // sideways does nothing. | |
| 604 | + | func TestFourFingerSwipeOverview(t *testing.T) { | |
| 605 | + | for _, natural := range []bool{false, true} { | |
| 606 | + | r := startSwipe(t, func(c *ports.Config) { c.Touchpad.NaturalScroll = natural }) | |
| 607 | + | threeColumns(t, r) | |
| 608 | + | four := func(dx, dy float64, n int) ports.Scene { | |
| 609 | + | r.at += time.Second | |
| 610 | + | r.input <- ports.SwipeBegin{Fingers: 4, Time: r.at} | |
| 611 | + | for range n { | |
| 612 | + | r.at += 8 * time.Millisecond | |
| 613 | + | r.input <- ports.SwipeUpdate{DX: dx, DY: dy, Time: r.at} | |
| 614 | + | } | |
| 615 | + | return r.end(t, false) | |
| 616 | + | } | |
| 617 | + | inOverview := func(s ports.Scene) bool { | |
| 618 | + | for _, w := range s.Windows { | |
| 619 | + | if w.Preview > 0 { | |
| 620 | + | return true | |
| 621 | + | } | |
| 622 | + | } | |
| 623 | + | return false | |
| 624 | + | } | |
| 625 | + | if inOverview(four(0, -5, 4)) { | |
| 626 | + | t.Fatalf("natural=%v: short swipe opened the overview", natural) | |
| 627 | + | } | |
| 628 | + | if inOverview(four(-40, 0, 10)) { | |
| 629 | + | t.Fatalf("natural=%v: sideways swipe opened the overview", natural) | |
| 630 | + | } | |
| 631 | + | if !inOverview(four(0, -40, 10)) { | |
| 632 | + | t.Fatalf("natural=%v: swipe up did not open the overview", natural) | |
| 633 | + | } | |
| 634 | + | if !inOverview(four(0, -40, 10)) { | |
| 635 | + | t.Fatalf("natural=%v: a second swipe up closed it", natural) | |
| 636 | + | } | |
| 637 | + | r.key(t, "h", 0) | |
| 638 | + | s := four(0, 40, 10) | |
| 639 | + | if inOverview(s) { | |
| 640 | + | t.Fatalf("natural=%v: swipe down did not close the overview", natural) | |
| 641 | + | } | |
| 642 | + | if id := r.focused(t); id != 2 { | |
| 643 | + | t.Fatalf("natural=%v: closed on %d, want the selection 2", natural, id) | |
| 644 | + | } | |
| 645 | + | } | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | // Opening the overview during a column swipe drops the swipe: the rest | |
| 649 | + | // of it neither slides the columns under the overview nor lands them, | |
| 650 | + | // also once the overview closed again (escape before the rest). | |
| 651 | + | func TestOverviewOpenedMidSwipeDropsIt(t *testing.T) { | |
| 652 | + | for _, closeFirst := range []bool{false, true} { | |
| 653 | + | r := startSwipe(t, nil) | |
| 654 | + | settled := threeColumns(t, r) | |
| 655 | + | r.begin() | |
| 656 | + | r.move(t, -40, 0) | |
| 657 | + | r.move(t, -40, 0) | |
| 658 | + | r.key(t, "o", ports.ModAlt) | |
| 659 | + | if closeFirst { | |
| 660 | + | r.key(t, "Escape", 0) | |
| 661 | + | } | |
| 662 | + | for range 20 { | |
| 663 | + | r.at += 8 * time.Millisecond | |
| 664 | + | r.input <- ports.SwipeUpdate{DX: -80, Time: r.at} | |
| 665 | + | } | |
| 666 | + | r.input <- ports.SwipeEnd{Time: r.at} | |
| 667 | + | if !closeFirst { | |
| 668 | + | r.input <- ports.KeyEvent{Keysym: "Escape", Pressed: true} | |
| 669 | + | } | |
| 670 | + | // Past any spring: the view shows where it settled. | |
| 671 | + | r.advance(5 * time.Second) | |
| 672 | + | r.frames <- ports.OutputFrame{Output: wide.Name} | |
| 673 | + | s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { | |
| 674 | + | for _, w := range s.Windows { | |
| 675 | + | if w.Preview > 0 { | |
| 676 | + | return false | |
| 677 | + | } | |
| 678 | + | } | |
| 679 | + | return true | |
| 680 | + | }) | |
| 681 | + | for _, id := range []ports.WindowID{1, 2, 3} { | |
| 682 | + | before, _ := rectOf(settled, id) | |
| 683 | + | if after, _ := rectOf(s, id); before != after { | |
| 684 | + | t.Fatalf("closeFirst=%v: window %d moved: %+v then %+v", closeFirst, id, before, after) | |
| 685 | + | } | |
| 686 | + | } | |
| 687 | + | } | |
| 688 | + | } | |
| @@ -76,7 +76,7 @@ func (r *swipeRig) advance(d time.Duration) { | |||||
| 76 | // begin starts a swipe; nothing moves until it picks an axis. | 76 | // begin starts a swipe; nothing moves until it picks an axis. | ||
| 77 | func (r *swipeRig) begin() { | 77 | func (r *swipeRig) begin() { | ||
| 78 | r.at += time.Second | 78 | r.at += time.Second | ||
| 79 | - | r.input <- ports.SwipeBegin{Time: r.at} | 79 | + | r.input <- ports.SwipeBegin{Fingers: 3, Time: r.at} |
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
| 82 | // move sends one swipe update and returns the scene it produced. | 82 | // move sends one swipe update and returns the scene it produced. | ||
| @@ -557,3 +557,132 @@ func TestKeyWithoutSlideMovesAtOnce(t *testing.T) { | |||||
| 557 | t.Fatalf("column 1 at %d", got.X) | 557 | t.Fatalf("column 1 at %d", got.X) | ||
| 558 | } | 558 | } | ||
| 559 | } | 559 | } | ||
| 560 | + | ||||
| 561 | + | // In the overview a three-finger swipe does not slide the view: nothing | |||
| 562 | + | // is drawn while the fingers move, and when they lift the selection moves | |||
| 563 | + | // one column, the previews where they were. | |||
| 564 | + | func TestSwipeInOverview(t *testing.T) { | |||
| 565 | + | r := startSwipe(t, nil) | |||
| 566 | + | threeColumns(t, r) | |||
| 567 | + | opened := r.key(t, "o", ports.ModAlt)[0] | |||
| 568 | + | selected := func(s ports.Scene) ports.WindowID { | |||
| 569 | + | for _, w := range s.Windows { | |||
| 570 | + | if w.Focused && w.Preview > 0 { | |||
| 571 | + | return w.ID | |||
| 572 | + | } | |||
| 573 | + | } | |||
| 574 | + | return 0 | |||
| 575 | + | } | |||
| 576 | + | r.begin() | |||
| 577 | + | for range 3 { | |||
| 578 | + | r.at += 8 * time.Millisecond | |||
| 579 | + | r.input <- ports.SwipeUpdate{DX: -40, Time: r.at} | |||
| 580 | + | } | |||
| 581 | + | s := r.end(t, false) | |||
| 582 | + | if id := selected(s); id != 2 { | |||
| 583 | + | t.Fatalf("selected %d after a left swipe, want 2", id) | |||
| 584 | + | } | |||
| 585 | + | before, _ := rectOf(opened, 2) | |||
| 586 | + | if after, _ := rectOf(s, 2); before != after { | |||
| 587 | + | t.Fatalf("preview moved: %+v then %+v", before, after) | |||
| 588 | + | } | |||
| 589 | + | // A small swipe does nothing: the l after it moves from 2 to 3. | |||
| 590 | + | r.begin() | |||
| 591 | + | r.at += 8 * time.Millisecond | |||
| 592 | + | r.input <- ports.SwipeUpdate{DX: -20, Time: r.at} | |||
| 593 | + | if id := selected(r.end(t, false)); id != 2 { | |||
| 594 | + | t.Fatalf("selected %d after a small swipe", id) | |||
| 595 | + | } | |||
| 596 | + | if id := selected(r.key(t, "l", 0)[0]); id != 3 { | |||
| 597 | + | t.Fatalf("selected %d after a small swipe and l, want 3", id) | |||
| 598 | + | } | |||
| 599 | + | } | |||
| 600 | + | ||||
| 601 | + | // A four-finger swipe up opens the overview and down closes it on the | |||
| 602 | + | // selection, whatever natural-scroll says (as niri). A short one or one | |||
| 603 | + | // sideways does nothing. | |||
| 604 | + | func TestFourFingerSwipeOverview(t *testing.T) { | |||
| 605 | + | for _, natural := range []bool{false, true} { | |||
| 606 | + | r := startSwipe(t, func(c *ports.Config) { c.Touchpad.NaturalScroll = natural }) | |||
| 607 | + | threeColumns(t, r) | |||
| 608 | + | four := func(dx, dy float64, n int) ports.Scene { | |||
| 609 | + | r.at += time.Second | |||
| 610 | + | r.input <- ports.SwipeBegin{Fingers: 4, Time: r.at} | |||
| 611 | + | for range n { | |||
| 612 | + | r.at += 8 * time.Millisecond | |||
| 613 | + | r.input <- ports.SwipeUpdate{DX: dx, DY: dy, Time: r.at} | |||
| 614 | + | } | |||
| 615 | + | return r.end(t, false) | |||
| 616 | + | } | |||
| 617 | + | inOverview := func(s ports.Scene) bool { | |||
| 618 | + | for _, w := range s.Windows { | |||
| 619 | + | if w.Preview > 0 { | |||
| 620 | + | return true | |||
| 621 | + | } | |||
| 622 | + | } | |||
| 623 | + | return false | |||
| 624 | + | } | |||
| 625 | + | if inOverview(four(0, -5, 4)) { | |||
| 626 | + | t.Fatalf("natural=%v: short swipe opened the overview", natural) | |||
| 627 | + | } | |||
| 628 | + | if inOverview(four(-40, 0, 10)) { | |||
| 629 | + | t.Fatalf("natural=%v: sideways swipe opened the overview", natural) | |||
| 630 | + | } | |||
| 631 | + | if !inOverview(four(0, -40, 10)) { | |||
| 632 | + | t.Fatalf("natural=%v: swipe up did not open the overview", natural) | |||
| 633 | + | } | |||
| 634 | + | if !inOverview(four(0, -40, 10)) { | |||
| 635 | + | t.Fatalf("natural=%v: a second swipe up closed it", natural) | |||
| 636 | + | } | |||
| 637 | + | r.key(t, "h", 0) | |||
| 638 | + | s := four(0, 40, 10) | |||
| 639 | + | if inOverview(s) { | |||
| 640 | + | t.Fatalf("natural=%v: swipe down did not close the overview", natural) | |||
| 641 | + | } | |||
| 642 | + | if id := r.focused(t); id != 2 { | |||
| 643 | + | t.Fatalf("natural=%v: closed on %d, want the selection 2", natural, id) | |||
| 644 | + | } | |||
| 645 | + | } | |||
| 646 | + | } | |||
| 647 | + | ||||
| 648 | + | // Opening the overview during a column swipe drops the swipe: the rest | |||
| 649 | + | // of it neither slides the columns under the overview nor lands them, | |||
| 650 | + | // also once the overview closed again (escape before the rest). | |||
| 651 | + | func TestOverviewOpenedMidSwipeDropsIt(t *testing.T) { | |||
| 652 | + | for _, closeFirst := range []bool{false, true} { | |||
| 653 | + | r := startSwipe(t, nil) | |||
| 654 | + | settled := threeColumns(t, r) | |||
| 655 | + | r.begin() | |||
| 656 | + | r.move(t, -40, 0) | |||
| 657 | + | r.move(t, -40, 0) | |||
| 658 | + | r.key(t, "o", ports.ModAlt) | |||
| 659 | + | if closeFirst { | |||
| 660 | + | r.key(t, "Escape", 0) | |||
| 661 | + | } | |||
| 662 | + | for range 20 { | |||
| 663 | + | r.at += 8 * time.Millisecond | |||
| 664 | + | r.input <- ports.SwipeUpdate{DX: -80, Time: r.at} | |||
| 665 | + | } | |||
| 666 | + | r.input <- ports.SwipeEnd{Time: r.at} | |||
| 667 | + | if !closeFirst { | |||
| 668 | + | r.input <- ports.KeyEvent{Keysym: "Escape", Pressed: true} | |||
| 669 | + | } | |||
| 670 | + | // Past any spring: the view shows where it settled. | |||
| 671 | + | r.advance(5 * time.Second) | |||
| 672 | + | r.frames <- ports.OutputFrame{Output: wide.Name} | |||
| 673 | + | s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { | |||
| 674 | + | for _, w := range s.Windows { | |||
| 675 | + | if w.Preview > 0 { | |||
| 676 | + | return false | |||
| 677 | + | } | |||
| 678 | + | } | |||
| 679 | + | return true | |||
| 680 | + | }) | |||
| 681 | + | for _, id := range []ports.WindowID{1, 2, 3} { | |||
| 682 | + | before, _ := rectOf(settled, id) | |||
| 683 | + | if after, _ := rectOf(s, id); before != after { | |||
| 684 | + | t.Fatalf("closeFirst=%v: window %d moved: %+v then %+v", closeFirst, id, before, after) | |||
| 685 | + | } | |||
| 686 | + | } | |||
| 687 | + | } | |||
| 688 | + | } | |||
M internal/ports/messages.go +9 -3
| @@ -368,9 +368,12 @@ type PointerAxis struct { | |||
| 368 | 368 | ||
| 369 | 369 | func (PointerAxis) inputEvent() {} | |
| 370 | 370 | ||
| 371 | - | // SwipeBegin starts a three-finger touchpad swipe. Time is the device | |
| 372 | - | // timestamp (CLOCK_MONOTONIC), shared by the updates and the end. | |
| 373 | - | type SwipeBegin struct{ Time time.Duration } | |
| 371 | + | // SwipeBegin starts a three- or four-finger touchpad swipe. Time is the | |
| 372 | + | // device timestamp (CLOCK_MONOTONIC), shared by the updates and the end. | |
| 373 | + | type SwipeBegin struct { | |
| 374 | + | Fingers int | |
| 375 | + | Time time.Duration | |
| 376 | + | } | |
| 374 | 377 | ||
| 375 | 378 | // SwipeUpdate moves the fingers by the unaccelerated deltas DX, DY | |
| 376 | 379 | // (touchpad units, as libinput reports them, not natural-scroll inverted). | |
| @@ -1021,6 +1024,9 @@ type SceneWindow struct { | |||
| 1021 | 1024 | // Dim darkens the window, border included, with black at this | |
| 1022 | 1025 | // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing. | |
| 1023 | 1026 | Dim float64 | |
| 1027 | + | // Preview, above 0, draws the window's surfaces that much smaller in | |
| 1028 | + | // Rect (an overview thumbnail): the client keeps its size. | |
| 1029 | + | Preview float64 | |
| 1024 | 1030 | // Popups are drawn from their content only: no border, no background. | |
| 1025 | 1031 | Popup bool | |
| 1026 | 1032 | // OverLayers popups hang from a layer surface: drawn over the top and | |
| @@ -368,9 +368,12 @@ type PointerAxis struct { | |||||
| 368 | 368 | ||||
| 369 | func (PointerAxis) inputEvent() {} | 369 | func (PointerAxis) inputEvent() {} | ||
| 370 | 370 | ||||
| 371 | - | // SwipeBegin starts a three-finger touchpad swipe. Time is the device | 371 | + | // SwipeBegin starts a three- or four-finger touchpad swipe. Time is the |
| 372 | - | // timestamp (CLOCK_MONOTONIC), shared by the updates and the end. | 372 | + | // device timestamp (CLOCK_MONOTONIC), shared by the updates and the end. |
| 373 | - | type SwipeBegin struct{ Time time.Duration } | 373 | + | type SwipeBegin struct { |
| 374 | + | Fingers int | |||
| 375 | + | Time time.Duration | |||
| 376 | + | } | |||
| 374 | 377 | ||||
| 375 | // SwipeUpdate moves the fingers by the unaccelerated deltas DX, DY | 378 | // SwipeUpdate moves the fingers by the unaccelerated deltas DX, DY | ||
| 376 | // (touchpad units, as libinput reports them, not natural-scroll inverted). | 379 | // (touchpad units, as libinput reports them, not natural-scroll inverted). | ||
| @@ -1021,6 +1024,9 @@ type SceneWindow struct { | |||||
| 1021 | // Dim darkens the window, border included, with black at this | 1024 | // Dim darkens the window, border included, with black at this | ||
| 1022 | // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing. | 1025 | // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing. | ||
| 1023 | Dim float64 | 1026 | Dim float64 | ||
| 1027 | + | // Preview, above 0, draws the window's surfaces that much smaller in | |||
| 1028 | + | // Rect (an overview thumbnail): the client keeps its size. | |||
| 1029 | + | Preview float64 | |||
| 1024 | // Popups are drawn from their content only: no border, no background. | 1030 | // Popups are drawn from their content only: no border, no background. | ||
| 1025 | Popup bool | 1031 | Popup bool | ||
| 1026 | // OverLayers popups hang from a layer surface: drawn over the top and | 1032 | // OverLayers popups hang from a layer surface: drawn over the top and | ||