detached 56de283

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 UnverifiedB5690EEEBB952194 The 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
108108 | `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 |
109109 | `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 |
110110 | `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 |
111112 | `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 |
112113 | `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 |
113114 | `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
131132
132133 Native floating windows (dialogs, file pickers) are not in the stash: they stay centred above it.
133134
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+
134152 ### Default binds
135153
136154 | Keys | Action |
@@ -155,6 +173,7 @@ Native floating windows (dialogs, file pickers) are not in the stash: they stay
155173 | `cmd+shift+f` | `toggle-fullscreen` |
156174 | `cmd+s` | `toggle-window-stash` |
157175 | `cmd+shift+s` | `toggle-stash-visible` |
176+| `cmd+o` | `toggle-overview` |
158177 | `cmd+q` | `close-window` |
159178 | `ctrl+alt+backspace` | `quit` |
160179 | `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 binds152 ### 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
1818 - `sleep 1s`
1919 - `move <x> <y>` (output coordinates)
2020 - `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
2222
2323 `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:
2424
@@ -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
127127 bind.cmd+shift+f = toggle-fullscreen
128128 bind.cmd+s = toggle-window-stash
129129 bind.cmd+shift+s = toggle-stash-visible
130+bind.cmd+o = toggle-overview
130131 bind.cmd+q = close-window
131132 bind.ctrl+alt+backspace = quit
132133 bind.ctrl+cmd+space = spawn fuzzel
@@ -127,6 +127,7 @@ bind.cmd+f = maximize-column
127 bind.cmd+shift+f = toggle-fullscreen127 bind.cmd+shift+f = toggle-fullscreen
128 bind.cmd+s = toggle-window-stash128 bind.cmd+s = toggle-window-stash
129 bind.cmd+shift+s = toggle-stash-visible129 bind.cmd+shift+s = toggle-stash-visible
130+bind.cmd+o = toggle-overview
130 bind.cmd+q = close-window131 bind.cmd+q = close-window
131 bind.ctrl+alt+backspace = quit132 bind.ctrl+alt+backspace = quit
132 bind.ctrl+cmd+space = spawn fuzzel133 bind.ctrl+cmd+space = spawn fuzzel
M internal/adapters/config/config.go +2 -1
@@ -34,6 +34,7 @@ var defaultBinds = []struct{ combo, action string }{
3434 {"cmd+shift+f", "toggle-fullscreen"},
3535 {"cmd+s", "toggle-window-stash"},
3636 {"cmd+shift+s", "toggle-stash-visible"},
37+ {"cmd+o", "toggle-overview"},
3738 {"cmd+q", "close-window"},
3839 {"ctrl+alt+backspace", "quit"},
3940 {"ctrl+cmd+space", "spawn fuzzel"},
@@ -46,7 +47,7 @@ var defaultBinds = []struct{ combo, action string }{
4647 {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"},
4748 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},
4849 }
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}
5051 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
5152 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
5253
@@ -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) {
2222 func TestDefaultsAndLoad(t *testing.T) {
2323 t.Setenv("TERMINAL", "")
2424 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 {
2626 t.Fatalf("defaults: %+v", d)
2727 }
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 {
2929 t.Fatal(d.Binds)
3030 }
3131 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
7575 c := surfaces[w.ID]
7676 // A viewport crop or PQ content is composed: the plane would show
7777 // 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() {
8081 pick = w
8182 continue
8283 }
@@ -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 show76 // 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 dimmed77 // 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 = w81 pick = w
81 continue82 continue
82 }83 }
M internal/adapters/drm/overlay_test.go +6 -0
@@ -84,6 +84,12 @@ func TestOverlayCandidate(t *testing.T) {
8484 if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {
8585 t.Fatalf("dimmed window reason %q", reason)
8686 }
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+ }
8793 // A viewport crop is composed: the plane would show the whole buffer.
8894 s, c = overlayScene()
8995 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
125125 log.Warn().Str("line", line).Msg("invalid move")
126126 continue
127127 }
128- if len(fields) > 0 && fields[0] == "swipe" {
128+ if len(fields) > 0 && (fields[0] == "swipe" || fields[0] == "swipe4") {
129129 dirs := map[string][2]float64{"up": {0, -1}, "down": {0, 1}, "left": {-1, 0}, "right": {1, 0}}
130130 d, ok := [2]float64{}, len(fields) == 2
131131 if ok {
@@ -135,7 +135,11 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
135135 log.Warn().Str("line", line).Msg("invalid swipe")
136136 continue
137137 }
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()) {
139143 if err := sendPointer(ev); err != nil {
140144 return err
141145 }
@@ -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 continue126 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) == 2130 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 continue136 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 err144 return err
141 }145 }
M internal/adapters/headlessinput/input_test.go +11 -2
@@ -62,7 +62,7 @@ func TestPointerScript(t *testing.T) {
6262 t.Skip(err)
6363 }
6464 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"} {
6666 script <- line
6767 }
6868 close(script)
@@ -70,7 +70,7 @@ func TestPointerScript(t *testing.T) {
7070 if err := Run(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input")); err != nil {
7171 t.Fatal(err)
7272 }
73- if len(input) != 5+swipeSteps+2 {
73+ if len(input) != 5+2*(swipeSteps+2) {
7474 t.Fatalf("events: %d", len(input))
7575 }
7676 m := (<-input).(ports.PointerMotion)
@@ -84,6 +84,9 @@ func TestPointerScript(t *testing.T) {
8484 }
8585 }
8686 begin := (<-input).(ports.SwipeBegin)
87+ if begin.Fingers != 3 {
88+ t.Fatal(begin)
89+ }
8790 last := begin.Time
8891 for range swipeSteps {
8992 u := (<-input).(ports.SwipeUpdate)
@@ -95,4 +98,10 @@ func TestPointerScript(t *testing.T) {
9598 if e := (<-input).(ports.SwipeEnd); e.Cancelled || e.Time != last {
9699 t.Fatal(e)
97100 }
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+ }
98107 }
@@ -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 <- line66 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.Time90 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 (
1515 swipeEvery = 8 * time.Millisecond
1616 )
1717
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}}
2222 at := start
2323 for range swipeSteps {
2424 at += swipeEvery
@@ -15,10 +15,10 @@ const (
15 swipeEvery = 8 * time.Millisecond15 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 := start22 at := start
23 for range swipeSteps {23 for range swipeSteps {
24 at += swipeEvery24 at += swipeEvery
M internal/adapters/libinput/libinput.go +4 -7
@@ -376,22 +376,19 @@ func (s *inputState) release() {
376376 clear(s.devices)
377377 }
378378
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
384381 // the fingers. owner is the touchpad of the swipe in progress (0: none);
385382 // another touchpad's swipe is ignored until it ends, so core never sees
386383 // two streams interleaved.
387384 type swipes struct{ owner uintptr }
388385
389386 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 {
391388 return nil
392389 }
393390 s.owner = dev
394- return ports.SwipeBegin{Time: at}
391+ return ports.SwipeBegin{Fingers: fingers, Time: at}
395392 }
396393
397394 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; other379+// 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 sees382 // 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 nil388 return nil
392 }389 }
393 s.owner = dev390 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) {
5555 func TestSwipesStreamThreeFingers(t *testing.T) {
5656 var s swipes
5757 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)
6060 }
6161 if ev := s.update(pad, 1, 1, time.Second); ev != nil {
6262 t.Fatalf("update without a swipe: %v", ev)
6363 }
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}) {
6565 t.Fatalf("begin: %v", ev)
6666 }
6767 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) {
7878 }
7979 }
8080
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+
8191 func TestAccelProfile(t *testing.T) {
8292 if accelProfile(ports.AccelFlat) != 1 || accelProfile(ports.AccelAdaptive) != 2 {
8393 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 swipes56 var s swipes
57 const pad, other = 1, 257 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 (
4646 flagExtendedLinear = 8
4747 flagYUV = 16
4848 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
4952 )
5053
5154 // 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
353356 if float64(full.Dx()) == cw && float64(full.Dy()) == ch && cw == float64(int(cw)) && ch == float64(int(ch)) {
354357 flags |= flagExact
355358 }
359+ if float64(full.Dx()) < cw/2 || float64(full.Dy()) < ch/2 {
360+ flags |= flagSmooth
361+ }
356362 dr.pc.misc = [4]uint32{mode, flags, uint32(r.width), uint32(r.height)}
357363 return dr
358364 }
@@ -46,6 +46,9 @@ const (
46 flagExtendedLinear = 846 flagExtendedLinear = 8
47 flagYUV = 1647 flagYUV = 16
48 flagP010 = 3248 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 |= flagExact357 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 dr363 return dr
358 }364 }
M internal/adapters/vulkan/renderer_test.go +66 -0
@@ -283,6 +283,72 @@ func TestRendererScale(t *testing.T) {
283283 }
284284 }
285285
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+
286352 // solidContent is a w×h B8G8R8A8 buffer of one color.
287353 func solidContent(t *testing.T, w, h int, c color.RGBA) ports.SurfaceContent {
288354 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 {
1616 contents map[ports.WindowID]ports.SurfaceContent
1717 dmg *damageRegion
1818 // 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
2023 bounds image.Rectangle
2124 draws []draw
2225 }
@@ -24,7 +27,7 @@ type sceneWalk struct {
2427 // draws walks the scene into quads in paint order.
2528 func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, dmg *damageRegion) []draw {
2629 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]}
2831 r.scratchCovers = r.scratchCovers[:0]
2932 if w.scale <= 0 {
3033 w.scale = 1
@@ -44,6 +47,12 @@ func (w *sceneWalk) physRect(x, y, width, height int) image.Rectangle {
4447 return image.Rect(w.phys(x), w.phys(y), w.phys(x+width), w.phys(y+height))
4548 }
4649
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+
4756 // fill draws a solid rect (physical pixels).
4857 func (w *sceneWalk) fill(rect image.Rectangle, c [3]uint8) {
4958 rect = rect.Intersect(w.bounds)
@@ -103,7 +112,11 @@ func (w *sceneWalk) windows() {
103112 // Until its first buffer, a window shows the background: no flash.
104113 w.fill(body, parseColor(w.s.Background))
105114 if !content.Empty() {
115+ if win.Preview > 0 {
116+ w.zoom = win.Preview
117+ }
106118 w.place(win.ID, &content, c.X, c.Y, c.W, c.H)
119+ w.zoom = 1
107120 }
108121 if win.Floating {
109122 w.separators(win.ID)
@@ -152,17 +165,22 @@ func (w *sceneWalk) popups(overLayers bool) {
152165 // surface of the tree is keyed by its stable surface identity.
153166 func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y, width, height int) {
154167 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 }
156172 covers := w.opaqueChildren(content, ox, oy, clip)
157173 for i := range content.Children {
158174 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)
160177 }
161178 }
162179 w.surface(content, ox, oy, clip, shmKey{id, content.Surface}, content.Version, covers, true)
163180 for i := range content.Children {
164181 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)
166184 }
167185 }
168186 }
@@ -172,14 +190,14 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y
172190 // they hide the surfaces below them. A child whose buffer will not draw is
173191 // left out, so the surface below it still shows: a dmabuf that fails to
174192 // 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 {
176194 covers := w.r.scratchCovers[:0]
177195 for i := range content.Children {
178196 ch := &content.Children[i]
179197 if ch.Below || !ch.Opaque {
180198 continue
181199 }
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)
183201 if !ok || !w.drawable(&ch.SurfaceContent) {
184202 continue
185203 }
@@ -205,9 +223,9 @@ func (w *sceneWalk) drawable(c *ports.SurfaceContent) bool {
205223 }
206224
207225 // 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) {
211229 lw, lh := content.LogicalW, content.LogicalH
212230 if lw <= 0 || lh <= 0 {
213231 lw, lh = content.Width, content.Height
@@ -218,7 +236,7 @@ func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y int, clip i
218236 if content.DMABuf == nil && (content.SHM.Stride < content.Width*4 || content.SHM.Offset < 0) {
219237 return full, dst, false
220238 }
221- full = w.physRect(x, y, lw, lh)
239+ full = w.physRectF(x, y, float64(lw)*w.zoom, float64(lh)*w.zoom)
222240 // A buffer drawn at the physical size (fractional-scale clients round
223241 // w*scale, we round per edge) is copied 1:1, never resampled for 1px.
224242 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
239257 // surface draws one surface buffer with its origin at (x, y) logical,
240258 // clipped to clip (logical), unless an opaque surface above it (covers,
241259 // 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) {
243261 full, dst, ok := w.surfaceRects(content, x, y, clip)
244262 if !ok {
245263 return
@@ -16,7 +16,10 @@ type sceneWalk struct {
16 contents map[ports.WindowID]ports.SurfaceContent16 contents map[ports.WindowID]ports.SurfaceContent
17 dmg *damageRegion17 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 float6419+ 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.Rectangle23 bounds image.Rectangle
21 draws []draw24 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 scenes29 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 = 133 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.Y168+ // 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 is190 // 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 to191 // 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 continue198 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 continue202 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 origin225 // 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 false226+// 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.LogicalH229 lw, lh := content.LogicalW, content.LogicalH
212 if lw <= 0 || lh <= 0 {230 if lw <= 0 || lh <= 0 {
213 lw, lh = content.Width, content.Height231 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, false237 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 round240 // 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 return263 return
M internal/adapters/vulkan/shaders/compose.frag +29 -13
@@ -29,6 +29,9 @@ const uint flagPQ = 4u;
2929 const uint flagExtendedLinear = 8u;
3030 const uint flagYUV = 16u;
3131 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;
3235
3336 // PQ values are absolute nits. BT.2020 is converted in linear light to
3437 // BT.709, then clipped to the 8-bit sRGB intermediate. Highlights above
@@ -84,26 +87,39 @@ vec4 texel(ivec2 p) {
8487 return unpackUnorm4x8(pixels[d.buf.x + uint(p.y) * d.buf.y + uint(p.x)]).zyxw;
8588 }
8689
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+
87110 void main() {
88111 vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy;
89112 bool exact = (d.misc.y & flagExact) != 0u;
90113 vec4 c;
91114 if (d.misc.x == modeSolid) {
92115 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;
101121 } 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);
107123 }
108124 if ((d.misc.y & flagOpaque) != 0u) {
109125 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 to36 // 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 above37 // 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;
2929 const uint flagExtendedLinear = 8u;
3030 const uint flagYUV = 16u;
3131 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;
3235
3336 // The HDR composition target holds linear BT.709 in SDR-white units.
3437 vec3 decodeSRGB(vec3 v) {
@@ -83,26 +86,39 @@ vec4 texel(ivec2 p) {
8386 return unpackUnorm4x8(pixels[d.buf.x + uint(p.y) * d.buf.y + uint(p.x)]).zyxw;
8487 }
8588
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+
86109 void main() {
87110 vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy;
88111 bool exact = (d.misc.y & flagExact) != 0u;
89112 vec4 c;
90113 if (d.misc.x == modeSolid) {
91114 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;
100120 } 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);
106122 }
107123 if ((d.misc.y & flagOpaque) != 0u) {
108124 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 (
2020 ActionToggleFullscreen Action = "toggle-fullscreen"
2121 ActionToggleWindowStash Action = "toggle-window-stash"
2222 ActionToggleStashVisible Action = "toggle-stash-visible"
23+ ActionToggleOverview Action = "toggle-overview"
2324 ActionCloseWindow Action = "close-window"
2425 ActionQuit Action = "quit"
2526 // Workspaces stack vertically; up/down stop at the ends.
@@ -200,6 +201,10 @@ func (c *Core) applyAction(a Action) Effect {
200201
201202 // Apply runs a bind action on the monitor.
202203 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+ }
203208 if n, op, ok := WorkspaceArg(a); ok {
204209 if op == FocusWorkspace {
205210 m.FocusNumber(n)
@@ -233,6 +238,9 @@ func (m *Monitor) Apply(a Action) Effect {
233238 case ActionToggleStashVisible:
234239 m.Current().ToggleStashVisible()
235240 return Effect{}
241+ case ActionToggleOverview:
242+ m.ToggleOverview()
243+ return Effect{}
236244 case ActionMaximizeColumn:
237245 // A client that made itself fullscreen (Wine at monitor size) goes
238246 // 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) goes245 // 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 {
213213 continue
214214 }
215215 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:
217217 default:
218218 return fmt.Errorf("invalid action %q", a)
219219 }
@@ -479,11 +479,15 @@ func (c *Core) publish(ctx context.Context) error {
479479 scene.Dim = floatDim(layout, o, c.cfg.Floating.Dim)
480480 // Only the focused output lights the focused window's lines.
481481 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+ }
482486 for _, p := range layout {
483487 alive[p.ID] = true
484488 // Only the focused output has an activated window.
485489 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}
487491 if p.Peek {
488492 sw.Dim = c.cfg.Stash.Dim
489493 }
@@ -508,6 +512,23 @@ func (c *Core) publish(ctx context.Context) error {
508512 c.sent[p.ID] = v
509513 continue
510514 }
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+ }
511532 r := c.clientRect(p)
512533 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)}
513534 if v.Floating && !sc.mon.Current().imposedFloat(p.ID) {
@@ -632,7 +653,7 @@ func (c *Core) resolveConstraint() {
632653 if id := c.constrained.ID; id != 0 {
633654 if s, _ := c.screenOf(id); s != nil {
634655 for _, p := range s.mon.Layout() {
635- if p.ID != id || p.Hidden {
656+ if p.ID != id || p.Hidden || p.Preview > 0 {
636657 continue
637658 }
638659 r := c.clientRect(p)
@@ -728,6 +749,11 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
728749 if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 {
729750 return id, px, py
730751 }
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+ }
731757 var id WindowID
732758 var sx, sy float64
733759 full := false
@@ -998,6 +1024,13 @@ func (c *Core) Run(ctx context.Context) error {
9981024 }
9991025 continue
10001026 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+ }
10011034 id := c.pointer
10021035 if c.grab != 0 {
10031036 id = c.grab
@@ -1049,6 +1082,18 @@ func (c *Core) Run(ctx context.Context) error {
10491082 }
10501083 continue
10511084 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+ }
10521097 // Scroll goes to the window under the pointer, which has the
10531098 // pointer focus even mid-drag.
10541099 if c.pointer != 0 {
@@ -1058,7 +1103,7 @@ func (c *Core) Run(ctx context.Context) error {
10581103 }
10591104 continue
10601105 case ports.SwipeBegin:
1061- if c.swipeBegin(v.Time) && c.slid(ctx, false) != nil {
1106+ if c.swipeBegin(v) && c.slid(ctx, false) != nil {
10621107 return nil
10631108 }
10641109 continue
@@ -1077,6 +1122,21 @@ func (c *Core) Run(ctx context.Context) error {
10771122 if !ok {
10781123 continue
10791124 }
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+ }
10801140 // A focused window inhibiting shortcuts gets every key:
10811141 // no bind runs (emergency quit and VT switch are handled by
10821142 // input before core).
@@ -1171,7 +1231,7 @@ func (c *Core) Run(ctx context.Context) error {
11711231 }
11721232 c.pressed[held] = false
11731233 }
1174- if id := c.keyboardFocus(); id != 0 {
1234+ if id := c.keyboardFocus(); id != 0 && (!c.cur().mon.overview || c.overviewKeyboardTaken()) {
11751235 if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil {
11761236 return nil
11771237 }
@@ -213,7 +213,7 @@ func (c *Core) apply(cfg ports.Config) error {
213 continue213 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] = true487 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.focusScreen489 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.Dim492 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] = v512 c.sent[p.ID] = v
509 continue513 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 continue657 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, py750 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 WindowID757 var id WindowID
732 var sx, sy float64758 var sx, sy float64
733 full := false759 full := false
@@ -998,6 +1024,13 @@ func (c *Core) Run(ctx context.Context) error {
998 }1024 }
999 continue1025 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.pointer1034 id := c.pointer
1002 if c.grab != 0 {1035 if c.grab != 0 {
1003 id = c.grab1036 id = c.grab
@@ -1049,6 +1082,18 @@ func (c *Core) Run(ctx context.Context) error {
1049 }1082 }
1050 continue1083 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 the1097 // 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 continue1104 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 nil1107 return nil
1063 }1108 }
1064 continue1109 continue
@@ -1077,6 +1122,21 @@ func (c *Core) Run(ctx context.Context) error {
1077 if !ok {1122 if !ok {
1078 continue1123 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 by1141 // 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] = false1232 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 nil1236 return nil
1177 }1237 }
M internal/core/gesture.go +47 -9
@@ -25,7 +25,8 @@ const (
2525 // workspace.
2626 workspaceSwipeMovement = 300.0
2727 // 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).
2930 discreteSwipeMin = 150.0
3031 )
3132
@@ -36,6 +37,9 @@ const (
3637 swipeColumns
3738 swipeWorkspaces
3839 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
3943 // swipeDropped ignores the rest of a swipe whose workspace changed.
4044 swipeDropped
4145 )
@@ -43,6 +47,7 @@ const (
4347 // swipeGesture is the swipe in progress, on the screen focused when it began.
4448 type swipeGesture struct {
4549 screen *screen
50+ fingers int
4651 mode swipeMode
4752 horizontal bool
4853 cx, cy float64
@@ -59,12 +64,16 @@ type swipeGesture struct {
5964 // list is the numbered workspaces when a workspace slide began: start
6065 // indexes it, so the slide ends if the list changes.
6166 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
6271 }
6372
6473 // 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.
6675 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)
6877 }
6978
7079 // swipeSign turns finger movement into view movement: natural scroll moves
@@ -78,13 +87,13 @@ func (c *Core) swipeSign() float64 {
7887
7988 // swipeBegin starts a swipe. Input streams one swipe at a time; one still
8089 // 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 {
8291 changed := false
8392 if c.swipe != nil {
84- c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: at})
93+ c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: b.Time})
8594 changed = true
8695 }
87- c.swipe = &swipeGesture{screen: c.cur()}
96+ c.swipe = &swipeGesture{screen: c.cur(), fingers: b.Fingers}
8897 return changed
8998 }
9099
@@ -107,11 +116,17 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool {
107116 if g.horizontal {
108117 d = u.DX
109118 }
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+ }
110125 g.tracker.push(d*c.swipeSign(), u.Time)
111126 m := g.screen.mon
112127 switch g.mode {
113128 case swipeColumns:
114- if m.Current() != g.ws || !g.ws.slidable() {
129+ if m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() {
115130 g.ws.stopSlide()
116131 g.mode = swipeDropped
117132 return true
@@ -136,8 +151,17 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool {
136151 func (c *Core) decide(g *swipeGesture) {
137152 g.horizontal = math.Abs(g.cx) > math.Abs(g.cy)
138153 m := g.screen.mon
154+ g.opens = m.overviewOpens
139155 w := m.Current()
140156 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
141165 case g.horizontal && w.slidable():
142166 g.mode, g.ws = swipeColumns, w
143167 w.motion = nil
@@ -171,7 +195,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
171195 switch g.mode {
172196 case swipeColumns:
173197 w := g.ws
174- if m.Current() != w || !w.slidable() {
198+ if m.overviewOpens != g.opens || m.Current() != w || !w.slidable() {
175199 w.stopSlide()
176200 return false
177201 }
@@ -212,6 +236,16 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
212236 }
213237 m.switchOff, m.switchList = off, g.list
214238 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
215249 case swipeDiscrete:
216250 p := g.tracker.projectedEnd()
217251 if e.Cancelled || math.Abs(p) < discreteSwipeMin {
@@ -233,7 +267,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
233267 }
234268 before := c.cur().mon.Current()
235269 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+ }
237275 return c.cur().mon.Current() != before
238276 }
239277 return shown
@@ -25,7 +25,8 @@ const (
25 // workspace.25 // workspace.
26 workspaceSwipeMovement = 300.026 workspaceSwipeMovement = 300.0
27 // discreteSwipeMin is the projected distance a swipe needs to run a27 // 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.030 discreteSwipeMin = 150.0
30 )31 )
31 32
@@ -36,6 +37,9 @@ const (
36 swipeColumns37 swipeColumns
37 swipeWorkspaces38 swipeWorkspaces
38 swipeDiscrete39 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 swipeDropped44 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 *screen49 screen *screen
50+ fingers int
46 mode swipeMode51 mode swipeMode
47 horizontal bool52 horizontal bool
48 cx, cy float6453 cx, cy float64
@@ -59,12 +64,16 @@ type swipeGesture struct {
59 // list is the numbered workspaces when a workspace slide began: start64 // 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 []*Workspace66 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 one73 // 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 moves79 // 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 still88 // 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 := false91 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 = true94 changed = true
86 }95 }
87- c.swipe = &swipeGesture{screen: c.cur()}96+ c.swipe = &swipeGesture{screen: c.cur(), fingers: b.Fingers}
88 return changed97 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.DX117 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.mon126 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 = swipeDropped131 g.mode = swipeDropped
117 return true132 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.mon153 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, w166 g.mode, g.ws = swipeColumns, w
143 w.motion = nil167 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.ws197 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 false200 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.list237 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 = 0269 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() != before275 return c.cur().mon.Current() != before
238 }276 }
239 return shown277 return shown
M internal/core/layout.go +5 -1
@@ -97,8 +97,12 @@ type Placement struct {
9797 Fullscreen, Focused, Hidden bool
9898 // Floating windows sit over the columns at their own size.
9999 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.
101102 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
102106 // Neighbors are the sides touching a visible tile on this output.
103107 // Inset reserves room for drawn lines: all sides of a float; for
104108 // tiles without gaps, only the right and bottom shared sides.
@@ -97,8 +97,12 @@ type Placement struct {
97 Fullscreen, Focused, Hidden bool97 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 bool99 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 bool102 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; for107 // 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 {
3434 // one its swipe began on); switchOff is then from the current
3535 // workspace's place in it.
3636 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
3753 }
3854
3955 // NamedWorkspace configures a named workspace. Zero MaxColumns and an empty
@@ -622,10 +638,20 @@ func (m *Monitor) landing() *Workspace {
622638 return m.Current()
623639 }
624640
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+}
626649
627650 // Layout places every window: the workspace on screen laid out, others hidden.
628651 func (m *Monitor) Layout() []Placement {
652+ if m.overview {
653+ return m.overviewLayout()
654+ }
629655 var result []Placement
630656 cur := m.Current()
631657 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 current34 // 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 []*Workspace36 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 empty55 // 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 []Placement655 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) {
147147 for _, pl := range sc.mon.Layout() {
148148 if pl.ID == id {
149149 // 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 {
151152 return nil, Rect{}, false
152153 }
153154 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{}, false152 return nil, Rect{}, false
152 }153 }
153 return sc, c.clientRect(pl), true154 return sc, c.clientRect(pl), true
M internal/core/stash.go +10 -3
@@ -144,15 +144,22 @@ func (w *Workspace) Click(id WindowID) {
144144 w.FocusID(id)
145145 }
146146
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+
147155 // stashLayout places the stash: the selected window centred, its
148156 // neighbors gap% of the usable width away, showing what the margins
149157 // leave of them, the others hidden. cover is the covering fullscreen
150158 // window, if any.
151159 func (w *Workspace) stashLayout(focusedID, cover WindowID) []Placement {
152160 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
156163 // Peeks stay in the margins: they never overlap the selected window.
157164 gap := u.W * w.stashGap / 100
158165 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, its155 // stashLayout places the stash: the selected window centred, its
148 // neighbors gap% of the usable width away, showing what the margins156 // neighbors gap% of the usable width away, showing what the margins
149 // leave of them, the others hidden. cover is the covering fullscreen157 // 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.Usable160 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 / 100164 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) {
7676 // begin starts a swipe; nothing moves until it picks an axis.
7777 func (r *swipeRig) begin() {
7878 r.at += time.Second
79- r.input <- ports.SwipeBegin{Time: r.at}
79+ r.input <- ports.SwipeBegin{Fingers: 3, Time: r.at}
8080 }
8181
8282 // move sends one swipe update and returns the scene it produced.
@@ -557,3 +557,132 @@ func TestKeyWithoutSlideMovesAtOnce(t *testing.T) {
557557 t.Fatalf("column 1 at %d", got.X)
558558 }
559559 }
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.Second78 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 {
368368
369369 func (PointerAxis) inputEvent() {}
370370
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+}
374377
375378 // SwipeUpdate moves the fingers by the unaccelerated deltas DX, DY
376379 // (touchpad units, as libinput reports them, not natural-scroll inverted).
@@ -1021,6 +1024,9 @@ type SceneWindow struct {
10211024 // Dim darkens the window, border included, with black at this
10221025 // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing.
10231026 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
10241030 // Popups are drawn from their content only: no border, no background.
10251031 Popup bool
10261032 // 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 device371+// 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, DY378 // 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 this1024 // 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 float641026 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 bool1031 Popup bool
1026 // OverLayers popups hang from a layer surface: drawn over the top and1032 // OverLayers popups hang from a layer surface: drawn over the top and

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close