detached 2732965

fix: v0.6.0 pre-release review fixes (#146)

* fix(config): name the replacement for renamed workspace actions

Binds using *-workspace-up/down now report the -prev/-next action they were renamed to instead of a generic unknown action.

* test(config): check cascade inheritance and the ignored column widths warning

TestAssembleSessionFailure uses a temporary capture allowlist and describes what it checks.

* docs: describe cascade in the example config and literal ; in commands

* fix(layout): reset the view on a content axis change and stop cascade focus at edge bands

Switching a workspace between cascade and scroll or fixed kept the old axis offset, leaving part of the screen empty. focus-window-up/down in cascade no longer leave the workspace past the first or last band, matching three-finger swipes.

* refactor(layout): drop the appendLast policy flag and ignore tiles under panels for drops

appendLast always matched equalCells. Drop targets now use the usable area like separators and the pulse.

* fix(overview): report no change for three-finger steps that move nothing

Scroll previews no longer compute column positions that cascade and fixed overwrite. Card deselection goes through clearCard, stale axis comments are updated, and stash input tests send swipes through handleInput.

* perf(overview): snapshot the selection only before a three-finger step

overviewScroll took the snapshot on every scroll event; stackFront allocates, so wheel and sub-threshold finger updates now skip it.
commit
2732965f7545ae8c156a98a37c9dce9b0b162833
author
bnema · · 2026-10-07 17:20 UTC
committer
GitHub · · 2026-10-07 17:20 UTC
parent
76cf032
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.6.0
18 files · +223 -105
M docs/config.md +1 -1
@@ -192,7 +192,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
192192
193193 Set `layout.overflow = cascade` globally, per output, or on a named workspace. Columns fill bands of up to `max-columns`, then continue in a fresh band below. The columns of a band share its width equally, as in `fixed` overflow: a lone column takes the full width until another joins its band. New columns append last; closing a column compacts the following bands. Each band fills the usable height after panel reservations.
194194
195-Left/right focus follows column order across band boundaries. Up/down visits stacked windows first, then the closest column in the adjacent band. Column width presets and resizing do nothing; window height shares still apply. Maximizing a column hides its same-band siblings until focus moves or maximization ends.
195+Left/right focus follows column order across band boundaries. Up/down visits stacked windows first, then the closest column in the adjacent band, and stops at the first and last band. Column width presets and resizing do nothing; window height shares still apply. Maximizing a column hides its same-band siblings until focus moves or maximization ends.
196196
197197 Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour.
198198
@@ -192,7 +192,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
192 192
193 Set `layout.overflow = cascade` globally, per output, or on a named workspace. Columns fill bands of up to `max-columns`, then continue in a fresh band below. The columns of a band share its width equally, as in `fixed` overflow: a lone column takes the full width until another joins its band. New columns append last; closing a column compacts the following bands. Each band fills the usable height after panel reservations.193 Set `layout.overflow = cascade` globally, per output, or on a named workspace. Columns fill bands of up to `max-columns`, then continue in a fresh band below. The columns of a band share its width equally, as in `fixed` overflow: a lone column takes the full width until another joins its band. New columns append last; closing a column compacts the following bands. Each band fills the usable height after panel reservations.
194 194
195-Left/right focus follows column order across band boundaries. Up/down visits stacked windows first, then the closest column in the adjacent band. Column width presets and resizing do nothing; window height shares still apply. Maximizing a column hides its same-band siblings until focus moves or maximization ends.195+Left/right focus follows column order across band boundaries. Up/down visits stacked windows first, then the closest column in the adjacent band, and stops at the first and last band. Column width presets and resizing do nothing; window height shares still apply. Maximizing a column hides its same-band siblings until focus moves or maximization ends.
196 196
197 Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour.197 Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour.
198 198
M docs/desktop.md +1 -1
@@ -12,7 +12,7 @@ startup = waybar
1212 startup = wl-paste --watch cliphist store
1313 ```
1414
15-`spawn` binds and `startup` lines can hold several commands separated by `;`. They all start right away, in order; one does not wait for the previous one to finish. Empty commands are ignored. `terminal` takes a single command.
15+`spawn` binds and `startup` lines can hold several commands separated by `;`. They all start right away, in order; one does not wait for the previous one to finish. Empty commands are ignored. A `;` always separates commands, so put an argument containing `;` in a script. `terminal` and workspace slot commands (`column.N`) take a single command.
1616
1717 ```text
1818 startup = waybar; mako; wl-paste --watch cliphist store
@@ -12,7 +12,7 @@ startup = waybar
12 startup = wl-paste --watch cliphist store12 startup = wl-paste --watch cliphist store
13 ```13 ```
14 14
15-`spawn` binds and `startup` lines can hold several commands separated by `;`. They all start right away, in order; one does not wait for the previous one to finish. Empty commands are ignored. `terminal` takes a single command.15+`spawn` binds and `startup` lines can hold several commands separated by `;`. They all start right away, in order; one does not wait for the previous one to finish. Empty commands are ignored. A `;` always separates commands, so put an argument containing `;` in a script. `terminal` and workspace slot commands (`column.N`) take a single command.
16 16
17 ```text17 ```text
18 startup = waybar; mako; wl-paste --watch cliphist store18 startup = waybar; mako; wl-paste --watch cliphist store
M examples/config +11 -8
@@ -19,7 +19,7 @@ terminal.auto-open = first
1919
2020 # Commands run once when the session starts; separate several with `;` or
2121 # use one line each. Like spawn, a command runs without a shell: see
22-# "Running commands" in the README.
22+# "Running commands" in docs/desktop.md.
2323 # Programs get WAYLAND_DISPLAY and NEFERWL_STATE.
2424 # startup = waybar; mako
2525 # startup = wl-paste --watch cliphist store
@@ -41,15 +41,16 @@ layout.gaps = 0
4141 # A lone column fills the screen without separators.
4242 layout.max-columns = 2
4343 # Past max-columns: scroll opens columns to the right; fixed keeps everything
44-# on screen, each new window splitting the newest one (spiral).
44+# on screen, each new window splitting the newest one (spiral); cascade starts
45+# a new band of columns below, scrolling vertically.
4546 layout.overflow = scroll
4647 # cycle-column-width steps through these, then back to the equal share.
4748 layout.presets = 1/3, 1/2, 2/3, 1
4849
4950 # Touchpad. A three-finger swipe up or down slides between workspaces, left
50-# or right scrolls the columns. natural-scroll = on moves the content with
51-# the fingers, for scroll and swipes. tap = on clicks on a tap: one finger
52-# left, two right, three middle.
51+# or right scrolls the columns (cascade swaps the two axes). natural-scroll
52+# = on moves the content with the fingers, for scroll and swipes. tap = on
53+# clicks on a tap: one finger left, two right, three middle.
5354 touchpad.natural-scroll = off
5455 touchpad.tap = on
5556 # Pointer speed from -1 to 1; adaptive speeds up quick moves, flat does not.
@@ -134,7 +135,8 @@ log.debug =
134135 # Keys are what your layout prints (cmd+é on AZERTY). Write = # and space
135136 # by name: cmd+equal, cmd+numbersign, cmd+space. `none` removes a default.
136137 # `spawn <command>` runs a program; arguments split on spaces, `;` starts
137-# another command, no shell (pipes and variables go in a script: see "Running commands" in the README).
138+# another command, no shell (pipes and variables go in a script: see
139+# "Running commands" in docs/desktop.md).
138140 bind.cmd+return = spawn-terminal
139141 bind.cmd+left = focus-column-left
140142 bind.cmd+right = focus-column-right
@@ -180,8 +182,9 @@ bind.cmd+q = close-window
180182 bind.ctrl+alt+backspace = quit
181183 bind.ctrl+cmd+space = spawn fuzzel
182184
183-# Workspaces stack vertically; numbers follow their order. An empty one always
184-# waits below the last. cmd+up/down past the column edge also switch workspace.
185+# Workspaces stack vertically (side by side with cascade); numbers follow
186+# their order. An empty one always waits after the last. cmd+up/down past the
187+# column edge also switch workspace.
185188 bind.cmd+pageup = focus-workspace-prev
186189 bind.cmd+pagedown = focus-workspace-next
187190 # move-column-to-workspace moves the whole column; move-window-to-workspace
@@ -19,7 +19,7 @@ terminal.auto-open = first
19 19
20 # Commands run once when the session starts; separate several with `;` or20 # Commands run once when the session starts; separate several with `;` or
21 # use one line each. Like spawn, a command runs without a shell: see21 # use one line each. Like spawn, a command runs without a shell: see
22-# "Running commands" in the README.22+# "Running commands" in docs/desktop.md.
23 # Programs get WAYLAND_DISPLAY and NEFERWL_STATE.23 # Programs get WAYLAND_DISPLAY and NEFERWL_STATE.
24 # startup = waybar; mako24 # startup = waybar; mako
25 # startup = wl-paste --watch cliphist store25 # startup = wl-paste --watch cliphist store
@@ -41,15 +41,16 @@ layout.gaps = 0
41 # A lone column fills the screen without separators.41 # A lone column fills the screen without separators.
42 layout.max-columns = 242 layout.max-columns = 2
43 # Past max-columns: scroll opens columns to the right; fixed keeps everything43 # Past max-columns: scroll opens columns to the right; fixed keeps everything
44-# on screen, each new window splitting the newest one (spiral).44+# on screen, each new window splitting the newest one (spiral); cascade starts
45+# a new band of columns below, scrolling vertically.
45 layout.overflow = scroll46 layout.overflow = scroll
46 # cycle-column-width steps through these, then back to the equal share.47 # cycle-column-width steps through these, then back to the equal share.
47 layout.presets = 1/3, 1/2, 2/3, 148 layout.presets = 1/3, 1/2, 2/3, 1
48 49
49 # Touchpad. A three-finger swipe up or down slides between workspaces, left50 # Touchpad. A three-finger swipe up or down slides between workspaces, left
50-# or right scrolls the columns. natural-scroll = on moves the content with51+# or right scrolls the columns (cascade swaps the two axes). natural-scroll
51-# the fingers, for scroll and swipes. tap = on clicks on a tap: one finger52+# = on moves the content with the fingers, for scroll and swipes. tap = on
52-# left, two right, three middle.53+# clicks on a tap: one finger left, two right, three middle.
53 touchpad.natural-scroll = off54 touchpad.natural-scroll = off
54 touchpad.tap = on55 touchpad.tap = on
55 # Pointer speed from -1 to 1; adaptive speeds up quick moves, flat does not.56 # Pointer speed from -1 to 1; adaptive speeds up quick moves, flat does not.
@@ -134,7 +135,8 @@ log.debug =
134 # Keys are what your layout prints (cmd+é on AZERTY). Write = # and space135 # Keys are what your layout prints (cmd+é on AZERTY). Write = # and space
135 # by name: cmd+equal, cmd+numbersign, cmd+space. `none` removes a default.136 # by name: cmd+equal, cmd+numbersign, cmd+space. `none` removes a default.
136 # `spawn <command>` runs a program; arguments split on spaces, `;` starts137 # `spawn <command>` runs a program; arguments split on spaces, `;` starts
137-# another command, no shell (pipes and variables go in a script: see "Running commands" in the README).138+# another command, no shell (pipes and variables go in a script: see
139+# "Running commands" in docs/desktop.md).
138 bind.cmd+return = spawn-terminal140 bind.cmd+return = spawn-terminal
139 bind.cmd+left = focus-column-left141 bind.cmd+left = focus-column-left
140 bind.cmd+right = focus-column-right142 bind.cmd+right = focus-column-right
@@ -180,8 +182,9 @@ bind.cmd+q = close-window
180 bind.ctrl+alt+backspace = quit182 bind.ctrl+alt+backspace = quit
181 bind.ctrl+cmd+space = spawn fuzzel183 bind.ctrl+cmd+space = spawn fuzzel
182 184
183-# Workspaces stack vertically; numbers follow their order. An empty one always185+# Workspaces stack vertically (side by side with cascade); numbers follow
184-# waits below the last. cmd+up/down past the column edge also switch workspace.186+# their order. An empty one always waits after the last. cmd+up/down past the
187+# column edge also switch workspace.
185 bind.cmd+pageup = focus-workspace-prev188 bind.cmd+pageup = focus-workspace-prev
186 bind.cmd+pagedown = focus-workspace-next189 bind.cmd+pagedown = focus-workspace-next
187 # move-column-to-workspace moves the whole column; move-window-to-workspace190 # move-column-to-workspace moves the whole column; move-window-to-workspace
M internal/adapters/config/cascade_test.go +13 -4
@@ -5,16 +5,25 @@ import (
55 "testing"
66 )
77
8-func TestCascadeLayoutInheritance(t *testing.T) {
9- c, w := parseString(t, "layout.overflow = cascade\nlayout.DP-1.overflow = cascade\nworkspace.work.overflow = cascade\nbind.cmd+w = workspace work\n")
8+func TestCascadeOverflowInherited(t *testing.T) {
9+ c, w := parseString(t, "layout.overflow = cascade\nlayout.DP-1.max-columns = 3\nworkspace.work.max-columns = 2\nbind.cmd+w = workspace work\n")
1010 if len(w) != 0 {
1111 t.Fatalf("warnings=%v", w)
1212 }
13- if c.Layout.Overflow != "cascade" || c.Layout.Outputs[0].Overflow != "cascade" || c.Workspaces[0].Overflow != "cascade" {
14- t.Fatalf("cascade inheritance=%+v", c.Layout)
13+ out := c.Layout.Outputs[0].LayoutRules.Over(c.Layout.LayoutRules)
14+ ws := c.Workspaces[0].LayoutRules.Over(out)
15+ if out.Overflow != "cascade" || ws.Overflow != "cascade" {
16+ t.Fatalf("output=%q workspace=%q, want cascade", out.Overflow, ws.Overflow)
1517 }
1618 _, warnings, err := Parse(strings.NewReader("layout.overflow = unknown\n"))
1719 if err != nil || len(warnings) != 1 || !strings.Contains(warnings[0].Msg, "must be scroll, fixed or cascade") {
1820 t.Fatalf("invalid overflow diagnostic: %v, %v", warnings, err)
1921 }
2022 }
23+
24+func TestCascadeIgnoresColumnWidths(t *testing.T) {
25+ _, w := parseString(t, "workspace.work.overflow = cascade\nworkspace.work.column.1 = 50%, foot\nbind.cmd+w = workspace work\n")
26+ if len(w) != 1 || !strings.Contains(w[0].Msg, "ignored with overflow = cascade") {
27+ t.Fatalf("warnings=%v", w)
28+ }
29+}
@@ -5,16 +5,25 @@ import (
5 "testing"5 "testing"
6 )6 )
7 7
8-func TestCascadeLayoutInheritance(t *testing.T) {8+func TestCascadeOverflowInherited(t *testing.T) {
9- c, w := parseString(t, "layout.overflow = cascade\nlayout.DP-1.overflow = cascade\nworkspace.work.overflow = cascade\nbind.cmd+w = workspace work\n")9+ c, w := parseString(t, "layout.overflow = cascade\nlayout.DP-1.max-columns = 3\nworkspace.work.max-columns = 2\nbind.cmd+w = workspace work\n")
10 if len(w) != 0 {10 if len(w) != 0 {
11 t.Fatalf("warnings=%v", w)11 t.Fatalf("warnings=%v", w)
12 }12 }
13- if c.Layout.Overflow != "cascade" || c.Layout.Outputs[0].Overflow != "cascade" || c.Workspaces[0].Overflow != "cascade" {13+ out := c.Layout.Outputs[0].LayoutRules.Over(c.Layout.LayoutRules)
14- t.Fatalf("cascade inheritance=%+v", c.Layout)14+ ws := c.Workspaces[0].LayoutRules.Over(out)
15+ if out.Overflow != "cascade" || ws.Overflow != "cascade" {
16+ t.Fatalf("output=%q workspace=%q, want cascade", out.Overflow, ws.Overflow)
15 }17 }
16 _, warnings, err := Parse(strings.NewReader("layout.overflow = unknown\n"))18 _, warnings, err := Parse(strings.NewReader("layout.overflow = unknown\n"))
17 if err != nil || len(warnings) != 1 || !strings.Contains(warnings[0].Msg, "must be scroll, fixed or cascade") {19 if err != nil || len(warnings) != 1 || !strings.Contains(warnings[0].Msg, "must be scroll, fixed or cascade") {
18 t.Fatalf("invalid overflow diagnostic: %v, %v", warnings, err)20 t.Fatalf("invalid overflow diagnostic: %v, %v", warnings, err)
19 }21 }
20 }22 }
23+
24+func TestCascadeIgnoresColumnWidths(t *testing.T) {
25+ _, w := parseString(t, "workspace.work.overflow = cascade\nworkspace.work.column.1 = 50%, foot\nbind.cmd+w = workspace work\n")
26+ if len(w) != 1 || !strings.Contains(w[0].Msg, "ignored with overflow = cascade") {
27+ t.Fatalf("warnings=%v", w)
28+ }
29+}
M internal/adapters/config/config.go +5 -0
@@ -1095,6 +1095,11 @@ func checkAction(v string) error {
10951095 return nil
10961096 }
10971097 }
1098+ for old, repl := range map[string]string{"-workspace-up": "-workspace-prev", "-workspace-down": "-workspace-next"} {
1099+ if base, ok := strings.CutSuffix(v, old); ok && actions[base+repl] {
1100+ return fmt.Errorf("action %q was renamed to %q", v, base+repl)
1101+ }
1102+ }
10981103 if !actions[v] {
10991104 return fmt.Errorf(`unknown action %q (or "spawn <command>", "workspace <name>")`, v)
11001105 }
@@ -1095,6 +1095,11 @@ func checkAction(v string) error {
1095 return nil1095 return nil
1096 }1096 }
1097 }1097 }
1098+ for old, repl := range map[string]string{"-workspace-up": "-workspace-prev", "-workspace-down": "-workspace-next"} {
1099+ if base, ok := strings.CutSuffix(v, old); ok && actions[base+repl] {
1100+ return fmt.Errorf("action %q was renamed to %q", v, base+repl)
1101+ }
1102+ }
1098 if !actions[v] {1103 if !actions[v] {
1099 return fmt.Errorf(`unknown action %q (or "spawn <command>", "workspace <name>")`, v)1104 return fmt.Errorf(`unknown action %q (or "spawn <command>", "workspace <name>")`, v)
1100 }1105 }
M internal/adapters/config/config_test.go +3 -3
@@ -283,9 +283,9 @@ func TestWarningsKeepDefaults(t *testing.T) {
283283 {"bind.cmd+ = quit", "missing key"},
284284 {"bind.hyper+x = quit", "unknown modifier"},
285285 {"bind.cmd+x = bogus", "unknown action"},
286- {"bind.cmd+x = focus-workspace-up", "unknown action"},
287- {"bind.cmd+x = move-workspace-down", "unknown action"},
288- {"bind.cmd+x = move-column-to-workspace-up", "unknown action"},
286+ {"bind.cmd+x = focus-workspace-up", `renamed to "focus-workspace-prev"`},
287+ {"bind.cmd+x = move-workspace-down", `renamed to "move-workspace-next"`},
288+ {"bind.cmd+x = move-column-to-workspace-up", `renamed to "move-column-to-workspace-prev"`},
289289 {"bind.cmd+x = set-column-width 10%", "set-column-width needs"},
290290 {"bind.cmd+x = set-column-width +0%", "set-column-width needs"},
291291 {"bind.cmd+x = set-window-height -101%", "set-window-height needs"},
@@ -283,9 +283,9 @@ func TestWarningsKeepDefaults(t *testing.T) {
283 {"bind.cmd+ = quit", "missing key"},283 {"bind.cmd+ = quit", "missing key"},
284 {"bind.hyper+x = quit", "unknown modifier"},284 {"bind.hyper+x = quit", "unknown modifier"},
285 {"bind.cmd+x = bogus", "unknown action"},285 {"bind.cmd+x = bogus", "unknown action"},
286- {"bind.cmd+x = focus-workspace-up", "unknown action"},286+ {"bind.cmd+x = focus-workspace-up", `renamed to "focus-workspace-prev"`},
287- {"bind.cmd+x = move-workspace-down", "unknown action"},287+ {"bind.cmd+x = move-workspace-down", `renamed to "move-workspace-next"`},
288- {"bind.cmd+x = move-column-to-workspace-up", "unknown action"},288+ {"bind.cmd+x = move-column-to-workspace-up", `renamed to "move-column-to-workspace-prev"`},
289 {"bind.cmd+x = set-column-width 10%", "set-column-width needs"},289 {"bind.cmd+x = set-column-width 10%", "set-column-width needs"},
290 {"bind.cmd+x = set-column-width +0%", "set-column-width needs"},290 {"bind.cmd+x = set-column-width +0%", "set-column-width needs"},
291 {"bind.cmd+x = set-window-height -101%", "set-window-height needs"},291 {"bind.cmd+x = set-window-height -101%", "set-window-height needs"},
M internal/app/headless_test.go +6 -3
@@ -1,6 +1,8 @@
11 package app
22
33 import (
4+ "os"
5+ "path/filepath"
46 "strings"
57 "testing"
68
@@ -40,11 +42,12 @@ func TestVirtualRendererExportsHDRTargets(t *testing.T) {
4042 }
4143 }
4244
43-// A failed assembly returns an error and releases what it acquired: without
44-// a runtime directory wayland.New, the last fallible step, fails.
45+// A failed assembly returns its error and no session: without a runtime
46+// directory wayland.New, the last fallible step, fails.
4547 func TestAssembleSessionFailure(t *testing.T) {
4648 t.Setenv("XDG_RUNTIME_DIR", "")
47- s, err := assembleSession(t.Context(), Options{Backend: "headless", Config: config.Defaults(), NoXwayland: true})
49+ allowPath := filepath.Join(t.TempDir(), "capture-allow")
50+ s, err := assembleSession(t.Context(), Options{Backend: "headless", Config: config.Defaults(), NoXwayland: true, captureAllowPath: allowPath, captureAllowOwner: uint32(os.Getuid())})
4851 if err == nil || !strings.Contains(err.Error(), "XDG_RUNTIME_DIR") {
4952 t.Fatalf("expected runtime directory failure, got %v", err)
5053 }
@@ -1,6 +1,8 @@
1 package app1 package app
2 2
3 import (3 import (
4+ "os"
5+ "path/filepath"
4 "strings"6 "strings"
5 "testing"7 "testing"
6 8
@@ -40,11 +42,12 @@ func TestVirtualRendererExportsHDRTargets(t *testing.T) {
40 }42 }
41 }43 }
42 44
43-// A failed assembly returns an error and releases what it acquired: without45+// A failed assembly returns its error and no session: without a runtime
44-// a runtime directory wayland.New, the last fallible step, fails.46+// directory wayland.New, the last fallible step, fails.
45 func TestAssembleSessionFailure(t *testing.T) {47 func TestAssembleSessionFailure(t *testing.T) {
46 t.Setenv("XDG_RUNTIME_DIR", "")48 t.Setenv("XDG_RUNTIME_DIR", "")
47- s, err := assembleSession(t.Context(), Options{Backend: "headless", Config: config.Defaults(), NoXwayland: true})49+ allowPath := filepath.Join(t.TempDir(), "capture-allow")
50+ s, err := assembleSession(t.Context(), Options{Backend: "headless", Config: config.Defaults(), NoXwayland: true, captureAllowPath: allowPath, captureAllowOwner: uint32(os.Getuid())})
48 if err == nil || !strings.Contains(err.Error(), "XDG_RUNTIME_DIR") {51 if err == nil || !strings.Contains(err.Error(), "XDG_RUNTIME_DIR") {
49 t.Fatalf("expected runtime directory failure, got %v", err)52 t.Fatalf("expected runtime directory failure, got %v", err)
50 }53 }
M internal/core/action.go +4 -2
@@ -318,13 +318,15 @@ func (m *Monitor) Apply(a Action) Effect {
318318 switch a {
319319 case ActionFocusWindowUp, ActionFocusWindowDown:
320320 // Past the column and any on-screen neighbor or demoted float,
321- // move to the next workspace.
321+ // move to the next workspace. Cascade workspaces sit side by side:
322+ // up/down stop at the first and last band.
322323 dir := 1
323324 if a == ActionFocusWindowUp {
324325 dir = -1
325326 }
326327 // A hidden workspace is outside the vertical list.
327- if !m.Current().FocusWindow(dir) && m.shown == nil {
328+ w := m.Current()
329+ if !w.FocusWindow(dir) && m.shown == nil && !w.policy().wraps {
328330 m.Focus(m.Active + dir)
329331 }
330332 return Effect{}
@@ -318,13 +318,15 @@ func (m *Monitor) Apply(a Action) Effect {
318 switch a {318 switch a {
319 case ActionFocusWindowUp, ActionFocusWindowDown:319 case ActionFocusWindowUp, ActionFocusWindowDown:
320 // Past the column and any on-screen neighbor or demoted float,320 // Past the column and any on-screen neighbor or demoted float,
321- // move to the next workspace.321+ // move to the next workspace. Cascade workspaces sit side by side:
322+ // up/down stop at the first and last band.
322 dir := 1323 dir := 1
323 if a == ActionFocusWindowUp {324 if a == ActionFocusWindowUp {
324 dir = -1325 dir = -1
325 }326 }
326 // A hidden workspace is outside the vertical list.327 // A hidden workspace is outside the vertical list.
327- if !m.Current().FocusWindow(dir) && m.shown == nil {328+ w := m.Current()
329+ if !w.FocusWindow(dir) && m.shown == nil && !w.policy().wraps {
328 m.Focus(m.Active + dir)330 m.Focus(m.Active + dir)
329 }331 }
330 return Effect{}332 return Effect{}
M internal/core/cascade_test.go +46 -11
@@ -221,6 +221,47 @@ func TestCascadeReloadAndStashRestoreClearMaximization(t *testing.T) {
221221 }
222222 }
223223
224+// Leaving cascade drops the band offset: scroll realigns on the focus
225+// instead of keeping it as a horizontal offset past the last column.
226+func TestReloadCascadeToScrollResetsView(t *testing.T) {
227+ m := NewMonitor()
228+ m.SetOutput(900, 600)
229+ m.SetMaxColumns(3)
230+ m.SetOverflow(OverflowCascade)
231+ for i := WindowID(1); i <= 4; i++ {
232+ m.AddWindow(i)
233+ }
234+ if m.Current().View != 600 {
235+ t.Fatalf("cascade view=%d, want 600", m.Current().View)
236+ }
237+ m.SetOverflow(OverflowScroll)
238+ if got := m.Current().View; got != 300 {
239+ t.Fatalf("scroll view=%d, want 300", got)
240+ }
241+}
242+
243+// Up/down stop at the first and last band: cascade workspaces sit side by
244+// side, so a vertical key never switches workspace.
245+func TestCascadeFocusWindowStopsAtEdgeBands(t *testing.T) {
246+ m := NewMonitor()
247+ m.SetOutput(900, 600)
248+ m.SetMaxColumns(3)
249+ m.SetOverflow(OverflowCascade)
250+ for i := WindowID(1); i <= 4; i++ {
251+ m.AddWindow(i)
252+ }
253+ for _, tc := range []struct {
254+ focus WindowID
255+ a Action
256+ }{{4, ActionFocusWindowDown}, {1, ActionFocusWindowUp}} {
257+ m.Current().FocusID(tc.focus)
258+ m.Apply(tc.a)
259+ if id, _ := m.Current().Focused(); m.Active != 0 || id != tc.focus {
260+ t.Fatalf("%s from %d: active=%d focus=%d", tc.a, tc.focus, m.Active, id)
261+ }
262+ }
263+}
264+
224265 func TestCascadeGestureValidityDoesNotAllocate(t *testing.T) {
225266 m := NewMonitor()
226267 m.SetOutput(900, 600)
@@ -302,7 +343,7 @@ func TestLaneGeometry(t *testing.T) {
302343 }
303344 }
304345
305-func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) {
346+func TestSlideLayoutAxisOffset(t *testing.T) {
306347 m := NewMonitor()
307348 m.SetOutput(900, 600)
308349 m.Current().Overflow = OverflowCascade
@@ -313,21 +354,15 @@ func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) {
313354 m.Focus(0)
314355 m.switchAxis = m.Current().policy().workspace
315356 m.switchView.off = .5
316- ps := m.layoutInto(nil)
317- for _, p := range ps {
318- if p.ID == 1 && (p.Rect.X != -450 || p.Rect.Y != 0) {
319- t.Fatalf("cascade source offset=%v", p.Rect)
320- }
357+ if p := previewOf(t, m.layoutInto(nil), 1); p.Rect.X != -450 || p.Rect.Y != 0 {
358+ t.Fatalf("cascade source offset=%v", p.Rect)
321359 }
322360 m.stopSwitch()
323361 m.Focus(1)
324362 m.switchAxis = m.Current().policy().workspace
325363 m.switchView.off = -.5
326- ps = m.layoutInto(nil)
327- for _, p := range ps {
328- if p.ID == 2 && (p.Rect.X != 0 || p.Rect.Y != 300) {
329- t.Fatalf("scroll source offset=%v", p.Rect)
330- }
364+ if p := previewOf(t, m.layoutInto(nil), 2); p.Rect.X != 0 || p.Rect.Y != 300 {
365+ t.Fatalf("scroll source offset=%v", p.Rect)
331366 }
332367 }
333368
@@ -221,6 +221,47 @@ func TestCascadeReloadAndStashRestoreClearMaximization(t *testing.T) {
221 }221 }
222 }222 }
223 223
224+// Leaving cascade drops the band offset: scroll realigns on the focus
225+// instead of keeping it as a horizontal offset past the last column.
226+func TestReloadCascadeToScrollResetsView(t *testing.T) {
227+ m := NewMonitor()
228+ m.SetOutput(900, 600)
229+ m.SetMaxColumns(3)
230+ m.SetOverflow(OverflowCascade)
231+ for i := WindowID(1); i <= 4; i++ {
232+ m.AddWindow(i)
233+ }
234+ if m.Current().View != 600 {
235+ t.Fatalf("cascade view=%d, want 600", m.Current().View)
236+ }
237+ m.SetOverflow(OverflowScroll)
238+ if got := m.Current().View; got != 300 {
239+ t.Fatalf("scroll view=%d, want 300", got)
240+ }
241+}
242+
243+// Up/down stop at the first and last band: cascade workspaces sit side by
244+// side, so a vertical key never switches workspace.
245+func TestCascadeFocusWindowStopsAtEdgeBands(t *testing.T) {
246+ m := NewMonitor()
247+ m.SetOutput(900, 600)
248+ m.SetMaxColumns(3)
249+ m.SetOverflow(OverflowCascade)
250+ for i := WindowID(1); i <= 4; i++ {
251+ m.AddWindow(i)
252+ }
253+ for _, tc := range []struct {
254+ focus WindowID
255+ a Action
256+ }{{4, ActionFocusWindowDown}, {1, ActionFocusWindowUp}} {
257+ m.Current().FocusID(tc.focus)
258+ m.Apply(tc.a)
259+ if id, _ := m.Current().Focused(); m.Active != 0 || id != tc.focus {
260+ t.Fatalf("%s from %d: active=%d focus=%d", tc.a, tc.focus, m.Active, id)
261+ }
262+ }
263+}
264+
224 func TestCascadeGestureValidityDoesNotAllocate(t *testing.T) {265 func TestCascadeGestureValidityDoesNotAllocate(t *testing.T) {
225 m := NewMonitor()266 m := NewMonitor()
226 m.SetOutput(900, 600)267 m.SetOutput(900, 600)
@@ -302,7 +343,7 @@ func TestLaneGeometry(t *testing.T) {
302 }343 }
303 }344 }
304 345
305-func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) {346+func TestSlideLayoutAxisOffset(t *testing.T) {
306 m := NewMonitor()347 m := NewMonitor()
307 m.SetOutput(900, 600)348 m.SetOutput(900, 600)
308 m.Current().Overflow = OverflowCascade349 m.Current().Overflow = OverflowCascade
@@ -313,21 +354,15 @@ func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) {
313 m.Focus(0)354 m.Focus(0)
314 m.switchAxis = m.Current().policy().workspace355 m.switchAxis = m.Current().policy().workspace
315 m.switchView.off = .5356 m.switchView.off = .5
316- ps := m.layoutInto(nil)357+ if p := previewOf(t, m.layoutInto(nil), 1); p.Rect.X != -450 || p.Rect.Y != 0 {
317- for _, p := range ps {358+ t.Fatalf("cascade source offset=%v", p.Rect)
318- if p.ID == 1 && (p.Rect.X != -450 || p.Rect.Y != 0) {
319- t.Fatalf("cascade source offset=%v", p.Rect)
320- }
321 }359 }
322 m.stopSwitch()360 m.stopSwitch()
323 m.Focus(1)361 m.Focus(1)
324 m.switchAxis = m.Current().policy().workspace362 m.switchAxis = m.Current().policy().workspace
325 m.switchView.off = -.5363 m.switchView.off = -.5
326- ps = m.layoutInto(nil)364+ if p := previewOf(t, m.layoutInto(nil), 2); p.Rect.X != 0 || p.Rect.Y != 300 {
327- for _, p := range ps {365+ t.Fatalf("scroll source offset=%v", p.Rect)
328- if p.ID == 2 && (p.Rect.X != 0 || p.Rect.Y != 300) {
329- t.Fatalf("scroll source offset=%v", p.Rect)
330- }
331 }366 }
332 }367 }
333 368
M internal/core/drag.go +1 -1
@@ -319,7 +319,7 @@ func (w *Workspace) dropIn(id WindowID, x, y, t int) dropTarget {
319319 lone := src >= 0 && len(w.Columns[src].Windows) == 1
320320 var cols []visibleColumn
321321 for _, p := range layout {
322- if p.Floating || p.Hidden || !p.Rect.Overlaps(w.Output) {
322+ if p.Floating || p.Hidden || !p.Rect.Overlaps(w.Usable) {
323323 continue
324324 }
325325 ci := w.columnOf(p.ID)
@@ -319,7 +319,7 @@ func (w *Workspace) dropIn(id WindowID, x, y, t int) dropTarget {
319 lone := src >= 0 && len(w.Columns[src].Windows) == 1319 lone := src >= 0 && len(w.Columns[src].Windows) == 1
320 var cols []visibleColumn320 var cols []visibleColumn
321 for _, p := range layout {321 for _, p := range layout {
322- if p.Floating || p.Hidden || !p.Rect.Overlaps(w.Output) {322+ if p.Floating || p.Hidden || !p.Rect.Overlaps(w.Usable) {
323 continue323 continue
324 }324 }
325 ci := w.columnOf(p.ID)325 ci := w.columnOf(p.ID)
M internal/core/layout.go +5 -4
@@ -498,7 +498,7 @@ func (w *Workspace) AddWindow(id WindowID) {
498498 // A new window never moves the view off a covering fullscreen
499499 // window (ADR 011): it waits, hidden, after the focused column.
500500 at := len(w.Columns)
501- if !w.policy().appendLast {
501+ if !w.policy().equalCells {
502502 // A covering float or stashed window may have no column under it.
503503 at = min(w.Focus+1, len(w.Columns))
504504 }
@@ -511,7 +511,7 @@ func (w *Workspace) AddWindow(id WindowID) {
511511 // addColumn places a new column like a new window and focuses it.
512512 func (w *Workspace) addColumn(col Column) {
513513 at := 0
514- if w.policy().appendLast {
514+ if w.policy().equalCells {
515515 // Ordered overflow depends only on window order: new windows go last.
516516 at = len(w.Columns)
517517 } else if len(w.Columns) > 0 {
@@ -998,7 +998,8 @@ func (w *Workspace) insertColumn(at int, col Column) {
998998 }
999999
10001000 // CycleWidth steps the focused column through the presets. Fixed overflow
1001-// ignores presets: it toggles the expanded column instead.
1001+// ignores presets: it toggles the expanded column instead; cascade does
1002+// nothing.
10021003 func (w *Workspace) CycleWidth() {
10031004 if w.policy().wraps {
10041005 return
@@ -1643,7 +1644,7 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
16431644 if full {
16441645 // Scroll mode aligns the view on the column; fixed never scrolls.
16451646 r = w.Output
1646- if w.Overflow == OverflowScroll || w.Overflow == "" {
1647+ if !w.policy().equalCells {
16471648 r.X = col.X
16481649 }
16491650 }
@@ -498,7 +498,7 @@ func (w *Workspace) AddWindow(id WindowID) {
498 // A new window never moves the view off a covering fullscreen498 // A new window never moves the view off a covering fullscreen
499 // window (ADR 011): it waits, hidden, after the focused column.499 // window (ADR 011): it waits, hidden, after the focused column.
500 at := len(w.Columns)500 at := len(w.Columns)
501- if !w.policy().appendLast {501+ if !w.policy().equalCells {
502 // A covering float or stashed window may have no column under it.502 // A covering float or stashed window may have no column under it.
503 at = min(w.Focus+1, len(w.Columns))503 at = min(w.Focus+1, len(w.Columns))
504 }504 }
@@ -511,7 +511,7 @@ func (w *Workspace) AddWindow(id WindowID) {
511 // addColumn places a new column like a new window and focuses it.511 // addColumn places a new column like a new window and focuses it.
512 func (w *Workspace) addColumn(col Column) {512 func (w *Workspace) addColumn(col Column) {
513 at := 0513 at := 0
514- if w.policy().appendLast {514+ if w.policy().equalCells {
515 // Ordered overflow depends only on window order: new windows go last.515 // Ordered overflow depends only on window order: new windows go last.
516 at = len(w.Columns)516 at = len(w.Columns)
517 } else if len(w.Columns) > 0 {517 } else if len(w.Columns) > 0 {
@@ -998,7 +998,8 @@ func (w *Workspace) insertColumn(at int, col Column) {
998 }998 }
999 999
1000 // CycleWidth steps the focused column through the presets. Fixed overflow1000 // CycleWidth steps the focused column through the presets. Fixed overflow
1001-// ignores presets: it toggles the expanded column instead.1001+// ignores presets: it toggles the expanded column instead; cascade does
1002+// nothing.
1002 func (w *Workspace) CycleWidth() {1003 func (w *Workspace) CycleWidth() {
1003 if w.policy().wraps {1004 if w.policy().wraps {
1004 return1005 return
@@ -1643,7 +1644,7 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
1643 if full {1644 if full {
1644 // Scroll mode aligns the view on the column; fixed never scrolls.1645 // Scroll mode aligns the view on the column; fixed never scrolls.
1645 r = w.Output1646 r = w.Output
1646- if w.Overflow == OverflowScroll || w.Overflow == "" {1647+ if !w.policy().equalCells {
1647 r.X = col.X1648 r.X = col.X
1648 }1649 }
1649 }1650 }
M internal/core/layout_policy.go +4 -4
@@ -25,16 +25,16 @@ func (a layoutAxis) offset(r *Rect, pixels int) {
2525 // layoutPolicy keeps geometry capabilities distinct from workspace navigation.
2626 // Window state remains owned by Workspace; policies contain no mutable state.
2727 type layoutPolicy struct {
28- content, workspace layoutAxis
29- equalCells, appendLast, wraps bool
28+ content, workspace layoutAxis
29+ equalCells, wraps bool
3030 }
3131
3232 func (w *Workspace) policy() layoutPolicy {
3333 if w.Overflow == OverflowCascade {
34- return layoutPolicy{content: verticalAxis, workspace: horizontalAxis, equalCells: true, appendLast: true, wraps: true}
34+ return layoutPolicy{content: verticalAxis, workspace: horizontalAxis, equalCells: true, wraps: true}
3535 }
3636 if w.Overflow == OverflowFixed {
37- return layoutPolicy{content: horizontalAxis, workspace: verticalAxis, equalCells: true, appendLast: true}
37+ return layoutPolicy{content: horizontalAxis, workspace: verticalAxis, equalCells: true}
3838 }
3939 return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}
4040 }
@@ -25,16 +25,16 @@ func (a layoutAxis) offset(r *Rect, pixels int) {
25 // layoutPolicy keeps geometry capabilities distinct from workspace navigation.25 // layoutPolicy keeps geometry capabilities distinct from workspace navigation.
26 // Window state remains owned by Workspace; policies contain no mutable state.26 // Window state remains owned by Workspace; policies contain no mutable state.
27 type layoutPolicy struct {27 type layoutPolicy struct {
28- content, workspace layoutAxis28+ content, workspace layoutAxis
29- equalCells, appendLast, wraps bool29+ equalCells, wraps bool
30 }30 }
31 31
32 func (w *Workspace) policy() layoutPolicy {32 func (w *Workspace) policy() layoutPolicy {
33 if w.Overflow == OverflowCascade {33 if w.Overflow == OverflowCascade {
34- return layoutPolicy{content: verticalAxis, workspace: horizontalAxis, equalCells: true, appendLast: true, wraps: true}34+ return layoutPolicy{content: verticalAxis, workspace: horizontalAxis, equalCells: true, wraps: true}
35 }35 }
36 if w.Overflow == OverflowFixed {36 if w.Overflow == OverflowFixed {
37- return layoutPolicy{content: horizontalAxis, workspace: verticalAxis, equalCells: true, appendLast: true}37+ return layoutPolicy{content: horizontalAxis, workspace: verticalAxis, equalCells: true}
38 }38 }
39 return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}39 return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}
40 }40 }
M internal/core/monitor.go +7 -1
@@ -510,7 +510,7 @@ func (m *Monitor) applyNamed() {
510510 }
511511 m.each(func(w *Workspace) {
512512 s := specs[w.Name]
513- before := w.Overflow
513+ before, axis := w.Overflow, w.policy().content
514514 w.Overflow = m.template.Overflow
515515 if s.Overflow != "" {
516516 w.Overflow = s.Overflow
@@ -518,6 +518,12 @@ func (m *Monitor) applyNamed() {
518518 if before != w.Overflow && w.policy().equalCells {
519519 w.unmaximize()
520520 }
521+ // The view offset belongs to the old content axis: SetMaxColumns
522+ // scrolls back to the focus along the new one.
523+ if w.policy().content != axis {
524+ w.view.stop()
525+ w.View = 0
526+ }
521527 w.SetMaxColumns(m.template.MaxColumns)
522528 if s.MaxColumns > 0 {
523529 w.SetMaxColumns(s.MaxColumns)
@@ -510,7 +510,7 @@ func (m *Monitor) applyNamed() {
510 }510 }
511 m.each(func(w *Workspace) {511 m.each(func(w *Workspace) {
512 s := specs[w.Name]512 s := specs[w.Name]
513- before := w.Overflow513+ before, axis := w.Overflow, w.policy().content
514 w.Overflow = m.template.Overflow514 w.Overflow = m.template.Overflow
515 if s.Overflow != "" {515 if s.Overflow != "" {
516 w.Overflow = s.Overflow516 w.Overflow = s.Overflow
@@ -518,6 +518,12 @@ func (m *Monitor) applyNamed() {
518 if before != w.Overflow && w.policy().equalCells {518 if before != w.Overflow && w.policy().equalCells {
519 w.unmaximize()519 w.unmaximize()
520 }520 }
521+ // The view offset belongs to the old content axis: SetMaxColumns
522+ // scrolls back to the focus along the new one.
523+ if w.policy().content != axis {
524+ w.view.stop()
525+ w.View = 0
526+ }
521 w.SetMaxColumns(m.template.MaxColumns)527 w.SetMaxColumns(m.template.MaxColumns)
522 if s.MaxColumns > 0 {528 if s.MaxColumns > 0 {
523 w.SetMaxColumns(s.MaxColumns)529 w.SetMaxColumns(s.MaxColumns)
M internal/core/overview.go +62 -34
@@ -24,8 +24,9 @@ type overviewState struct {
2424 selected WindowID
2525 selectedAt int
2626 scrollX, scrollY float64
27- // scrolled is set once a three-finger gesture stepped up or down: it
28- // waits for the fingers to lift before the next step. sideways is set
27+ // scrolled is set once a three-finger gesture took its one step per
28+ // gesture (up or down, a cascade's horizontal step, or a turn of the
29+ // overview): it waits for the fingers to lift. sideways is set
2930 // once it stepped to a column: it stays on columns and steps once per
3031 // overviewScrollRepeat.
3132 scrolled, sideways bool
@@ -48,9 +49,9 @@ type rowSnapshot struct {
4849
4950 // The overview scales workspace rows without resizing clients. Covering
5051 // floats and the columns form a linear stack of cards; small floats stay
51-// hidden. Up/down traverse the stack before changing workspace. A pinned
52-// fullscreen window is the front card of its row, the windows it hides
53-// behind it; choosing one of them leaves fullscreen.
52+// hidden. Moves along the workspace axis traverse the stack before changing
53+// workspace. A pinned fullscreen window is the front card of its row, the
54+// windows it hides behind it; choosing one of them leaves fullscreen.
5455
5556 // A workspace's stash shows as a pile of cards pinned to the left edge of
5657 // its row: the selected stashed window in front at overviewCardZoom, up to
@@ -63,9 +64,9 @@ const (
6364 overviewPeekStep = 0.07
6465 )
6566
66-// overviewMaxZoom caps the current row so its neighbors show above and
67-// below; overviewMinZoom floors the shrink of wide workspaces, which then
68-// scroll to keep the selected column in view.
67+// overviewMaxZoom caps the current row so its neighbors show beside it
68+// along the workspace axis; overviewMinZoom floors the shrink of wide
69+// workspaces, which then scroll to keep the selected column in view.
6970 const overviewMaxZoom, overviewMinZoom = 0.6, 0.25
7071
7172 // ToggleOverview opens the overview, or closes it on the selection. Over
@@ -110,7 +111,7 @@ func (m *Monitor) remember(w *Workspace) {
110111 // selectRow resets provisional stack and stash selection for a new row.
111112 func (m *Monitor) selectRow() {
112113 m.remember(m.Current())
113- m.ov.card, m.ov.cardOf = 0, nil
114+ m.clearCard()
114115 m.ov.front, m.ov.row, m.ov.frontAt = stackItem{}, nil, 0
115116 m.ov.selected, m.ov.selectedAt = 0, -1
116117 w := m.Current()
@@ -140,6 +141,9 @@ func (m *Monitor) card() WindowID {
140141 return m.ov.card
141142 }
142143
144+// clearCard deselects the stash card.
145+func (m *Monitor) clearCard() { m.ov.card, m.ov.cardOf = 0, nil }
146+
143147 // cardAt is the pile entry of w that is selected, -1 when none.
144148 func (m *Monitor) cardAt(w *Workspace) int {
145149 if id := m.card(); id != 0 && w == m.ov.cardOf {
@@ -382,7 +386,13 @@ func (m *Monitor) OverviewMove(dx, dy int) {
382386 }
383387 return
384388 }
385- m.overviewMoveVertical(dx, dy)
389+ // Workspaces stack vertically: vertical moves stay in a selected stash
390+ // pile; left stays, and right deselects it.
391+ if dy != 0 {
392+ m.overviewVertical(dy)
393+ } else if dx != 0 {
394+ m.overviewHorizontal(dx)
395+ }
386396 }
387397
388398 // overviewWorkspaceStep is a focus-workspace bind. A vertical overview
@@ -447,7 +457,7 @@ func (m *Monitor) overviewAcross(dx int) {
447457 m.overviewStep(dx)
448458 return
449459 }
450- m.ov.card, m.ov.cardOf = 0, nil
460+ m.clearCard()
451461 m.ov.selected = 0
452462 if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {
453463 // The front column: a maximized one keeps its cards behind.
@@ -485,17 +495,9 @@ func (m *Monitor) laneEdge(w *Workspace, dx int) {
485495 m.overviewStep(dx)
486496 }
487497
488-// overviewMoveVertical is the move of an overview whose workspaces stack
489-// vertically. Vertical moves stay in a selected stash pile; left stays, and
490-// right deselects it, even when the row has no main card.
491-func (m *Monitor) overviewMoveVertical(dx, dy int) {
492- if dy != 0 {
493- m.overviewVertical(dy)
494- } else if dx != 0 {
495- m.overviewHorizontal(dx)
496- }
497-}
498-
498+// overviewVertical is the vertical move of an overview whose workspaces
499+// stack vertically: through a selected stash pile, then the stack, then
500+// to the next workspace.
499501 func (m *Monitor) overviewVertical(dy int) {
500502 if m.cycleCard(m.Current(), dy) {
501503 return
@@ -522,7 +524,7 @@ func (m *Monitor) overviewHorizontal(dx int) {
522524 w := m.Current()
523525 if m.cardAt(w) >= 0 {
524526 if dx > 0 {
525- m.ov.card, m.ov.cardOf = 0, nil
527+ m.clearCard()
526528 m.ov.selected = 0
527529 item := m.stackFront(w)
528530 if item.kind == stackColumns {
@@ -594,7 +596,7 @@ func (m *Monitor) OverviewPick(id WindowID) {
594596 }
595597 }
596598 m.showOverview(w)
597- m.ov.card, m.ov.cardOf = 0, nil
599+ m.clearCard()
598600 if i := w.stashIndex(id); i >= 0 {
599601 m.selectCard(w, i)
600602 } else if item, ok := w.itemOf(id); ok {
@@ -990,7 +992,7 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
990992 }
991993 }
992994 for i, c := range w.Columns {
993- r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}
995+ r := Rect{Y: g, H: h}
994996 if w.policy().wraps {
995997 if w.hiddenByMaximized(i) {
996998 continue // A card, above.
@@ -1012,9 +1014,12 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
10121014 } else if w.Overflow == OverflowFixed {
10131015 r = rects[i]
10141016 r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y
1015- } else if w.fullscreenColumn(i) {
1016- // A fullscreen column shows at the width it had in the row.
1017- r.W = max(w.Usable.W-2*g, 0)
1017+ } else {
1018+ r.X, r.W = w.columnX(i)-w.Usable.X, w.columnWidth(i)
1019+ if w.fullscreenColumn(i) {
1020+ // A fullscreen column shows at the width it had in the row.
1021+ r.W = max(w.Usable.W-2*g, 0)
1022+ }
10181023 }
10191024 if w.hiddenByMaximized(i) {
10201025 // Fixed overflow shows the hidden columns on their own card
@@ -1285,10 +1290,11 @@ const (
12851290 // overviewScroll moves the overview selection with a scroll frame: a row
12861291 // step when the vertical scroll reaches overviewScrollStep and leads, else
12871292 // a column step. Three-finger updates (encoded here as AxisFinger) step
1288-// to the next column after
1289-// overviewScrollStep, then once per overviewScrollRepeat, and stay on
1290-// columns until they lift; up or down they step once per scroll, so a card
1291-// in a stack is easy to pick.
1293+// to the next column after overviewScrollStep, then once per
1294+// overviewScrollRepeat, and stay on columns until they lift; up or down they
1295+// step once per scroll, so a card in a stack is easy to pick. In a cascade
1296+// overview they take one step per gesture on both axes: a band vertically,
1297+// a card or workspace horizontally.
12921298 // Continuous scrolling steps once per overviewScrollStep and a wheel once
12931299 // per notch (high-resolution wheels add up their fractions of a notch).
12941300 // Vertical steps select cards first, then rows; natural scroll
@@ -1328,6 +1334,10 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
13281334 // A step that turns the overview returns, so the axis holds for the loop.
13291335 axis := m.overviewAxis()
13301336 cascade := axis == horizontalAxis
1337+ // A finger step reports a change only when the selection moved: at an
1338+ // edge or over a float it does nothing, and the scene stays. The
1339+ // snapshot is taken only before a step: stackFront allocates.
1340+ var before overviewSelection
13311341 for {
13321342 switch {
13331343 case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX):
@@ -1336,6 +1346,9 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
13361346 // overview drops the scroll left (showOverview).
13371347 m.ov.scrollY -= float64(d) * overviewScrollStep
13381348 m.ov.scrollX = 0
1349+ if finger {
1350+ before = m.overviewSelection()
1351+ }
13391352 if cascade && !finger && m.cardAt(m.Current()) < 0 {
13401353 m.overviewStep(d)
13411354 } else {
@@ -1346,11 +1359,12 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
13461359 }
13471360 if finger {
13481361 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
1349- return true
1362+ return m.overviewSelection() != before
13501363 }
13511364 case math.Abs(m.ov.scrollX) >= stepX:
13521365 d := sign(m.ov.scrollX)
13531366 if cascade && finger {
1367+ before = m.overviewSelection()
13541368 // One step per gesture: a card behind a maximized
13551369 // column first, like the arrows, else one workspace,
13561370 // even over a stash card.
@@ -1360,7 +1374,7 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
13601374 m.overviewStep(d)
13611375 }
13621376 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
1363- return true
1377+ return m.overviewSelection() != before
13641378 }
13651379 m.ov.scrollX -= float64(d) * stepX
13661380 m.ov.scrollY = 0
@@ -1384,6 +1398,20 @@ func (m *Monitor) bandCardsAt(w *Workspace) bool {
13841398 return m.cardAt(w) < 0 && w.hasBandCards() && m.stackFront(w).kind == stackColumns
13851399 }
13861400
1401+// overviewSelection is what an overview step can move: the workspace, its
1402+// stack front, stash card, focused column and selected window.
1403+type overviewSelection struct {
1404+ w *Workspace
1405+ front stackItem
1406+ card, selected WindowID
1407+ focus int
1408+}
1409+
1410+func (m *Monitor) overviewSelection() overviewSelection {
1411+ w := m.Current()
1412+ return overviewSelection{w: w, front: m.stackFront(w), card: m.card(), selected: m.ov.selected, focus: w.Focus}
1413+}
1414+
13871415 // overviewTurned reports whether a scroll step turned the overview from the
13881416 // axis before. A finger scroll then waits for the fingers to lift, so it
13891417 // cannot bounce back.
@@ -24,8 +24,9 @@ type overviewState struct {
24 selected WindowID24 selected WindowID
25 selectedAt int25 selectedAt int
26 scrollX, scrollY float6426 scrollX, scrollY float64
27- // scrolled is set once a three-finger gesture stepped up or down: it27+ // scrolled is set once a three-finger gesture took its one step per
28- // waits for the fingers to lift before the next step. sideways is set28+ // gesture (up or down, a cascade's horizontal step, or a turn of the
29+ // overview): it waits for the fingers to lift. sideways is set
29 // once it stepped to a column: it stays on columns and steps once per30 // once it stepped to a column: it stays on columns and steps once per
30 // overviewScrollRepeat.31 // overviewScrollRepeat.
31 scrolled, sideways bool32 scrolled, sideways bool
@@ -48,9 +49,9 @@ type rowSnapshot struct {
48 49
49 // The overview scales workspace rows without resizing clients. Covering50 // The overview scales workspace rows without resizing clients. Covering
50 // floats and the columns form a linear stack of cards; small floats stay51 // floats and the columns form a linear stack of cards; small floats stay
51-// hidden. Up/down traverse the stack before changing workspace. A pinned52+// hidden. Moves along the workspace axis traverse the stack before changing
52-// fullscreen window is the front card of its row, the windows it hides53+// workspace. A pinned fullscreen window is the front card of its row, the
53-// behind it; choosing one of them leaves fullscreen.54+// windows it hides behind it; choosing one of them leaves fullscreen.
54 55
55 // A workspace's stash shows as a pile of cards pinned to the left edge of56 // A workspace's stash shows as a pile of cards pinned to the left edge of
56 // its row: the selected stashed window in front at overviewCardZoom, up to57 // its row: the selected stashed window in front at overviewCardZoom, up to
@@ -63,9 +64,9 @@ const (
63 overviewPeekStep = 0.0764 overviewPeekStep = 0.07
64 )65 )
65 66
66-// overviewMaxZoom caps the current row so its neighbors show above and67+// overviewMaxZoom caps the current row so its neighbors show beside it
67-// below; overviewMinZoom floors the shrink of wide workspaces, which then68+// along the workspace axis; overviewMinZoom floors the shrink of wide
68-// scroll to keep the selected column in view.69+// workspaces, which then scroll to keep the selected column in view.
69 const overviewMaxZoom, overviewMinZoom = 0.6, 0.2570 const overviewMaxZoom, overviewMinZoom = 0.6, 0.25
70 71
71 // ToggleOverview opens the overview, or closes it on the selection. Over72 // ToggleOverview opens the overview, or closes it on the selection. Over
@@ -110,7 +111,7 @@ func (m *Monitor) remember(w *Workspace) {
110 // selectRow resets provisional stack and stash selection for a new row.111 // selectRow resets provisional stack and stash selection for a new row.
111 func (m *Monitor) selectRow() {112 func (m *Monitor) selectRow() {
112 m.remember(m.Current())113 m.remember(m.Current())
113- m.ov.card, m.ov.cardOf = 0, nil114+ m.clearCard()
114 m.ov.front, m.ov.row, m.ov.frontAt = stackItem{}, nil, 0115 m.ov.front, m.ov.row, m.ov.frontAt = stackItem{}, nil, 0
115 m.ov.selected, m.ov.selectedAt = 0, -1116 m.ov.selected, m.ov.selectedAt = 0, -1
116 w := m.Current()117 w := m.Current()
@@ -140,6 +141,9 @@ func (m *Monitor) card() WindowID {
140 return m.ov.card141 return m.ov.card
141 }142 }
142 143
144+// clearCard deselects the stash card.
145+func (m *Monitor) clearCard() { m.ov.card, m.ov.cardOf = 0, nil }
146+
143 // cardAt is the pile entry of w that is selected, -1 when none.147 // cardAt is the pile entry of w that is selected, -1 when none.
144 func (m *Monitor) cardAt(w *Workspace) int {148 func (m *Monitor) cardAt(w *Workspace) int {
145 if id := m.card(); id != 0 && w == m.ov.cardOf {149 if id := m.card(); id != 0 && w == m.ov.cardOf {
@@ -382,7 +386,13 @@ func (m *Monitor) OverviewMove(dx, dy int) {
382 }386 }
383 return387 return
384 }388 }
385- m.overviewMoveVertical(dx, dy)389+ // Workspaces stack vertically: vertical moves stay in a selected stash
390+ // pile; left stays, and right deselects it.
391+ if dy != 0 {
392+ m.overviewVertical(dy)
393+ } else if dx != 0 {
394+ m.overviewHorizontal(dx)
395+ }
386 }396 }
387 397
388 // overviewWorkspaceStep is a focus-workspace bind. A vertical overview398 // overviewWorkspaceStep is a focus-workspace bind. A vertical overview
@@ -447,7 +457,7 @@ func (m *Monitor) overviewAcross(dx int) {
447 m.overviewStep(dx)457 m.overviewStep(dx)
448 return458 return
449 }459 }
450- m.ov.card, m.ov.cardOf = 0, nil460+ m.clearCard()
451 m.ov.selected = 0461 m.ov.selected = 0
452 if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {462 if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {
453 // The front column: a maximized one keeps its cards behind.463 // The front column: a maximized one keeps its cards behind.
@@ -485,17 +495,9 @@ func (m *Monitor) laneEdge(w *Workspace, dx int) {
485 m.overviewStep(dx)495 m.overviewStep(dx)
486 }496 }
487 497
488-// overviewMoveVertical is the move of an overview whose workspaces stack498+// overviewVertical is the vertical move of an overview whose workspaces
489-// vertically. Vertical moves stay in a selected stash pile; left stays, and499+// stack vertically: through a selected stash pile, then the stack, then
490-// right deselects it, even when the row has no main card.500+// to the next workspace.
491-func (m *Monitor) overviewMoveVertical(dx, dy int) {
492- if dy != 0 {
493- m.overviewVertical(dy)
494- } else if dx != 0 {
495- m.overviewHorizontal(dx)
496- }
497-}
498-
499 func (m *Monitor) overviewVertical(dy int) {501 func (m *Monitor) overviewVertical(dy int) {
500 if m.cycleCard(m.Current(), dy) {502 if m.cycleCard(m.Current(), dy) {
501 return503 return
@@ -522,7 +524,7 @@ func (m *Monitor) overviewHorizontal(dx int) {
522 w := m.Current()524 w := m.Current()
523 if m.cardAt(w) >= 0 {525 if m.cardAt(w) >= 0 {
524 if dx > 0 {526 if dx > 0 {
525- m.ov.card, m.ov.cardOf = 0, nil527+ m.clearCard()
526 m.ov.selected = 0528 m.ov.selected = 0
527 item := m.stackFront(w)529 item := m.stackFront(w)
528 if item.kind == stackColumns {530 if item.kind == stackColumns {
@@ -594,7 +596,7 @@ func (m *Monitor) OverviewPick(id WindowID) {
594 }596 }
595 }597 }
596 m.showOverview(w)598 m.showOverview(w)
597- m.ov.card, m.ov.cardOf = 0, nil599+ m.clearCard()
598 if i := w.stashIndex(id); i >= 0 {600 if i := w.stashIndex(id); i >= 0 {
599 m.selectCard(w, i)601 m.selectCard(w, i)
600 } else if item, ok := w.itemOf(id); ok {602 } else if item, ok := w.itemOf(id); ok {
@@ -990,7 +992,7 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
990 }992 }
991 }993 }
992 for i, c := range w.Columns {994 for i, c := range w.Columns {
993- r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}995+ r := Rect{Y: g, H: h}
994 if w.policy().wraps {996 if w.policy().wraps {
995 if w.hiddenByMaximized(i) {997 if w.hiddenByMaximized(i) {
996 continue // A card, above.998 continue // A card, above.
@@ -1012,9 +1014,12 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
1012 } else if w.Overflow == OverflowFixed {1014 } else if w.Overflow == OverflowFixed {
1013 r = rects[i]1015 r = rects[i]
1014 r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y1016 r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y
1015- } else if w.fullscreenColumn(i) {1017+ } else {
1016- // A fullscreen column shows at the width it had in the row.1018+ r.X, r.W = w.columnX(i)-w.Usable.X, w.columnWidth(i)
1017- r.W = max(w.Usable.W-2*g, 0)1019+ if w.fullscreenColumn(i) {
1020+ // A fullscreen column shows at the width it had in the row.
1021+ r.W = max(w.Usable.W-2*g, 0)
1022+ }
1018 }1023 }
1019 if w.hiddenByMaximized(i) {1024 if w.hiddenByMaximized(i) {
1020 // Fixed overflow shows the hidden columns on their own card1025 // Fixed overflow shows the hidden columns on their own card
@@ -1285,10 +1290,11 @@ const (
1285 // overviewScroll moves the overview selection with a scroll frame: a row1290 // overviewScroll moves the overview selection with a scroll frame: a row
1286 // step when the vertical scroll reaches overviewScrollStep and leads, else1291 // step when the vertical scroll reaches overviewScrollStep and leads, else
1287 // a column step. Three-finger updates (encoded here as AxisFinger) step1292 // a column step. Three-finger updates (encoded here as AxisFinger) step
1288-// to the next column after1293+// to the next column after overviewScrollStep, then once per
1289-// overviewScrollStep, then once per overviewScrollRepeat, and stay on1294+// overviewScrollRepeat, and stay on columns until they lift; up or down they
1290-// columns until they lift; up or down they step once per scroll, so a card1295+// step once per scroll, so a card in a stack is easy to pick. In a cascade
1291-// in a stack is easy to pick.1296+// overview they take one step per gesture on both axes: a band vertically,
1297+// a card or workspace horizontally.
1292 // Continuous scrolling steps once per overviewScrollStep and a wheel once1298 // Continuous scrolling steps once per overviewScrollStep and a wheel once
1293 // per notch (high-resolution wheels add up their fractions of a notch).1299 // per notch (high-resolution wheels add up their fractions of a notch).
1294 // Vertical steps select cards first, then rows; natural scroll1300 // Vertical steps select cards first, then rows; natural scroll
@@ -1328,6 +1334,10 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
1328 // A step that turns the overview returns, so the axis holds for the loop.1334 // A step that turns the overview returns, so the axis holds for the loop.
1329 axis := m.overviewAxis()1335 axis := m.overviewAxis()
1330 cascade := axis == horizontalAxis1336 cascade := axis == horizontalAxis
1337+ // A finger step reports a change only when the selection moved: at an
1338+ // edge or over a float it does nothing, and the scene stays. The
1339+ // snapshot is taken only before a step: stackFront allocates.
1340+ var before overviewSelection
1331 for {1341 for {
1332 switch {1342 switch {
1333 case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX):1343 case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX):
@@ -1336,6 +1346,9 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
1336 // overview drops the scroll left (showOverview).1346 // overview drops the scroll left (showOverview).
1337 m.ov.scrollY -= float64(d) * overviewScrollStep1347 m.ov.scrollY -= float64(d) * overviewScrollStep
1338 m.ov.scrollX = 01348 m.ov.scrollX = 0
1349+ if finger {
1350+ before = m.overviewSelection()
1351+ }
1339 if cascade && !finger && m.cardAt(m.Current()) < 0 {1352 if cascade && !finger && m.cardAt(m.Current()) < 0 {
1340 m.overviewStep(d)1353 m.overviewStep(d)
1341 } else {1354 } else {
@@ -1346,11 +1359,12 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
1346 }1359 }
1347 if finger {1360 if finger {
1348 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true1361 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
1349- return true1362+ return m.overviewSelection() != before
1350 }1363 }
1351 case math.Abs(m.ov.scrollX) >= stepX:1364 case math.Abs(m.ov.scrollX) >= stepX:
1352 d := sign(m.ov.scrollX)1365 d := sign(m.ov.scrollX)
1353 if cascade && finger {1366 if cascade && finger {
1367+ before = m.overviewSelection()
1354 // One step per gesture: a card behind a maximized1368 // One step per gesture: a card behind a maximized
1355 // column first, like the arrows, else one workspace,1369 // column first, like the arrows, else one workspace,
1356 // even over a stash card.1370 // even over a stash card.
@@ -1360,7 +1374,7 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
1360 m.overviewStep(d)1374 m.overviewStep(d)
1361 }1375 }
1362 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true1376 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
1363- return true1377+ return m.overviewSelection() != before
1364 }1378 }
1365 m.ov.scrollX -= float64(d) * stepX1379 m.ov.scrollX -= float64(d) * stepX
1366 m.ov.scrollY = 01380 m.ov.scrollY = 0
@@ -1384,6 +1398,20 @@ func (m *Monitor) bandCardsAt(w *Workspace) bool {
1384 return m.cardAt(w) < 0 && w.hasBandCards() && m.stackFront(w).kind == stackColumns1398 return m.cardAt(w) < 0 && w.hasBandCards() && m.stackFront(w).kind == stackColumns
1385 }1399 }
1386 1400
1401+// overviewSelection is what an overview step can move: the workspace, its
1402+// stack front, stash card, focused column and selected window.
1403+type overviewSelection struct {
1404+ w *Workspace
1405+ front stackItem
1406+ card, selected WindowID
1407+ focus int
1408+}
1409+
1410+func (m *Monitor) overviewSelection() overviewSelection {
1411+ w := m.Current()
1412+ return overviewSelection{w: w, front: m.stackFront(w), card: m.card(), selected: m.ov.selected, focus: w.Focus}
1413+}
1414+
1387 // overviewTurned reports whether a scroll step turned the overview from the1415 // overviewTurned reports whether a scroll step turned the overview from the
1388 // axis before. A finger scroll then waits for the fingers to lift, so it1416 // axis before. A finger scroll then waits for the fingers to lift, so it
1389 // cannot bounce back.1417 // cannot bounce back.
M internal/core/overview_cascade_test.go +24 -11
@@ -68,7 +68,7 @@ func within1(a, b int) bool { return a-b <= 1 && b-a <= 1 }
6868
6969 func hiddenOff(p Placement) bool { return p.Hidden && p.Preview == 0 && !p.Focused }
7070
71-// T1: the zoom does not depend on the number of bands.
71+// The zoom does not depend on the number of bands.
7272 func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) {
7373 if one, three := cascadeWorkspace(3).overviewZoom(), cascadeWorkspace(7).overviewZoom(); one != three || one != overviewMaxZoom {
7474 t.Fatalf("zoom with one band %v, with three %v", one, three)
@@ -243,7 +243,7 @@ func TestOverviewCascadeMaximizedCardPick(t *testing.T) {
243243 }
244244 }
245245
246-// T2: the selected band is the centre lane: undimmed, centred, selected tile
246+// The selected band is the centre lane: undimmed, centred, selected tile
247247 // focused.
248248 func TestOverviewCascadeCentreLane(t *testing.T) {
249249 m := cascadeOverview()
@@ -261,7 +261,7 @@ func TestOverviewCascadeCentreLane(t *testing.T) {
261261 }
262262 }
263263
264-// T3: the other bands are dimmed lanes a screen (and a gap) away; those
264+// The other bands are dimmed lanes a screen (and a gap) away; those
265265 // outside the area are hidden.
266266 func TestOverviewCascadeOtherLanes(t *testing.T) {
267267 m := cascadeOverview()
@@ -309,7 +309,7 @@ func TestOverviewCascadeOtherLanes(t *testing.T) {
309309 }
310310 }
311311
312-// T4: neighbor workspaces sit left and right of the current slot, dimmed,
312+// Neighbor workspaces sit left and right of the current slot, dimmed,
313313 // their selected lane level with the centre lane.
314314 func TestOverviewCascadeNeighbors(t *testing.T) {
315315 m := cascadeOverview()
@@ -355,7 +355,7 @@ func TestOverviewCascadeNeighbors(t *testing.T) {
355355 }
356356 }
357357
358-// T5: lanes along the overview axis would overlap the other workspaces: a
358+// Lanes along the overview axis would overlap the other workspaces: a
359359 // cascade neighbor of a scroll workspace shows its selected band only.
360360 func TestOverviewCascadeNeighborBelowScrollShowsOneBand(t *testing.T) {
361361 m := cascadeOverview()
@@ -384,7 +384,7 @@ func TestOverviewCascadeNeighborBelowScrollShowsOneBand(t *testing.T) {
384384 }
385385 }
386386
387-// T6: the stash pile is inside the current slot, left of the band; the
387+// The stash pile is inside the current slot, left of the band; the
388388 // previous workspace stays clear of it.
389389 func TestOverviewCascadeStashInSlot(t *testing.T) {
390390 m := cascadeOverview()
@@ -449,7 +449,7 @@ func focusedID(m *Monitor) WindowID {
449449 return id
450450 }
451451
452-// T7: ↑/↓ change band and stop at the ends; ←/→ walk the band, then leave it
452+// ↑/↓ change band and stop at the ends; ←/→ walk the band, then leave it
453453 // for the stash or the neighbor workspaces; Escape restores everything.
454454 func TestOverviewCascadeKeys(t *testing.T) {
455455 for _, d := range cascadeDrivers {
@@ -530,7 +530,7 @@ func TestOverviewCascadeKeys(t *testing.T) {
530530 }
531531
532532 // T7b: with a stash card selected, up/down and the three-finger vertical
533-// swipe cycle the pile as on main; confirming shows the picked window.
533+// swipe cycle the pile as in a vertical overview; confirming shows the picked window.
534534 func TestOverviewCascadeStashPile(t *testing.T) {
535535 for _, d := range cascadeDrivers {
536536 t.Run(d.name, func(t *testing.T) {
@@ -768,7 +768,7 @@ func TestOverviewCascadeStashToMaximized(t *testing.T) {
768768 }
769769 }
770770
771-// T8: a named boundary of a cascade overview is a vertical rule strictly
771+// A named boundary of a cascade overview is a vertical rule strictly
772772 // between the two slots.
773773 func TestOverviewCascadeNamedDivider(t *testing.T) {
774774 m := namedOverviewMonitor()
@@ -787,7 +787,7 @@ func TestOverviewCascadeNamedDivider(t *testing.T) {
787787 }
788788 }
789789
790-// T10: covering floats fan along the horizontal axis: left goes deeper in the
790+// Covering floats fan along the horizontal axis: left goes deeper in the
791791 // stack (the cards behind show on the left), right comes back to the front.
792792 func TestOverviewCascadeCoveringFloat(t *testing.T) {
793793 for _, d := range cascadeDrivers {
@@ -872,7 +872,7 @@ func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {
872872 }
873873 }
874874
875-// T11: the focus binds keep their meaning in a cascade overview.
875+// The focus binds keep their meaning in a cascade overview.
876876 func TestOverviewCascadeFocusBinds(t *testing.T) {
877877 m := cascadeOverview()
878878 w := m.Current()
@@ -913,6 +913,19 @@ func TestOverviewCascadeFocusBinds(t *testing.T) {
913913 }
914914 }
915915
916+// A three-finger step that moves nothing reports no change: no scene
917+// transition, no keyboard grab. Here the first band of a cascade has no
918+// band above it.
919+func TestOverviewCascadeFingerNoopReportsNoChange(t *testing.T) {
920+ m := cascadeOverview()
921+ m.Current().FocusID(5)
922+ m.ToggleOverview()
923+ up := ports.PointerAxis{Source: ports.AxisFinger, Vertical: ports.ScrollAxis{Set: true, Value: -overviewScrollStep}}
924+ if m.overviewScroll(up) {
925+ t.Fatal("a step past the first band reported a change")
926+ }
927+}
928+
916929 func TestOverviewMixedLayoutGestureAndCancel(t *testing.T) {
917930 m := overviewMonitor()
918931 m.SetOverflow(OverflowFixed)
@@ -68,7 +68,7 @@ func within1(a, b int) bool { return a-b <= 1 && b-a <= 1 }
68 68
69 func hiddenOff(p Placement) bool { return p.Hidden && p.Preview == 0 && !p.Focused }69 func hiddenOff(p Placement) bool { return p.Hidden && p.Preview == 0 && !p.Focused }
70 70
71-// T1: the zoom does not depend on the number of bands.71+// The zoom does not depend on the number of bands.
72 func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) {72 func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) {
73 if one, three := cascadeWorkspace(3).overviewZoom(), cascadeWorkspace(7).overviewZoom(); one != three || one != overviewMaxZoom {73 if one, three := cascadeWorkspace(3).overviewZoom(), cascadeWorkspace(7).overviewZoom(); one != three || one != overviewMaxZoom {
74 t.Fatalf("zoom with one band %v, with three %v", one, three)74 t.Fatalf("zoom with one band %v, with three %v", one, three)
@@ -243,7 +243,7 @@ func TestOverviewCascadeMaximizedCardPick(t *testing.T) {
243 }243 }
244 }244 }
245 245
246-// T2: the selected band is the centre lane: undimmed, centred, selected tile246+// The selected band is the centre lane: undimmed, centred, selected tile
247 // focused.247 // focused.
248 func TestOverviewCascadeCentreLane(t *testing.T) {248 func TestOverviewCascadeCentreLane(t *testing.T) {
249 m := cascadeOverview()249 m := cascadeOverview()
@@ -261,7 +261,7 @@ func TestOverviewCascadeCentreLane(t *testing.T) {
261 }261 }
262 }262 }
263 263
264-// T3: the other bands are dimmed lanes a screen (and a gap) away; those264+// The other bands are dimmed lanes a screen (and a gap) away; those
265 // outside the area are hidden.265 // outside the area are hidden.
266 func TestOverviewCascadeOtherLanes(t *testing.T) {266 func TestOverviewCascadeOtherLanes(t *testing.T) {
267 m := cascadeOverview()267 m := cascadeOverview()
@@ -309,7 +309,7 @@ func TestOverviewCascadeOtherLanes(t *testing.T) {
309 }309 }
310 }310 }
311 311
312-// T4: neighbor workspaces sit left and right of the current slot, dimmed,312+// Neighbor workspaces sit left and right of the current slot, dimmed,
313 // their selected lane level with the centre lane.313 // their selected lane level with the centre lane.
314 func TestOverviewCascadeNeighbors(t *testing.T) {314 func TestOverviewCascadeNeighbors(t *testing.T) {
315 m := cascadeOverview()315 m := cascadeOverview()
@@ -355,7 +355,7 @@ func TestOverviewCascadeNeighbors(t *testing.T) {
355 }355 }
356 }356 }
357 357
358-// T5: lanes along the overview axis would overlap the other workspaces: a358+// Lanes along the overview axis would overlap the other workspaces: a
359 // cascade neighbor of a scroll workspace shows its selected band only.359 // cascade neighbor of a scroll workspace shows its selected band only.
360 func TestOverviewCascadeNeighborBelowScrollShowsOneBand(t *testing.T) {360 func TestOverviewCascadeNeighborBelowScrollShowsOneBand(t *testing.T) {
361 m := cascadeOverview()361 m := cascadeOverview()
@@ -384,7 +384,7 @@ func TestOverviewCascadeNeighborBelowScrollShowsOneBand(t *testing.T) {
384 }384 }
385 }385 }
386 386
387-// T6: the stash pile is inside the current slot, left of the band; the387+// The stash pile is inside the current slot, left of the band; the
388 // previous workspace stays clear of it.388 // previous workspace stays clear of it.
389 func TestOverviewCascadeStashInSlot(t *testing.T) {389 func TestOverviewCascadeStashInSlot(t *testing.T) {
390 m := cascadeOverview()390 m := cascadeOverview()
@@ -449,7 +449,7 @@ func focusedID(m *Monitor) WindowID {
449 return id449 return id
450 }450 }
451 451
452-// T7: ↑/↓ change band and stop at the ends; ←/→ walk the band, then leave it452+// ↑/↓ change band and stop at the ends; ←/→ walk the band, then leave it
453 // for the stash or the neighbor workspaces; Escape restores everything.453 // for the stash or the neighbor workspaces; Escape restores everything.
454 func TestOverviewCascadeKeys(t *testing.T) {454 func TestOverviewCascadeKeys(t *testing.T) {
455 for _, d := range cascadeDrivers {455 for _, d := range cascadeDrivers {
@@ -530,7 +530,7 @@ func TestOverviewCascadeKeys(t *testing.T) {
530 }530 }
531 531
532 // T7b: with a stash card selected, up/down and the three-finger vertical532 // T7b: with a stash card selected, up/down and the three-finger vertical
533-// swipe cycle the pile as on main; confirming shows the picked window.533+// swipe cycle the pile as in a vertical overview; confirming shows the picked window.
534 func TestOverviewCascadeStashPile(t *testing.T) {534 func TestOverviewCascadeStashPile(t *testing.T) {
535 for _, d := range cascadeDrivers {535 for _, d := range cascadeDrivers {
536 t.Run(d.name, func(t *testing.T) {536 t.Run(d.name, func(t *testing.T) {
@@ -768,7 +768,7 @@ func TestOverviewCascadeStashToMaximized(t *testing.T) {
768 }768 }
769 }769 }
770 770
771-// T8: a named boundary of a cascade overview is a vertical rule strictly771+// A named boundary of a cascade overview is a vertical rule strictly
772 // between the two slots.772 // between the two slots.
773 func TestOverviewCascadeNamedDivider(t *testing.T) {773 func TestOverviewCascadeNamedDivider(t *testing.T) {
774 m := namedOverviewMonitor()774 m := namedOverviewMonitor()
@@ -787,7 +787,7 @@ func TestOverviewCascadeNamedDivider(t *testing.T) {
787 }787 }
788 }788 }
789 789
790-// T10: covering floats fan along the horizontal axis: left goes deeper in the790+// Covering floats fan along the horizontal axis: left goes deeper in the
791 // stack (the cards behind show on the left), right comes back to the front.791 // stack (the cards behind show on the left), right comes back to the front.
792 func TestOverviewCascadeCoveringFloat(t *testing.T) {792 func TestOverviewCascadeCoveringFloat(t *testing.T) {
793 for _, d := range cascadeDrivers {793 for _, d := range cascadeDrivers {
@@ -872,7 +872,7 @@ func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {
872 }872 }
873 }873 }
874 874
875-// T11: the focus binds keep their meaning in a cascade overview.875+// The focus binds keep their meaning in a cascade overview.
876 func TestOverviewCascadeFocusBinds(t *testing.T) {876 func TestOverviewCascadeFocusBinds(t *testing.T) {
877 m := cascadeOverview()877 m := cascadeOverview()
878 w := m.Current()878 w := m.Current()
@@ -913,6 +913,19 @@ func TestOverviewCascadeFocusBinds(t *testing.T) {
913 }913 }
914 }914 }
915 915
916+// A three-finger step that moves nothing reports no change: no scene
917+// transition, no keyboard grab. Here the first band of a cascade has no
918+// band above it.
919+func TestOverviewCascadeFingerNoopReportsNoChange(t *testing.T) {
920+ m := cascadeOverview()
921+ m.Current().FocusID(5)
922+ m.ToggleOverview()
923+ up := ports.PointerAxis{Source: ports.AxisFinger, Vertical: ports.ScrollAxis{Set: true, Value: -overviewScrollStep}}
924+ if m.overviewScroll(up) {
925+ t.Fatal("a step past the first band reported a change")
926+ }
927+}
928+
916 func TestOverviewMixedLayoutGestureAndCancel(t *testing.T) {929 func TestOverviewMixedLayoutGestureAndCancel(t *testing.T) {
917 m := overviewMonitor()930 m := overviewMonitor()
918 m.SetOverflow(OverflowFixed)931 m.SetOverflow(OverflowFixed)
M internal/core/overview_stack.go +1 -1
@@ -170,7 +170,7 @@ func (m *Monitor) moveStack(d int) bool {
170170 return false
171171 }
172172 m.setFront(w, items[at])
173- m.ov.cardOf, m.ov.card = nil, 0
173+ m.clearCard()
174174 return true
175175 }
176176
@@ -170,7 +170,7 @@ func (m *Monitor) moveStack(d int) bool {
170 return false170 return false
171 }171 }
172 m.setFront(w, items[at])172 m.setFront(w, items[at])
173- m.ov.cardOf, m.ov.card = nil, 0173+ m.clearCard()
174 return true174 return true
175 }175 }
176 176
M internal/core/overview_stash_input_test.go +24 -11
@@ -17,42 +17,55 @@ func TestOverviewStashInputNavigation(t *testing.T) {
1717 before := stashIDs(w)
1818 m.ToggleOverview()
1919 var at time.Duration
20- move := func(dx, dy int, cancelled bool) {
20+ send := func(ev ports.InputEvent) {
21+ t.Helper()
22+ if err := c.handleInput(t.Context(), ev); err != nil {
23+ t.Fatal(err)
24+ }
25+ }
26+ move := func(dx, dy int) {
2127 t.Helper()
2228 switch input {
2329 case "swipe":
2430 at += time.Second
25- c.swipeBegin(ports.SwipeBegin{Fingers: 3, Time: at})
31+ send(ports.SwipeBegin{Fingers: 3, Time: at})
2632 for range 3 {
2733 at += 8 * time.Millisecond
28- c.swipeUpdate(ports.SwipeUpdate{DX: float64(dx) * 40, DY: float64(dy) * 40, Time: at})
34+ send(ports.SwipeUpdate{DX: float64(dx) * 40, DY: float64(dy) * 40, Time: at})
2935 }
30- c.swipeEnd(ports.SwipeEnd{Cancelled: cancelled, Time: at})
36+ send(ports.SwipeEnd{Time: at})
3137 case "wheel":
3238 m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel,
3339 Horizontal: ports.ScrollAxis{Set: dx != 0, Value: float64(dx) * 70, V120: int32(dx * 120)},
3440 Vertical: ports.ScrollAxis{Set: dy != 0, Value: float64(dy) * 70, V120: int32(dy * 120)}})
3541 c.scrollStop(ports.PointerAxis{Vertical: ports.ScrollAxis{Stop: true}})
3642 case "bind":
37- a := ActionFocusWorkspaceNext
38- if dy < 0 {
43+ var a Action
44+ switch {
45+ case dx == 0 && dy < 0:
3946 a = ActionFocusWorkspacePrev
40- } else if dx > 0 {
47+ case dx == 0 && dy > 0:
48+ a = ActionFocusWorkspaceNext
49+ case dx > 0 && dy == 0:
4150 a = ActionFocusColumnRight
51+ case dx < 0 && dy == 0:
52+ a = ActionFocusColumnLeft
53+ default:
54+ t.Fatalf("no bind for move %d,%d", dx, dy)
4255 }
4356 m.overviewFocus(a)
4457 }
4558 }
46- move(0, -1, false)
59+ move(0, -1)
4760 if m.cardAt(w) != 1 {
4861 t.Fatalf("up selected card %d, want 1", m.cardAt(w))
4962 }
50- move(0, 1, false)
51- move(0, 1, false)
63+ move(0, 1)
64+ move(0, 1)
5265 if m.Current() != w || m.cardAt(w) != 2 || !slices.Equal(stashIDs(w), before) {
5366 t.Fatal("down escaped or reordered the stash")
5467 }
55- move(1, 0, false)
68+ move(1, 0)
5669 if m.cardAt(w) >= 0 {
5770 t.Fatal("right did not leave the stash")
5871 }
@@ -17,42 +17,55 @@ func TestOverviewStashInputNavigation(t *testing.T) {
17 before := stashIDs(w)17 before := stashIDs(w)
18 m.ToggleOverview()18 m.ToggleOverview()
19 var at time.Duration19 var at time.Duration
20- move := func(dx, dy int, cancelled bool) {20+ send := func(ev ports.InputEvent) {
21+ t.Helper()
22+ if err := c.handleInput(t.Context(), ev); err != nil {
23+ t.Fatal(err)
24+ }
25+ }
26+ move := func(dx, dy int) {
21 t.Helper()27 t.Helper()
22 switch input {28 switch input {
23 case "swipe":29 case "swipe":
24 at += time.Second30 at += time.Second
25- c.swipeBegin(ports.SwipeBegin{Fingers: 3, Time: at})31+ send(ports.SwipeBegin{Fingers: 3, Time: at})
26 for range 3 {32 for range 3 {
27 at += 8 * time.Millisecond33 at += 8 * time.Millisecond
28- c.swipeUpdate(ports.SwipeUpdate{DX: float64(dx) * 40, DY: float64(dy) * 40, Time: at})34+ send(ports.SwipeUpdate{DX: float64(dx) * 40, DY: float64(dy) * 40, Time: at})
29 }35 }
30- c.swipeEnd(ports.SwipeEnd{Cancelled: cancelled, Time: at})36+ send(ports.SwipeEnd{Time: at})
31 case "wheel":37 case "wheel":
32 m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel,38 m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel,
33 Horizontal: ports.ScrollAxis{Set: dx != 0, Value: float64(dx) * 70, V120: int32(dx * 120)},39 Horizontal: ports.ScrollAxis{Set: dx != 0, Value: float64(dx) * 70, V120: int32(dx * 120)},
34 Vertical: ports.ScrollAxis{Set: dy != 0, Value: float64(dy) * 70, V120: int32(dy * 120)}})40 Vertical: ports.ScrollAxis{Set: dy != 0, Value: float64(dy) * 70, V120: int32(dy * 120)}})
35 c.scrollStop(ports.PointerAxis{Vertical: ports.ScrollAxis{Stop: true}})41 c.scrollStop(ports.PointerAxis{Vertical: ports.ScrollAxis{Stop: true}})
36 case "bind":42 case "bind":
37- a := ActionFocusWorkspaceNext43+ var a Action
38- if dy < 0 {44+ switch {
45+ case dx == 0 && dy < 0:
39 a = ActionFocusWorkspacePrev46 a = ActionFocusWorkspacePrev
40- } else if dx > 0 {47+ case dx == 0 && dy > 0:
48+ a = ActionFocusWorkspaceNext
49+ case dx > 0 && dy == 0:
41 a = ActionFocusColumnRight50 a = ActionFocusColumnRight
51+ case dx < 0 && dy == 0:
52+ a = ActionFocusColumnLeft
53+ default:
54+ t.Fatalf("no bind for move %d,%d", dx, dy)
42 }55 }
43 m.overviewFocus(a)56 m.overviewFocus(a)
44 }57 }
45 }58 }
46- move(0, -1, false)59+ move(0, -1)
47 if m.cardAt(w) != 1 {60 if m.cardAt(w) != 1 {
48 t.Fatalf("up selected card %d, want 1", m.cardAt(w))61 t.Fatalf("up selected card %d, want 1", m.cardAt(w))
49 }62 }
50- move(0, 1, false)63+ move(0, 1)
51- move(0, 1, false)64+ move(0, 1)
52 if m.Current() != w || m.cardAt(w) != 2 || !slices.Equal(stashIDs(w), before) {65 if m.Current() != w || m.cardAt(w) != 2 || !slices.Equal(stashIDs(w), before) {
53 t.Fatal("down escaped or reordered the stash")66 t.Fatal("down escaped or reordered the stash")
54 }67 }
55- move(1, 0, false)68+ move(1, 0)
56 if m.cardAt(w) >= 0 {69 if m.cardAt(w) >= 0 {
57 t.Fatal("right did not leave the stash")70 t.Fatal("right did not leave the stash")
58 }71 }
M internal/core/slide.go +5 -5
@@ -37,7 +37,7 @@ func (s *slide) step(now time.Time) {
3737 // busy reports whether the slide is off its state or its spring runs.
3838 func (s *slide) busy() bool { return s.motion.on || s.off != 0 }
3939
40-// slidable reports whether a swipe can scroll the columns: only scroll
40+// slidable reports whether a swipe can scroll the columns: scroll or cascade
4141 // overflow, with the focus on the columns and no fullscreen window.
4242 func (w *Workspace) slidable() bool {
4343 return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat()
@@ -57,8 +57,8 @@ func (w *Workspace) retarget(before int) {
5757 func (w *Workspace) shiftPixels() int { return int(math.Round(w.view.off)) }
5858
5959 // switchSpring is the workspace slide's spring from off: it snaps once it
60-// is under half a logical pixel of the output height from its target
61-// (slideLayout rounds the offset times the height).
60+// is under half a logical pixel of the output span along the switch axis
61+// from its target (slideLayout rounds the offset times that span).
6262 func (m *Monitor) switchSpring(off, velocity float64) spring {
6363 s := workspaceSpring(off, velocity)
6464 if h := m.switchAxis.span(m.template.Output); h > 0 {
@@ -192,10 +192,10 @@ func (m *Monitor) slideLayout(cur []Placement) []Placement {
192192 h := float64(m.switchAxis.span(m.template.Output))
193193 pos := float64(base) + m.switchView.off
194194 offset := func(p []Placement, k int) []Placement {
195- dy := int(math.Round((float64(k) - pos) * h))
195+ d := int(math.Round((float64(k) - pos) * h))
196196 for i := range p {
197197 if !p[i].Hidden {
198- m.switchAxis.offset(&p[i].Rect, dy)
198+ m.switchAxis.offset(&p[i].Rect, d)
199199 }
200200 }
201201 return p
@@ -37,7 +37,7 @@ func (s *slide) step(now time.Time) {
37 // busy reports whether the slide is off its state or its spring runs.37 // busy reports whether the slide is off its state or its spring runs.
38 func (s *slide) busy() bool { return s.motion.on || s.off != 0 }38 func (s *slide) busy() bool { return s.motion.on || s.off != 0 }
39 39
40-// slidable reports whether a swipe can scroll the columns: only scroll40+// slidable reports whether a swipe can scroll the columns: scroll or cascade
41 // overflow, with the focus on the columns and no fullscreen window.41 // overflow, with the focus on the columns and no fullscreen window.
42 func (w *Workspace) slidable() bool {42 func (w *Workspace) slidable() bool {
43 return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat()43 return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat()
@@ -57,8 +57,8 @@ func (w *Workspace) retarget(before int) {
57 func (w *Workspace) shiftPixels() int { return int(math.Round(w.view.off)) }57 func (w *Workspace) shiftPixels() int { return int(math.Round(w.view.off)) }
58 58
59 // switchSpring is the workspace slide's spring from off: it snaps once it59 // switchSpring is the workspace slide's spring from off: it snaps once it
60-// is under half a logical pixel of the output height from its target60+// is under half a logical pixel of the output span along the switch axis
61-// (slideLayout rounds the offset times the height).61+// from its target (slideLayout rounds the offset times that span).
62 func (m *Monitor) switchSpring(off, velocity float64) spring {62 func (m *Monitor) switchSpring(off, velocity float64) spring {
63 s := workspaceSpring(off, velocity)63 s := workspaceSpring(off, velocity)
64 if h := m.switchAxis.span(m.template.Output); h > 0 {64 if h := m.switchAxis.span(m.template.Output); h > 0 {
@@ -192,10 +192,10 @@ func (m *Monitor) slideLayout(cur []Placement) []Placement {
192 h := float64(m.switchAxis.span(m.template.Output))192 h := float64(m.switchAxis.span(m.template.Output))
193 pos := float64(base) + m.switchView.off193 pos := float64(base) + m.switchView.off
194 offset := func(p []Placement, k int) []Placement {194 offset := func(p []Placement, k int) []Placement {
195- dy := int(math.Round((float64(k) - pos) * h))195+ d := int(math.Round((float64(k) - pos) * h))
196 for i := range p {196 for i := range p {
197 if !p[i].Hidden {197 if !p[i].Hidden {
198- m.switchAxis.offset(&p[i].Rect, dy)198+ m.switchAxis.offset(&p[i].Rect, d)
199 }199 }
200 }200 }
201 return p201 return p

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