detached c6b6c21

Merge pull request 'feat(layout): add Cascade bands and axis-aware navigation' (#8) from feat/cascade into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/8
commit
c6b6c21ba08b43640ed46329ee0e9278a549f96b
author
bnema · · 2026-10-06 09:11 UTC
merge of
2decdfb + 7609c0e · diff against the first parent
signature
unsigned
27 files · +608 -116
M docs/config.md +16 -5
@@ -30,7 +30,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
3030 | `border.active` / `border.inactive` | `#808080` / `#111111` | Colors for focused / other window lines; only the focused output lights up |
3131 | `layout.gaps` | `0` | Space between windows |
3232 | `layout.max-columns` | `2` | Columns that share the screen before scrolling |
33-| `layout.overflow` | `scroll` | Past the max: `scroll` right, or `fixed` spiral split |
33+| `layout.overflow` | `scroll` | Past the max: `scroll` right, `fixed` spiral split, or `cascade` into vertically scrolling bands |
3434 | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key |
3535 | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` (scroll overflow) |
3636 | `touchpad.natural-scroll` | `off` | Content follows the fingers, for two-finger scroll and three-finger swipes (see [Touchpad](#touchpad)) |
@@ -139,15 +139,16 @@ bind.Cmd+p = workspace presentation
139139 With `animations = on`, a three-finger swipe follows the fingers and moves one step at most, so a quick swipe never skips a column or a workspace:
140140
141141 - Left or right scrolls the columns (`scroll` overflow) to the next column edge; the focus moves to a column fully on screen.
142-- Up or down slides to the next numbered workspace.
142+- Up or down slides to the next numbered workspace in scroll and fixed layouts.
143+- In [Cascade](#cascade), up/down scrolls one band and left/right switches workspaces.
143144
144145 The view sticks a little near each step and catches up between them. Past the next step it resists; lift and swipe again to go further. When the fingers lift, a quick swipe goes on to the next step and a slow one settles on the closest.
145146
146-With `natural-scroll = off`, a swipe left shows the columns to the left and a swipe up shows the workspace above. `natural-scroll = on` moves the content with the fingers, so both are reversed.
147+In scroll and fixed layouts with `natural-scroll = off`, a swipe left shows the columns to the left and a swipe up shows the workspace above. `natural-scroll = on` moves the content with the fingers, so both are reversed.
147148
148-With `animations = off`, nothing moves during the swipe: when the fingers lift, a quick or long enough swipe runs `focus-column-left/right` or `focus-workspace-up/down`.
149+With `animations = off`, nothing moves during the swipe: when the fingers lift, a quick or long enough swipe navigates content or workspaces along the layout's axes. Cascade band swipes stop at the first and last band.
149150
150-Over the shown stash a sideways swipe slides it with the fingers, with a stop on each window, and moves one window at most. Where the view cannot scroll (`fixed` overflow, a floating or fullscreen window, a named workspace), a quick swipe runs `focus-column-left/right` or `focus-workspace-up/down` when the fingers lift.
151+Over the shown stash a sideways swipe slides it with the fingers, with a stop on each window, and moves one window at most. In scroll and fixed layouts where the view cannot scroll (a floating or fullscreen window, fixed overflow, or a named workspace), a quick swipe runs `focus-column-left/right` or `focus-workspace-up/down` when the fingers lift. Cascade keeps horizontal workspace navigation; vertical band swipes do nothing while a floating or fullscreen window has focus.
151152
152153 Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia.
153154
@@ -187,6 +188,16 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
187188 - `none` removes a default bind.
188189 - `spawn <command>` runs a program without a shell; see [Running commands](desktop.md#running-commands).
189190
191+### Cascade
192+
193+Set `layout.overflow = cascade` globally, per output, or on a named workspace. Columns fill equal-width cells, up to `max-columns` per band, then continue in a fresh band below. Incomplete bands keep empty cells. New columns append last; closing a column compacts the following bands. Each band fills the usable height after panel reservations.
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.
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-up/down` commands still mean previous/next workspace, even when the transition is horizontal. Four-finger and stash gestures keep their existing behaviour.
198+
199+The overview presents bands side by side in one workspace card. Selecting a window reveals its band. Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.
200+
190201 ### Actions
191202
192203 | Action | Effect |
@@ -30,7 +30,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
30 | `border.active` / `border.inactive` | `#808080` / `#111111` | Colors for focused / other window lines; only the focused output lights up |30 | `border.active` / `border.inactive` | `#808080` / `#111111` | Colors for focused / other window lines; only the focused output lights up |
31 | `layout.gaps` | `0` | Space between windows |31 | `layout.gaps` | `0` | Space between windows |
32 | `layout.max-columns` | `2` | Columns that share the screen before scrolling |32 | `layout.max-columns` | `2` | Columns that share the screen before scrolling |
33-| `layout.overflow` | `scroll` | Past the max: `scroll` right, or `fixed` spiral split |33+| `layout.overflow` | `scroll` | Past the max: `scroll` right, `fixed` spiral split, or `cascade` into vertically scrolling bands |
34 | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key |34 | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key |
35 | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` (scroll overflow) |35 | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` (scroll overflow) |
36 | `touchpad.natural-scroll` | `off` | Content follows the fingers, for two-finger scroll and three-finger swipes (see [Touchpad](#touchpad)) |36 | `touchpad.natural-scroll` | `off` | Content follows the fingers, for two-finger scroll and three-finger swipes (see [Touchpad](#touchpad)) |
@@ -139,15 +139,16 @@ bind.Cmd+p = workspace presentation
139 With `animations = on`, a three-finger swipe follows the fingers and moves one step at most, so a quick swipe never skips a column or a workspace:139 With `animations = on`, a three-finger swipe follows the fingers and moves one step at most, so a quick swipe never skips a column or a workspace:
140 140
141 - Left or right scrolls the columns (`scroll` overflow) to the next column edge; the focus moves to a column fully on screen.141 - Left or right scrolls the columns (`scroll` overflow) to the next column edge; the focus moves to a column fully on screen.
142-- Up or down slides to the next numbered workspace.142+- Up or down slides to the next numbered workspace in scroll and fixed layouts.
143+- In [Cascade](#cascade), up/down scrolls one band and left/right switches workspaces.
143 144
144 The view sticks a little near each step and catches up between them. Past the next step it resists; lift and swipe again to go further. When the fingers lift, a quick swipe goes on to the next step and a slow one settles on the closest.145 The view sticks a little near each step and catches up between them. Past the next step it resists; lift and swipe again to go further. When the fingers lift, a quick swipe goes on to the next step and a slow one settles on the closest.
145 146
146-With `natural-scroll = off`, a swipe left shows the columns to the left and a swipe up shows the workspace above. `natural-scroll = on` moves the content with the fingers, so both are reversed.147+In scroll and fixed layouts with `natural-scroll = off`, a swipe left shows the columns to the left and a swipe up shows the workspace above. `natural-scroll = on` moves the content with the fingers, so both are reversed.
147 148
148-With `animations = off`, nothing moves during the swipe: when the fingers lift, a quick or long enough swipe runs `focus-column-left/right` or `focus-workspace-up/down`.149+With `animations = off`, nothing moves during the swipe: when the fingers lift, a quick or long enough swipe navigates content or workspaces along the layout's axes. Cascade band swipes stop at the first and last band.
149 150
150-Over the shown stash a sideways swipe slides it with the fingers, with a stop on each window, and moves one window at most. Where the view cannot scroll (`fixed` overflow, a floating or fullscreen window, a named workspace), a quick swipe runs `focus-column-left/right` or `focus-workspace-up/down` when the fingers lift.151+Over the shown stash a sideways swipe slides it with the fingers, with a stop on each window, and moves one window at most. In scroll and fixed layouts where the view cannot scroll (a floating or fullscreen window, fixed overflow, or a named workspace), a quick swipe runs `focus-column-left/right` or `focus-workspace-up/down` when the fingers lift. Cascade keeps horizontal workspace navigation; vertical band swipes do nothing while a floating or fullscreen window has focus.
151 152
152 Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia.153 Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia.
153 154
@@ -187,6 +188,16 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
187 - `none` removes a default bind.188 - `none` removes a default bind.
188 - `spawn <command>` runs a program without a shell; see [Running commands](desktop.md#running-commands).189 - `spawn <command>` runs a program without a shell; see [Running commands](desktop.md#running-commands).
189 190
191+### Cascade
192+
193+Set `layout.overflow = cascade` globally, per output, or on a named workspace. Columns fill equal-width cells, up to `max-columns` per band, then continue in a fresh band below. Incomplete bands keep empty cells. New columns append last; closing a column compacts the following bands. Each band fills the usable height after panel reservations.
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.
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-up/down` commands still mean previous/next workspace, even when the transition is horizontal. Four-finger and stash gestures keep their existing behaviour.
198+
199+The overview presents bands side by side in one workspace card. Selecting a window reveals its band. Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.
200+
190 ### Actions201 ### Actions
191 202
192 | Action | Effect |203 | Action | Effect |
A internal/adapters/config/cascade_test.go +20 -0
@@ -0,0 +1,20 @@
1+package config
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
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")
10+ if len(w) != 0 {
11+ t.Fatalf("warnings=%v", w)
12+ }
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)
15+ }
16+ _, 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") {
18+ t.Fatalf("invalid overflow diagnostic: %v, %v", warnings, err)
19+ }
20+}
@@ -0,0 +1,20 @@
1+package config
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
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")
10+ if len(w) != 0 {
11+ t.Fatalf("warnings=%v", w)
12+ }
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)
15+ }
16+ _, 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") {
18+ t.Fatalf("invalid overflow diagnostic: %v, %v", warnings, err)
19+ }
20+}
M internal/adapters/config/config.go +4 -4
@@ -914,8 +914,8 @@ func setLayoutRule(r *ports.LayoutRules, field, v string) error {
914914 case "max-columns":
915915 return positive(&r.MaxColumns, v, 16)
916916 case "overflow":
917- if v != "scroll" && v != "fixed" {
918- return fmt.Errorf("must be scroll or fixed")
917+ if v != "scroll" && v != "fixed" && v != "cascade" {
918+ return fmt.Errorf("must be scroll, fixed or cascade")
919919 }
920920 r.Overflow = v
921921 return nil
@@ -952,9 +952,9 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
952952 rules = o.Over(rules)
953953 }
954954 }
955- if w.Over(rules).Overflow == "fixed" && len(w.Slots) > 0 {
955+ if overflow := w.Over(rules).Overflow; (overflow == "fixed" || overflow == "cascade") && len(w.Slots) > 0 {
956956 line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)]
957- warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = fixed (columns share the width)", w.Name)})
957+ warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = %s (columns share the width)", w.Name, overflow)})
958958 }
959959 if !bound[w.Name] {
960960 line := 0
@@ -914,8 +914,8 @@ func setLayoutRule(r *ports.LayoutRules, field, v string) error {
914 case "max-columns":914 case "max-columns":
915 return positive(&r.MaxColumns, v, 16)915 return positive(&r.MaxColumns, v, 16)
916 case "overflow":916 case "overflow":
917- if v != "scroll" && v != "fixed" {917+ if v != "scroll" && v != "fixed" && v != "cascade" {
918- return fmt.Errorf("must be scroll or fixed")918+ return fmt.Errorf("must be scroll, fixed or cascade")
919 }919 }
920 r.Overflow = v920 r.Overflow = v
921 return nil921 return nil
@@ -952,9 +952,9 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
952 rules = o.Over(rules)952 rules = o.Over(rules)
953 }953 }
954 }954 }
955- if w.Over(rules).Overflow == "fixed" && len(w.Slots) > 0 {955+ if overflow := w.Over(rules).Overflow; (overflow == "fixed" || overflow == "cascade") && len(w.Slots) > 0 {
956 line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)]956 line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)]
957- warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = fixed (columns share the width)", w.Name)})957+ warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = %s (columns share the width)", w.Name, overflow)})
958 }958 }
959 if !bound[w.Name] {959 if !bound[w.Name] {
960 line := 0960 line := 0
A internal/core/cascade.go +79 -0
@@ -0,0 +1,79 @@
1+package core
2+
3+import "slices"
4+
5+func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) }
6+func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) }
7+func (w *Workspace) cellX(i int) int {
8+ return w.gap() + (i%max(w.MaxColumns, 1))*(w.cellWidth()+w.gap())
9+}
10+func (w *Workspace) cascadeBands() (n, spacing int) {
11+ if len(w.Columns) == 0 || w.Usable.H <= 0 {
12+ return 1, 0
13+ }
14+ return w.band(len(w.Columns)-1) + 1, w.Usable.H
15+}
16+func (w *Workspace) cellWidth() int {
17+ k := max(w.MaxColumns, 1)
18+ return max((w.Usable.W-w.gap()*(k+1))/k, 0)
19+}
20+func (w *Workspace) cellInBand(b int) int {
21+ k := max(w.MaxColumns, 1)
22+ return min(b*k+w.Focus%k, len(w.Columns)-1)
23+}
24+func (w *Workspace) focusBand(dir int) bool {
25+ i := w.bandNeighbor(dir)
26+ if i < 0 {
27+ return false
28+ }
29+ w.unmaximize()
30+ w.Focus = i
31+ w.Columns[i].Focus = 0
32+ if dir < 0 {
33+ w.Columns[i].Focus = len(w.Columns[i].Windows) - 1
34+ }
35+ w.raiseColumns()
36+ w.scroll()
37+ return true
38+}
39+func (w *Workspace) bandNeighbor(dir int) int {
40+ k := max(w.MaxColumns, 1)
41+ b := w.band(w.Focus) + dir
42+ if b < 0 || b*k >= len(w.Columns) {
43+ return -1
44+ }
45+ return w.cellInBand(b)
46+}
47+
48+func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
49+ g := w.gap()
50+ width := w.cellWidth()
51+ rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
52+ view := w.View + w.shiftPixels()
53+ for i := range w.Columns {
54+ r := Rect{X: w.Usable.X + w.cellX(i), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: width, H: max(w.Usable.H-2*g, 0)}
55+ if w.Columns[i].FullWidth && !ignoreFullWidth {
56+ r.X = w.Usable.X + g
57+ r.W = max(w.Usable.W-2*g, 0)
58+ }
59+ rects[i] = r
60+ }
61+ return rects
62+}
63+
64+func (w *Workspace) cascadePoints() []float64 {
65+ n, spacing := w.cascadeBands()
66+ points := make([]float64, n)
67+ for i := range points {
68+ points[i] = float64(i * spacing)
69+ }
70+ return points
71+}
72+
73+func (w *Workspace) cascadeFocus(view int) int {
74+ if len(w.Columns) == 0 {
75+ return 0
76+ }
77+ b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1))
78+ return w.cellInBand(b)
79+}
@@ -0,0 +1,79 @@
1+package core
2+
3+import "slices"
4+
5+func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) }
6+func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) }
7+func (w *Workspace) cellX(i int) int {
8+ return w.gap() + (i%max(w.MaxColumns, 1))*(w.cellWidth()+w.gap())
9+}
10+func (w *Workspace) cascadeBands() (n, spacing int) {
11+ if len(w.Columns) == 0 || w.Usable.H <= 0 {
12+ return 1, 0
13+ }
14+ return w.band(len(w.Columns)-1) + 1, w.Usable.H
15+}
16+func (w *Workspace) cellWidth() int {
17+ k := max(w.MaxColumns, 1)
18+ return max((w.Usable.W-w.gap()*(k+1))/k, 0)
19+}
20+func (w *Workspace) cellInBand(b int) int {
21+ k := max(w.MaxColumns, 1)
22+ return min(b*k+w.Focus%k, len(w.Columns)-1)
23+}
24+func (w *Workspace) focusBand(dir int) bool {
25+ i := w.bandNeighbor(dir)
26+ if i < 0 {
27+ return false
28+ }
29+ w.unmaximize()
30+ w.Focus = i
31+ w.Columns[i].Focus = 0
32+ if dir < 0 {
33+ w.Columns[i].Focus = len(w.Columns[i].Windows) - 1
34+ }
35+ w.raiseColumns()
36+ w.scroll()
37+ return true
38+}
39+func (w *Workspace) bandNeighbor(dir int) int {
40+ k := max(w.MaxColumns, 1)
41+ b := w.band(w.Focus) + dir
42+ if b < 0 || b*k >= len(w.Columns) {
43+ return -1
44+ }
45+ return w.cellInBand(b)
46+}
47+
48+func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
49+ g := w.gap()
50+ width := w.cellWidth()
51+ rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
52+ view := w.View + w.shiftPixels()
53+ for i := range w.Columns {
54+ r := Rect{X: w.Usable.X + w.cellX(i), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: width, H: max(w.Usable.H-2*g, 0)}
55+ if w.Columns[i].FullWidth && !ignoreFullWidth {
56+ r.X = w.Usable.X + g
57+ r.W = max(w.Usable.W-2*g, 0)
58+ }
59+ rects[i] = r
60+ }
61+ return rects
62+}
63+
64+func (w *Workspace) cascadePoints() []float64 {
65+ n, spacing := w.cascadeBands()
66+ points := make([]float64, n)
67+ for i := range points {
68+ points[i] = float64(i * spacing)
69+ }
70+ return points
71+}
72+
73+func (w *Workspace) cascadeFocus(view int) int {
74+ if len(w.Columns) == 0 {
75+ return 0
76+ }
77+ b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1))
78+ return w.cellInBand(b)
79+}
A internal/core/cascade_swipe_test.go +48 -0
@@ -0,0 +1,48 @@
1+package core_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestCascadeSwipeAxes(t *testing.T) {
11+ r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "cascade"; c.Layout.MaxColumns = 3 })
12+ for i := 1; i <= 7; i++ {
13+ r.mapWindow(t, ports.WindowID(i))
14+ }
15+ r.begin()
16+ s := r.move(t, 0, -400)
17+ band, ok := rectOf(s, 7)
18+ if !ok || band.X != 0 || band.Y <= 0 {
19+ t.Fatalf("band swipe must move vertically: %v, shown=%v", band, ok)
20+ }
21+ r.end(t, false)
22+ r.settleAll(t, r.outs...)
23+ r.begin()
24+ s = r.move(t, 400, 0)
25+ workspace, ok := rectOf(s, 7)
26+ if !ok || workspace.X >= 0 || workspace.Y != 0 {
27+ t.Fatalf("workspace swipe must move horizontally: %v, shown=%v", workspace, ok)
28+ }
29+ r.end(t, true)
30+ r.settleAll(t, r.outs...)
31+}
32+
33+func TestCascadeDiscreteSwipeRevealsBand(t *testing.T) {
34+ r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "cascade"; c.Layout.MaxColumns = 3; c.Animations.On = false })
35+ for i := 1; i <= 4; i++ {
36+ r.mapWindow(t, ports.WindowID(i))
37+ }
38+ r.begin()
39+ for range 10 {
40+ r.at += 8 * time.Millisecond
41+ r.input <- ports.SwipeUpdate{DY: -40, Time: r.at}
42+ }
43+ s := r.end(t, false)
44+ p, ok := rectOf(s, 1)
45+ if !ok || p.Y != 0 {
46+ t.Fatalf("band not revealed: %v shown=%v", p, ok)
47+ }
48+}
@@ -0,0 +1,48 @@
1+package core_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestCascadeSwipeAxes(t *testing.T) {
11+ r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "cascade"; c.Layout.MaxColumns = 3 })
12+ for i := 1; i <= 7; i++ {
13+ r.mapWindow(t, ports.WindowID(i))
14+ }
15+ r.begin()
16+ s := r.move(t, 0, -400)
17+ band, ok := rectOf(s, 7)
18+ if !ok || band.X != 0 || band.Y <= 0 {
19+ t.Fatalf("band swipe must move vertically: %v, shown=%v", band, ok)
20+ }
21+ r.end(t, false)
22+ r.settleAll(t, r.outs...)
23+ r.begin()
24+ s = r.move(t, 400, 0)
25+ workspace, ok := rectOf(s, 7)
26+ if !ok || workspace.X >= 0 || workspace.Y != 0 {
27+ t.Fatalf("workspace swipe must move horizontally: %v, shown=%v", workspace, ok)
28+ }
29+ r.end(t, true)
30+ r.settleAll(t, r.outs...)
31+}
32+
33+func TestCascadeDiscreteSwipeRevealsBand(t *testing.T) {
34+ r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "cascade"; c.Layout.MaxColumns = 3; c.Animations.On = false })
35+ for i := 1; i <= 4; i++ {
36+ r.mapWindow(t, ports.WindowID(i))
37+ }
38+ r.begin()
39+ for range 10 {
40+ r.at += 8 * time.Millisecond
41+ r.input <- ports.SwipeUpdate{DY: -40, Time: r.at}
42+ }
43+ s := r.end(t, false)
44+ p, ok := rectOf(s, 1)
45+ if !ok || p.Y != 0 {
46+ t.Fatalf("band not revealed: %v shown=%v", p, ok)
47+ }
48+}
A internal/core/cascade_test.go +213 -0
@@ -0,0 +1,213 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+func cascadeWorkspace(n int) *Workspace {
9+ w := &Workspace{Overflow: OverflowCascade, MaxColumns: 3}
10+ w.SetOutput(900, 600)
11+ for i := 1; i <= n; i++ {
12+ w.AddWindow(WindowID(i))
13+ }
14+ return w
15+}
16+
17+func TestCascadeBandsAndReveal(t *testing.T) {
18+ for _, n := range []int{0, 1, 3, 4, 7} {
19+ w := cascadeWorkspace(n)
20+ ps := w.Layout()
21+ if len(ps) != n {
22+ t.Fatalf("n=%d placements=%d", n, len(ps))
23+ }
24+ for i, p := range ps {
25+ want := Rect{X: i % 3 * 300, Y: (i/3)*600 - w.View, W: 300, H: 600}
26+ if p.Rect != want {
27+ t.Fatalf("n=%d window=%d rect=%v want=%v", n, p.ID, p.Rect, want)
28+ }
29+ }
30+ if n > 0 && w.View != ((n-1)/3)*600 {
31+ t.Fatalf("n=%d view=%d", n, w.View)
32+ }
33+ }
34+}
35+
36+func TestCascadeCompactionAndNavigation(t *testing.T) {
37+ w := cascadeWorkspace(7)
38+ w.FocusID(2)
39+ if !w.FocusWindow(1) {
40+ t.Fatal("no lower band")
41+ }
42+ if id, _ := w.Focused(); id != 5 {
43+ t.Fatalf("focus=%d", id)
44+ }
45+ w.RemoveWindow(1)
46+ if w.View != 600 {
47+ t.Fatalf("compacted view=%d", w.View)
48+ }
49+ w.SetMaxColumns(2)
50+ if w.View != 600 {
51+ t.Fatalf("changed max view=%d", w.View)
52+ }
53+ w.FocusID(2)
54+ w.AddWindow(8)
55+ if w.Columns[len(w.Columns)-1].Windows[0] != 8 {
56+ t.Fatal("new column did not append")
57+ }
58+ w.SetUsable(Rect{X: 0, Y: 20, W: 900, H: 580})
59+ if w.View != w.band(w.Focus)*580 {
60+ t.Fatal("resize did not reveal band")
61+ }
62+ if !slices.Equal(w.snapPoints(), []float64{0, 580, 1160, 1740}) {
63+ t.Fatalf("points=%v", w.snapPoints())
64+ }
65+}
66+
67+func TestCascadeCovers(t *testing.T) {
68+ w := cascadeWorkspace(4)
69+ w.FocusID(2)
70+ w.ToggleFullWidth()
71+ ps := w.Layout()
72+ for _, p := range ps {
73+ if p.ID == 2 && p.Rect.W != 900 {
74+ t.Fatalf("full width=%v", p.Rect)
75+ }
76+ if (p.ID == 1 || p.ID == 3) && !p.Hidden {
77+ t.Fatalf("sibling %d visible", p.ID)
78+ }
79+ }
80+ w.ToggleFullWidth()
81+ w.ToggleFullscreen()
82+ for _, p := range w.Layout() {
83+ if p.ID == 2 && p.Rect != w.Output {
84+ t.Fatalf("fullscreen=%v", p.Rect)
85+ }
86+ if p.ID != 2 && !p.Hidden {
87+ t.Fatalf("fullscreen sibling %d visible", p.ID)
88+ }
89+ }
90+ w.ToggleFullscreen()
91+ w.FocusID(4)
92+ if w.View != 600 {
93+ t.Fatal("fullscreen restore lost band reveal")
94+ }
95+}
96+
97+func TestCascadeClearsMaximizationOnBandChanges(t *testing.T) {
98+ for _, move := range []struct {
99+ name string
100+ run func(*Workspace)
101+ }{
102+ {"focus", func(w *Workspace) { w.FocusWindow(1) }},
103+ {"append", func(w *Workspace) { w.AddWindow(5) }},
104+ {"insert", func(w *Workspace) { w.insertColumn(4, Column{Windows: []WindowID{5}}) }},
105+ } {
106+ t.Run(move.name, func(t *testing.T) {
107+ w := cascadeWorkspace(4)
108+ w.FocusID(1)
109+ w.ToggleFullWidth()
110+ move.run(w)
111+ for _, col := range w.Columns {
112+ if col.FullWidth {
113+ t.Fatal("stale maximization")
114+ }
115+ }
116+ })
117+ }
118+}
119+
120+func TestCascadeReloadAndStashRestoreClearMaximization(t *testing.T) {
121+ w := cascadeWorkspace(3)
122+ w.FocusID(1)
123+ w.ToggleFullWidth()
124+ w.restore(4, &origPlace{stacked: []WindowID{2}, col: 1})
125+ for _, c := range w.Columns {
126+ if c.FullWidth {
127+ t.Fatal("restore retained maximization")
128+ }
129+ }
130+ m := NewMonitor()
131+ m.SetOutput(900, 600)
132+ for i := WindowID(1); i <= 3; i++ {
133+ m.AddWindow(i)
134+ }
135+ m.Current().FocusID(1)
136+ m.Current().ToggleFullWidth()
137+ m.Current().FocusID(3)
138+ m.SetOverflow(OverflowCascade)
139+ for _, c := range m.Current().Columns {
140+ if c.FullWidth {
141+ t.Fatal("reload retained maximization")
142+ }
143+ }
144+}
145+
146+func TestCascadeGestureValidityDoesNotAllocate(t *testing.T) {
147+ m := NewMonitor()
148+ m.SetOutput(900, 600)
149+ m.SetOverflow(OverflowCascade)
150+ for i := WindowID(1); i <= 7; i++ {
151+ m.AddWindow(i)
152+ }
153+ g := &swipeGesture{ws: m.Current(), points: m.Current().snapPoints(), horizontal: false, opens: m.overviewOpens}
154+ if n := testing.AllocsPerRun(100, func() {
155+ if g.columnsChanged(m) {
156+ t.Fatal("unchanged gesture invalidated")
157+ }
158+ }); n != 0 {
159+ t.Fatalf("allocations=%v", n)
160+ }
161+}
162+
163+func TestCascadeGeometryReusesBuffer(t *testing.T) {
164+ w := cascadeWorkspace(7)
165+ buf := w.columnRectsInto(nil, false)
166+ if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 {
167+ t.Fatalf("allocations=%v", n)
168+ }
169+}
170+
171+func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) {
172+ m := NewMonitor()
173+ m.SetOutput(900, 600)
174+ m.Current().Overflow = OverflowCascade
175+ m.AddWindow(1)
176+ m.Focus(1)
177+ m.AddWindow(2)
178+ m.Current().Overflow = OverflowScroll
179+ m.Focus(0)
180+ m.switchAxis = m.Current().policy().workspace
181+ m.switchView.off = .5
182+ ps := m.layoutInto(nil)
183+ for _, p := range ps {
184+ if p.ID == 1 && (p.Rect.X != -450 || p.Rect.Y != 0) {
185+ t.Fatalf("cascade source offset=%v", p.Rect)
186+ }
187+ }
188+ m.stopSwitch()
189+ m.Focus(1)
190+ m.switchAxis = m.Current().policy().workspace
191+ m.switchView.off = -.5
192+ ps = m.layoutInto(nil)
193+ for _, p := range ps {
194+ if p.ID == 2 && (p.Rect.X != 0 || p.Rect.Y != 300) {
195+ t.Fatalf("scroll source offset=%v", p.Rect)
196+ }
197+ }
198+}
199+
200+func TestCascadeViewportSnap(t *testing.T) {
201+ w := cascadeWorkspace(7)
202+ w.FocusID(3)
203+ if w.snapFocus(600, true) != 5 {
204+ t.Fatalf("nearest cell=%d", w.snapFocus(600, true))
205+ }
206+ if w.snapFocus(1200, true) != 6 {
207+ t.Fatal("short band neighbour")
208+ }
209+ w.SetUsable(Rect{})
210+ if got := w.snapPoints(); len(got) != 1 || got[0] != 0 {
211+ t.Fatalf("zero area=%v", got)
212+ }
213+}
@@ -0,0 +1,213 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+func cascadeWorkspace(n int) *Workspace {
9+ w := &Workspace{Overflow: OverflowCascade, MaxColumns: 3}
10+ w.SetOutput(900, 600)
11+ for i := 1; i <= n; i++ {
12+ w.AddWindow(WindowID(i))
13+ }
14+ return w
15+}
16+
17+func TestCascadeBandsAndReveal(t *testing.T) {
18+ for _, n := range []int{0, 1, 3, 4, 7} {
19+ w := cascadeWorkspace(n)
20+ ps := w.Layout()
21+ if len(ps) != n {
22+ t.Fatalf("n=%d placements=%d", n, len(ps))
23+ }
24+ for i, p := range ps {
25+ want := Rect{X: i % 3 * 300, Y: (i/3)*600 - w.View, W: 300, H: 600}
26+ if p.Rect != want {
27+ t.Fatalf("n=%d window=%d rect=%v want=%v", n, p.ID, p.Rect, want)
28+ }
29+ }
30+ if n > 0 && w.View != ((n-1)/3)*600 {
31+ t.Fatalf("n=%d view=%d", n, w.View)
32+ }
33+ }
34+}
35+
36+func TestCascadeCompactionAndNavigation(t *testing.T) {
37+ w := cascadeWorkspace(7)
38+ w.FocusID(2)
39+ if !w.FocusWindow(1) {
40+ t.Fatal("no lower band")
41+ }
42+ if id, _ := w.Focused(); id != 5 {
43+ t.Fatalf("focus=%d", id)
44+ }
45+ w.RemoveWindow(1)
46+ if w.View != 600 {
47+ t.Fatalf("compacted view=%d", w.View)
48+ }
49+ w.SetMaxColumns(2)
50+ if w.View != 600 {
51+ t.Fatalf("changed max view=%d", w.View)
52+ }
53+ w.FocusID(2)
54+ w.AddWindow(8)
55+ if w.Columns[len(w.Columns)-1].Windows[0] != 8 {
56+ t.Fatal("new column did not append")
57+ }
58+ w.SetUsable(Rect{X: 0, Y: 20, W: 900, H: 580})
59+ if w.View != w.band(w.Focus)*580 {
60+ t.Fatal("resize did not reveal band")
61+ }
62+ if !slices.Equal(w.snapPoints(), []float64{0, 580, 1160, 1740}) {
63+ t.Fatalf("points=%v", w.snapPoints())
64+ }
65+}
66+
67+func TestCascadeCovers(t *testing.T) {
68+ w := cascadeWorkspace(4)
69+ w.FocusID(2)
70+ w.ToggleFullWidth()
71+ ps := w.Layout()
72+ for _, p := range ps {
73+ if p.ID == 2 && p.Rect.W != 900 {
74+ t.Fatalf("full width=%v", p.Rect)
75+ }
76+ if (p.ID == 1 || p.ID == 3) && !p.Hidden {
77+ t.Fatalf("sibling %d visible", p.ID)
78+ }
79+ }
80+ w.ToggleFullWidth()
81+ w.ToggleFullscreen()
82+ for _, p := range w.Layout() {
83+ if p.ID == 2 && p.Rect != w.Output {
84+ t.Fatalf("fullscreen=%v", p.Rect)
85+ }
86+ if p.ID != 2 && !p.Hidden {
87+ t.Fatalf("fullscreen sibling %d visible", p.ID)
88+ }
89+ }
90+ w.ToggleFullscreen()
91+ w.FocusID(4)
92+ if w.View != 600 {
93+ t.Fatal("fullscreen restore lost band reveal")
94+ }
95+}
96+
97+func TestCascadeClearsMaximizationOnBandChanges(t *testing.T) {
98+ for _, move := range []struct {
99+ name string
100+ run func(*Workspace)
101+ }{
102+ {"focus", func(w *Workspace) { w.FocusWindow(1) }},
103+ {"append", func(w *Workspace) { w.AddWindow(5) }},
104+ {"insert", func(w *Workspace) { w.insertColumn(4, Column{Windows: []WindowID{5}}) }},
105+ } {
106+ t.Run(move.name, func(t *testing.T) {
107+ w := cascadeWorkspace(4)
108+ w.FocusID(1)
109+ w.ToggleFullWidth()
110+ move.run(w)
111+ for _, col := range w.Columns {
112+ if col.FullWidth {
113+ t.Fatal("stale maximization")
114+ }
115+ }
116+ })
117+ }
118+}
119+
120+func TestCascadeReloadAndStashRestoreClearMaximization(t *testing.T) {
121+ w := cascadeWorkspace(3)
122+ w.FocusID(1)
123+ w.ToggleFullWidth()
124+ w.restore(4, &origPlace{stacked: []WindowID{2}, col: 1})
125+ for _, c := range w.Columns {
126+ if c.FullWidth {
127+ t.Fatal("restore retained maximization")
128+ }
129+ }
130+ m := NewMonitor()
131+ m.SetOutput(900, 600)
132+ for i := WindowID(1); i <= 3; i++ {
133+ m.AddWindow(i)
134+ }
135+ m.Current().FocusID(1)
136+ m.Current().ToggleFullWidth()
137+ m.Current().FocusID(3)
138+ m.SetOverflow(OverflowCascade)
139+ for _, c := range m.Current().Columns {
140+ if c.FullWidth {
141+ t.Fatal("reload retained maximization")
142+ }
143+ }
144+}
145+
146+func TestCascadeGestureValidityDoesNotAllocate(t *testing.T) {
147+ m := NewMonitor()
148+ m.SetOutput(900, 600)
149+ m.SetOverflow(OverflowCascade)
150+ for i := WindowID(1); i <= 7; i++ {
151+ m.AddWindow(i)
152+ }
153+ g := &swipeGesture{ws: m.Current(), points: m.Current().snapPoints(), horizontal: false, opens: m.overviewOpens}
154+ if n := testing.AllocsPerRun(100, func() {
155+ if g.columnsChanged(m) {
156+ t.Fatal("unchanged gesture invalidated")
157+ }
158+ }); n != 0 {
159+ t.Fatalf("allocations=%v", n)
160+ }
161+}
162+
163+func TestCascadeGeometryReusesBuffer(t *testing.T) {
164+ w := cascadeWorkspace(7)
165+ buf := w.columnRectsInto(nil, false)
166+ if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 {
167+ t.Fatalf("allocations=%v", n)
168+ }
169+}
170+
171+func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) {
172+ m := NewMonitor()
173+ m.SetOutput(900, 600)
174+ m.Current().Overflow = OverflowCascade
175+ m.AddWindow(1)
176+ m.Focus(1)
177+ m.AddWindow(2)
178+ m.Current().Overflow = OverflowScroll
179+ m.Focus(0)
180+ m.switchAxis = m.Current().policy().workspace
181+ m.switchView.off = .5
182+ ps := m.layoutInto(nil)
183+ for _, p := range ps {
184+ if p.ID == 1 && (p.Rect.X != -450 || p.Rect.Y != 0) {
185+ t.Fatalf("cascade source offset=%v", p.Rect)
186+ }
187+ }
188+ m.stopSwitch()
189+ m.Focus(1)
190+ m.switchAxis = m.Current().policy().workspace
191+ m.switchView.off = -.5
192+ ps = m.layoutInto(nil)
193+ for _, p := range ps {
194+ if p.ID == 2 && (p.Rect.X != 0 || p.Rect.Y != 300) {
195+ t.Fatalf("scroll source offset=%v", p.Rect)
196+ }
197+ }
198+}
199+
200+func TestCascadeViewportSnap(t *testing.T) {
201+ w := cascadeWorkspace(7)
202+ w.FocusID(3)
203+ if w.snapFocus(600, true) != 5 {
204+ t.Fatalf("nearest cell=%d", w.snapFocus(600, true))
205+ }
206+ if w.snapFocus(1200, true) != 6 {
207+ t.Fatal("short band neighbour")
208+ }
209+ w.SetUsable(Rect{})
210+ if got := w.snapPoints(); len(got) != 1 || got[0] != 0 {
211+ t.Fatalf("zero area=%v", got)
212+ }
213+}
M internal/core/constraint_frame_test.go +1 -1
@@ -27,7 +27,7 @@ func partlyShown(t *testing.T) *Core {
2727 for id := WindowID(1); id <= 3; id++ {
2828 s.mon.AddWindow(id)
2929 }
30- s.mon.Current().ViewX += 25
30+ s.mon.Current().View += 25
3131 return c
3232 }
3333
@@ -27,7 +27,7 @@ func partlyShown(t *testing.T) *Core {
27 for id := WindowID(1); id <= 3; id++ {27 for id := WindowID(1); id <= 3; id++ {
28 s.mon.AddWindow(id)28 s.mon.AddWindow(id)
29 }29 }
30- s.mon.Current().ViewX += 2530+ s.mon.Current().View += 25
31 return c31 return c
32 }32 }
33 33
M internal/core/core.go +1 -1
@@ -251,7 +251,7 @@ func (c *Core) apply(cfg ports.Config) error {
251251 }
252252 overflow := func(v string) (Overflow, error) {
253253 switch o := Overflow(v); o {
254- case "", OverflowScroll, OverflowFixed:
254+ case "", OverflowScroll, OverflowFixed, OverflowCascade:
255255 return o, nil
256256 }
257257 return "", fmt.Errorf("invalid overflow %q", v)
@@ -251,7 +251,7 @@ func (c *Core) apply(cfg ports.Config) error {
251 }251 }
252 overflow := func(v string) (Overflow, error) {252 overflow := func(v string) (Overflow, error) {
253 switch o := Overflow(v); o {253 switch o := Overflow(v); o {
254- case "", OverflowScroll, OverflowFixed:254+ case "", OverflowScroll, OverflowFixed, OverflowCascade:
255 return o, nil255 return o, nil
256 }256 }
257 return "", fmt.Errorf("invalid overflow %q", v)257 return "", fmt.Errorf("invalid overflow %q", v)
M internal/core/drag.go +5 -5
@@ -340,7 +340,7 @@ func (w *Workspace) dropIn(id WindowID, x, y, t int) dropTarget {
340340 r := Rect{X: w.Usable.X + g, Y: w.Usable.Y + g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)}
341341 return dropTarget{kind: dropInsert, hints: outline(r, t)}
342342 }
343- if w.Overflow != OverflowFixed {
343+ if !w.policy().equalCells {
344344 if tgt, ok := w.gapDrop(cols, id, src, lone, x, y, t); ok {
345345 return tgt
346346 }
@@ -531,7 +531,7 @@ func (c *Core) edgeScroll(t time.Duration) bool {
531531 }
532532 sc := c.screens[i]
533533 w := sc.mon.Current()
534- if w.Overflow == OverflowFixed || len(w.Columns) == 0 || w.pinned() {
534+ if w.policy().equalCells || len(w.Columns) == 0 || w.pinned() {
535535 return false
536536 }
537537 lx := int(c.cursorX) - sc.x
@@ -557,7 +557,7 @@ func (w *Workspace) scrollBy(dir int) bool {
557557 g := w.gap()
558558 n := len(w.Columns)
559559 maxView := max(w.columnX(n-1)+w.columnWidth(n-1)+g-(w.Usable.X+w.Usable.W), 0)
560- view := w.ViewX
560+ view := w.View
561561 if dir < 0 {
562562 // The first column not fully on screen at the left.
563563 for i := n - 1; i >= 0; i-- {
@@ -575,11 +575,11 @@ func (w *Workspace) scrollBy(dir int) bool {
575575 }
576576 }
577577 view = min(max(view, 0), maxView)
578- if view == w.ViewX {
578+ if view == w.View {
579579 return false
580580 }
581581 w.view.stop()
582- w.ViewX = view
582+ w.View = view
583583 return true
584584 }
585585
@@ -340,7 +340,7 @@ func (w *Workspace) dropIn(id WindowID, x, y, t int) dropTarget {
340 r := Rect{X: w.Usable.X + g, Y: w.Usable.Y + g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)}340 r := Rect{X: w.Usable.X + g, Y: w.Usable.Y + g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)}
341 return dropTarget{kind: dropInsert, hints: outline(r, t)}341 return dropTarget{kind: dropInsert, hints: outline(r, t)}
342 }342 }
343- if w.Overflow != OverflowFixed {343+ if !w.policy().equalCells {
344 if tgt, ok := w.gapDrop(cols, id, src, lone, x, y, t); ok {344 if tgt, ok := w.gapDrop(cols, id, src, lone, x, y, t); ok {
345 return tgt345 return tgt
346 }346 }
@@ -531,7 +531,7 @@ func (c *Core) edgeScroll(t time.Duration) bool {
531 }531 }
532 sc := c.screens[i]532 sc := c.screens[i]
533 w := sc.mon.Current()533 w := sc.mon.Current()
534- if w.Overflow == OverflowFixed || len(w.Columns) == 0 || w.pinned() {534+ if w.policy().equalCells || len(w.Columns) == 0 || w.pinned() {
535 return false535 return false
536 }536 }
537 lx := int(c.cursorX) - sc.x537 lx := int(c.cursorX) - sc.x
@@ -557,7 +557,7 @@ func (w *Workspace) scrollBy(dir int) bool {
557 g := w.gap()557 g := w.gap()
558 n := len(w.Columns)558 n := len(w.Columns)
559 maxView := max(w.columnX(n-1)+w.columnWidth(n-1)+g-(w.Usable.X+w.Usable.W), 0)559 maxView := max(w.columnX(n-1)+w.columnWidth(n-1)+g-(w.Usable.X+w.Usable.W), 0)
560- view := w.ViewX560+ view := w.View
561 if dir < 0 {561 if dir < 0 {
562 // The first column not fully on screen at the left.562 // The first column not fully on screen at the left.
563 for i := n - 1; i >= 0; i-- {563 for i := n - 1; i >= 0; i-- {
@@ -575,11 +575,11 @@ func (w *Workspace) scrollBy(dir int) bool {
575 }575 }
576 }576 }
577 view = min(max(view, 0), maxView)577 view = min(max(view, 0), maxView)
578- if view == w.ViewX {578+ if view == w.View {
579 return false579 return false
580 }580 }
581 w.view.stop()581 w.view.stop()
582- w.ViewX = view582+ w.View = view
583 return true583 return true
584 }584 }
585 585
M internal/core/drag_internal_test.go +5 -5
@@ -36,8 +36,8 @@ func TestScrollBy(t *testing.T) {
3636 w.AddWindow(id)
3737 }
3838 w.FocusID(1)
39- if w.ViewX != 0 {
40- t.Fatal(w.ViewX)
39+ if w.View != 0 {
40+ t.Fatal(w.View)
4141 }
4242 if w.scrollBy(-1) {
4343 t.Fatal("scrolled past the start")
@@ -52,7 +52,7 @@ func TestScrollBy(t *testing.T) {
5252 if steps != 2 {
5353 t.Fatal(steps)
5454 }
55- last := w.columnX(3) + w.columnWidth(3) - w.ViewX
55+ last := w.columnX(3) + w.columnWidth(3) - w.View
5656 if last != w.Usable.X+w.Usable.W-w.gap() {
5757 t.Fatal(last)
5858 }
@@ -62,8 +62,8 @@ func TestScrollBy(t *testing.T) {
6262 }
6363 for w.scrollBy(-1) {
6464 }
65- if w.ViewX != 0 {
66- t.Fatal(w.ViewX)
65+ if w.View != 0 {
66+ t.Fatal(w.View)
6767 }
6868 }
6969
@@ -36,8 +36,8 @@ func TestScrollBy(t *testing.T) {
36 w.AddWindow(id)36 w.AddWindow(id)
37 }37 }
38 w.FocusID(1)38 w.FocusID(1)
39- if w.ViewX != 0 {39+ if w.View != 0 {
40- t.Fatal(w.ViewX)40+ t.Fatal(w.View)
41 }41 }
42 if w.scrollBy(-1) {42 if w.scrollBy(-1) {
43 t.Fatal("scrolled past the start")43 t.Fatal("scrolled past the start")
@@ -52,7 +52,7 @@ func TestScrollBy(t *testing.T) {
52 if steps != 2 {52 if steps != 2 {
53 t.Fatal(steps)53 t.Fatal(steps)
54 }54 }
55- last := w.columnX(3) + w.columnWidth(3) - w.ViewX55+ last := w.columnX(3) + w.columnWidth(3) - w.View
56 if last != w.Usable.X+w.Usable.W-w.gap() {56 if last != w.Usable.X+w.Usable.W-w.gap() {
57 t.Fatal(last)57 t.Fatal(last)
58 }58 }
@@ -62,8 +62,8 @@ func TestScrollBy(t *testing.T) {
62 }62 }
63 for w.scrollBy(-1) {63 for w.scrollBy(-1) {
64 }64 }
65- if w.ViewX != 0 {65+ if w.View != 0 {
66- t.Fatal(w.ViewX)66+ t.Fatal(w.View)
67 }67 }
68 }68 }
69 69
M internal/core/gesture.go +55 -18
@@ -90,7 +90,7 @@ type swipeGesture struct {
9090 // listChanged reports whether the numbered workspaces or the active one
9191 // changed since a workspace slide began, or the overview opened over it.
9292 func (g *swipeGesture) listChanged(m *Monitor) bool {
93- return m.overviewOpens != g.opens || m.shown != nil || m.Workspaces[m.Active] != g.ws || !slices.Equal(m.Workspaces, g.list)
93+ return m.overviewOpens != g.opens || m.shown != nil || m.Workspaces[m.Active] != g.ws || g.horizontal != (g.ws.policy().workspace == horizontalAxis) || !slices.Equal(m.Workspaces, g.list)
9494 }
9595
9696 // columnsChanged reports whether a column swipe lost its workspace: it is
@@ -98,7 +98,22 @@ func (g *swipeGesture) listChanged(m *Monitor) bool {
9898 // panel, a window mapped or resized, other gaps), or the overview opened
9999 // over it.
100100 func (g *swipeGesture) columnsChanged(m *Monitor) bool {
101- return m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || !slices.Equal(g.ws.snapPoints(), g.points)
101+ if m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || g.horizontal != (g.ws.policy().content == horizontalAxis) {
102+ return true
103+ }
104+ if g.ws.policy().wraps {
105+ n, spacing := g.ws.cascadeBands()
106+ if len(g.points) != n {
107+ return true
108+ }
109+ for i, p := range g.points {
110+ if p != float64(i*spacing) {
111+ return true
112+ }
113+ }
114+ return false
115+ }
116+ return !slices.Equal(g.ws.snapPoints(), g.points)
102117 }
103118
104119 // stashChanged reports whether a stash swipe lost its stash: it is no
@@ -191,7 +206,7 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool {
191206 g.mode = swipeDropped
192207 return true
193208 }
194- g.ws.view.off = g.snap.pos() - float64(g.ws.ViewX)
209+ g.ws.view.off = g.snap.pos() - float64(g.ws.View)
195210 return true
196211 case swipeWorkspaces:
197212 if g.listChanged(m) {
@@ -241,16 +256,21 @@ func (c *Core) decide(g *swipeGesture) {
241256 }
242257 w.stashView.motion = motion{}
243258 g.snap = newSnapSwipe(float64(w.stashAt)+w.stashView.off, float64(w.stashAt), 1/stashSwipeMovement, indexPoints(len(w.Stash)), workspaceBand)
244- case g.horizontal && w.slidable():
259+ case g.horizontal == (w.policy().content == horizontalAxis) && w.slidable():
245260 g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints()
246261 w.view.motion = motion{}
247- g.snap = newSnapSwipe(float64(w.ViewX)+w.view.off, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W)))
248- case !g.horizontal && m.shown == nil:
262+ g.snap = newSnapSwipe(float64(w.View)+w.view.off, float64(w.View), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.policy().content.span(w.Usable))))
263+ case g.horizontal == (w.policy().workspace == horizontalAxis) && m.shown == nil:
249264 // A landing slide measured in an older list lands at once first.
250265 if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) {
251266 m.stopSwitch()
252267 }
253268 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]
269+ axis := w.policy().workspace
270+ if m.switchView.busy() && m.switchAxis != axis {
271+ m.stopSwitch()
272+ }
273+ m.switchAxis = axis
254274 g.list = slices.Clone(m.Workspaces)
255275 m.switchView.motion, m.switchList = motion{}, nil
256276 g.snap = newSnapSwipe(float64(m.Active)+m.switchView.off, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand)
@@ -282,18 +302,22 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
282302 w.view.stop()
283303 return false
284304 }
285- shown := float64(w.ViewX) + w.view.off
305+ shown := float64(w.View) + w.view.off
286306 target, velocity := g.snap.end(e.Cancelled, e.Time)
287307 if !e.Cancelled {
288308 view := int(math.Round(target))
289- w.Focus = w.snapFocus(view, target >= shown)
290- w.ViewX = view
309+ focus := w.snapFocus(view, target >= shown)
310+ if focus != w.Focus && w.policy().equalCells {
311+ w.unmaximize()
312+ }
313+ w.Focus = focus
314+ w.View = view
291315 // A column wider than the view aligns as focus moves align it.
292316 w.scroll()
293317 c.focusScreen = c.screenIndex(g.screen.name())
294318 c.keyboard.takeBack()
295319 }
296- w.view.off = shown - float64(w.ViewX)
320+ w.view.off = shown - float64(w.View)
297321 if c.animOn() {
298322 w.view.motion = c.spring(viewSpring(w.view.off, velocity), now)
299323 } else {
@@ -380,14 +404,23 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
380404 if step == 0 {
381405 return false
382406 }
383- a := ActionFocusWorkspaceDown
384- switch {
385- case g.horizontal && step < 0:
407+ w := g.screen.mon.Current()
408+ p := w.policy()
409+ workspace := g.horizontal == (p.workspace == horizontalAxis)
410+ a := ActionFocusColumnRight
411+ if step < 0 {
386412 a = ActionFocusColumnLeft
387- case g.horizontal:
388- a = ActionFocusColumnRight
389- case step < 0:
390- a = ActionFocusWorkspaceUp
413+ }
414+ if workspace {
415+ a = ActionFocusWorkspaceDown
416+ if step < 0 {
417+ a = ActionFocusWorkspaceUp
418+ }
419+ } else if p.wraps {
420+ // A content swipe cannot escape the last band to another workspace.
421+ if w.onFloat() || w.fullscreen != 0 || w.bandNeighbor(step) < 0 {
422+ return false
423+ }
391424 }
392425 // Focus actions act on the focused screen: the swipe's, unless the
393426 // pointer took the focus to another output meanwhile.
@@ -400,7 +433,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
400433 before := c.cur().mon.Current()
401434 shots := c.snapshot(now)
402435 c.keyboard.takeBack()
403- c.applyAction(a)
436+ if p.wraps && !workspace {
437+ w.focusBand(step)
438+ } else {
439+ c.applyAction(a)
440+ }
404441 c.transition(shots, now)
405442 return c.cur().mon.Current() != before
406443 }
@@ -90,7 +90,7 @@ type swipeGesture struct {
90 // listChanged reports whether the numbered workspaces or the active one90 // listChanged reports whether the numbered workspaces or the active one
91 // changed since a workspace slide began, or the overview opened over it.91 // changed since a workspace slide began, or the overview opened over it.
92 func (g *swipeGesture) listChanged(m *Monitor) bool {92 func (g *swipeGesture) listChanged(m *Monitor) bool {
93- return m.overviewOpens != g.opens || m.shown != nil || m.Workspaces[m.Active] != g.ws || !slices.Equal(m.Workspaces, g.list)93+ return m.overviewOpens != g.opens || m.shown != nil || m.Workspaces[m.Active] != g.ws || g.horizontal != (g.ws.policy().workspace == horizontalAxis) || !slices.Equal(m.Workspaces, g.list)
94 }94 }
95 95
96 // columnsChanged reports whether a column swipe lost its workspace: it is96 // columnsChanged reports whether a column swipe lost its workspace: it is
@@ -98,7 +98,22 @@ func (g *swipeGesture) listChanged(m *Monitor) bool {
98 // panel, a window mapped or resized, other gaps), or the overview opened98 // panel, a window mapped or resized, other gaps), or the overview opened
99 // over it.99 // over it.
100 func (g *swipeGesture) columnsChanged(m *Monitor) bool {100 func (g *swipeGesture) columnsChanged(m *Monitor) bool {
101- return m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || !slices.Equal(g.ws.snapPoints(), g.points)101+ if m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || g.horizontal != (g.ws.policy().content == horizontalAxis) {
102+ return true
103+ }
104+ if g.ws.policy().wraps {
105+ n, spacing := g.ws.cascadeBands()
106+ if len(g.points) != n {
107+ return true
108+ }
109+ for i, p := range g.points {
110+ if p != float64(i*spacing) {
111+ return true
112+ }
113+ }
114+ return false
115+ }
116+ return !slices.Equal(g.ws.snapPoints(), g.points)
102 }117 }
103 118
104 // stashChanged reports whether a stash swipe lost its stash: it is no119 // stashChanged reports whether a stash swipe lost its stash: it is no
@@ -191,7 +206,7 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool {
191 g.mode = swipeDropped206 g.mode = swipeDropped
192 return true207 return true
193 }208 }
194- g.ws.view.off = g.snap.pos() - float64(g.ws.ViewX)209+ g.ws.view.off = g.snap.pos() - float64(g.ws.View)
195 return true210 return true
196 case swipeWorkspaces:211 case swipeWorkspaces:
197 if g.listChanged(m) {212 if g.listChanged(m) {
@@ -241,16 +256,21 @@ func (c *Core) decide(g *swipeGesture) {
241 }256 }
242 w.stashView.motion = motion{}257 w.stashView.motion = motion{}
243 g.snap = newSnapSwipe(float64(w.stashAt)+w.stashView.off, float64(w.stashAt), 1/stashSwipeMovement, indexPoints(len(w.Stash)), workspaceBand)258 g.snap = newSnapSwipe(float64(w.stashAt)+w.stashView.off, float64(w.stashAt), 1/stashSwipeMovement, indexPoints(len(w.Stash)), workspaceBand)
244- case g.horizontal && w.slidable():259+ case g.horizontal == (w.policy().content == horizontalAxis) && w.slidable():
245 g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints()260 g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints()
246 w.view.motion = motion{}261 w.view.motion = motion{}
247- g.snap = newSnapSwipe(float64(w.ViewX)+w.view.off, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W)))262+ g.snap = newSnapSwipe(float64(w.View)+w.view.off, float64(w.View), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.policy().content.span(w.Usable))))
248- case !g.horizontal && m.shown == nil:263+ case g.horizontal == (w.policy().workspace == horizontalAxis) && m.shown == nil:
249 // A landing slide measured in an older list lands at once first.264 // A landing slide measured in an older list lands at once first.
250 if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) {265 if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) {
251 m.stopSwitch()266 m.stopSwitch()
252 }267 }
253 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]268 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]
269+ axis := w.policy().workspace
270+ if m.switchView.busy() && m.switchAxis != axis {
271+ m.stopSwitch()
272+ }
273+ m.switchAxis = axis
254 g.list = slices.Clone(m.Workspaces)274 g.list = slices.Clone(m.Workspaces)
255 m.switchView.motion, m.switchList = motion{}, nil275 m.switchView.motion, m.switchList = motion{}, nil
256 g.snap = newSnapSwipe(float64(m.Active)+m.switchView.off, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand)276 g.snap = newSnapSwipe(float64(m.Active)+m.switchView.off, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand)
@@ -282,18 +302,22 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
282 w.view.stop()302 w.view.stop()
283 return false303 return false
284 }304 }
285- shown := float64(w.ViewX) + w.view.off305+ shown := float64(w.View) + w.view.off
286 target, velocity := g.snap.end(e.Cancelled, e.Time)306 target, velocity := g.snap.end(e.Cancelled, e.Time)
287 if !e.Cancelled {307 if !e.Cancelled {
288 view := int(math.Round(target))308 view := int(math.Round(target))
289- w.Focus = w.snapFocus(view, target >= shown)309+ focus := w.snapFocus(view, target >= shown)
290- w.ViewX = view310+ if focus != w.Focus && w.policy().equalCells {
311+ w.unmaximize()
312+ }
313+ w.Focus = focus
314+ w.View = view
291 // A column wider than the view aligns as focus moves align it.315 // A column wider than the view aligns as focus moves align it.
292 w.scroll()316 w.scroll()
293 c.focusScreen = c.screenIndex(g.screen.name())317 c.focusScreen = c.screenIndex(g.screen.name())
294 c.keyboard.takeBack()318 c.keyboard.takeBack()
295 }319 }
296- w.view.off = shown - float64(w.ViewX)320+ w.view.off = shown - float64(w.View)
297 if c.animOn() {321 if c.animOn() {
298 w.view.motion = c.spring(viewSpring(w.view.off, velocity), now)322 w.view.motion = c.spring(viewSpring(w.view.off, velocity), now)
299 } else {323 } else {
@@ -380,14 +404,23 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
380 if step == 0 {404 if step == 0 {
381 return false405 return false
382 }406 }
383- a := ActionFocusWorkspaceDown407+ w := g.screen.mon.Current()
384- switch {408+ p := w.policy()
385- case g.horizontal && step < 0:409+ workspace := g.horizontal == (p.workspace == horizontalAxis)
410+ a := ActionFocusColumnRight
411+ if step < 0 {
386 a = ActionFocusColumnLeft412 a = ActionFocusColumnLeft
387- case g.horizontal:413+ }
388- a = ActionFocusColumnRight414+ if workspace {
389- case step < 0:415+ a = ActionFocusWorkspaceDown
390- a = ActionFocusWorkspaceUp416+ if step < 0 {
417+ a = ActionFocusWorkspaceUp
418+ }
419+ } else if p.wraps {
420+ // A content swipe cannot escape the last band to another workspace.
421+ if w.onFloat() || w.fullscreen != 0 || w.bandNeighbor(step) < 0 {
422+ return false
423+ }
391 }424 }
392 // Focus actions act on the focused screen: the swipe's, unless the425 // Focus actions act on the focused screen: the swipe's, unless the
393 // pointer took the focus to another output meanwhile.426 // pointer took the focus to another output meanwhile.
@@ -400,7 +433,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
400 before := c.cur().mon.Current()433 before := c.cur().mon.Current()
401 shots := c.snapshot(now)434 shots := c.snapshot(now)
402 c.keyboard.takeBack()435 c.keyboard.takeBack()
403- c.applyAction(a)436+ if p.wraps && !workspace {
437+ w.focusBand(step)
438+ } else {
439+ c.applyAction(a)
440+ }
404 c.transition(shots, now)441 c.transition(shots, now)
405 return c.cur().mon.Current() != before442 return c.cur().mon.Current() != before
406 }443 }
M internal/core/layout.go +49 -30
@@ -149,6 +149,8 @@ type Overflow string
149149 const (
150150 // OverflowScroll opens further columns to the right; the view scrolls.
151151 OverflowScroll Overflow = "scroll"
152+ // OverflowCascade wraps columns into vertically scrolling viewport bands.
153+ OverflowCascade Overflow = "cascade"
152154 // OverflowFixed keeps every column on screen: past the max, each new
153155 // column splits the newest one, alternating top/bottom and left/right
154156 // (dwindle spiral).
@@ -169,8 +171,9 @@ type Workspace struct {
169171 // first; stack() filters removed windows at read time.
170172 maximized []WindowID
171173 Focus int
172- ViewX int
173- // view slides the columns on screen past ViewX, in logical pixels.
174+ View int
175+ // View is the settled offset along the layout's content axis.
176+ // view is its presentation offset, in logical pixels.
174177 view slide
175178 // Output is the effective viewport in monitor coordinates: the whole
176179 // monitor unless the workspace has a size override, then a centered
@@ -495,7 +498,7 @@ func (w *Workspace) AddWindow(id WindowID) {
495498 // A new window never moves the view off a covering fullscreen
496499 // window (ADR 011): it waits, hidden, after the focused column.
497500 at := len(w.Columns)
498- if w.Overflow != OverflowFixed {
501+ if !w.policy().appendLast {
499502 // A covering float or stashed window may have no column under it.
500503 at = min(w.Focus+1, len(w.Columns))
501504 }
@@ -508,13 +511,13 @@ func (w *Workspace) AddWindow(id WindowID) {
508511 // addColumn places a new column like a new window and focuses it.
509512 func (w *Workspace) addColumn(col Column) {
510513 at := 0
511- if w.Overflow == OverflowFixed {
512- // The spiral depends only on window order: new windows go last.
514+ if w.policy().appendLast {
515+ // Ordered overflow depends only on window order: new windows go last.
513516 at = len(w.Columns)
514517 } else if len(w.Columns) > 0 {
515518 at = w.Focus + 1
516519 }
517- if w.Overflow == OverflowFixed && len(w.Columns) > 0 {
520+ if w.policy().equalCells && len(w.Columns) > 0 {
518521 w.unmaximize()
519522 }
520523 w.Columns = append(w.Columns, Column{})
@@ -618,7 +621,7 @@ func (w *Workspace) RemoveWindow(id WindowID) {
618621 w.Columns = append(w.Columns[:i], w.Columns[i+1:]...)
619622 if len(w.Columns) == 0 {
620623 w.Focus = 0
621- w.ViewX = 0
624+ w.View = 0
622625 return
623626 }
624627 if i < w.Focus || (i == w.Focus && i > 0) {
@@ -665,7 +668,7 @@ func (w *Workspace) FocusID(id WindowID) bool {
665668 w.dropStashOver()
666669 w.floatFocus, w.stashFocus = false, false
667670 w.raiseColumns()
668- if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) && !w.Columns[i].FullWidth {
671+ if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) && !w.Columns[i].FullWidth {
669672 w.unmaximize()
670673 }
671674 w.Focus = i
@@ -710,7 +713,7 @@ func (w *Workspace) focusColumn(dir int) {
710713 }
711714 if i := w.columnToward(dir); i >= 0 {
712715 w.raiseColumns()
713- if w.Overflow == OverflowFixed {
716+ if w.policy().equalCells {
714717 w.unmaximize()
715718 }
716719 w.Focus = i
@@ -774,6 +777,9 @@ func (w *Workspace) focusWindow(dir int) bool {
774777 w.scroll()
775778 return true
776779 }
780+ if w.policy().wraps && w.focusBand(dir) {
781+ return true
782+ }
777783 if w.onScreenFocus() {
778784 if i := w.screenNeighbor(0, dir); i >= 0 {
779785 w.Focus = i
@@ -865,7 +871,7 @@ func (w *Workspace) MoveWindow(dir int) {
865871 // usable width, clamped to 10-100%. An auto column becomes explicit. Fixed
866872 // overflow shares the width equally: nothing changes there.
867873 func (w *Workspace) ResizeColumn(pct int) {
868- if w.onFloat() || len(w.Columns) == 0 || w.Overflow == OverflowFixed || w.fullscreenHides() {
874+ if w.onFloat() || len(w.Columns) == 0 || w.policy().equalCells || w.fullscreenHides() {
869875 return
870876 }
871877 w.unmaximize()
@@ -973,7 +979,7 @@ func (w *Workspace) takeColumn() (Column, bool) {
973979 w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)
974980 w.Focus = min(w.Focus, max(len(w.Columns)-1, 0))
975981 if len(w.Columns) == 0 {
976- w.ViewX = 0
982+ w.View = 0
977983 } else {
978984 w.scroll()
979985 }
@@ -983,7 +989,7 @@ func (w *Workspace) takeColumn() (Column, bool) {
983989 // insertColumn adds a column at index at (clamped) and focuses it.
984990 func (w *Workspace) insertColumn(at int, col Column) {
985991 at = min(max(at, 0), len(w.Columns))
986- if w.Overflow == OverflowFixed && len(w.Columns) > 0 {
992+ if w.policy().equalCells && len(w.Columns) > 0 {
987993 w.unmaximize()
988994 }
989995 w.Columns = slices.Insert(w.Columns, at, col)
@@ -994,6 +1000,9 @@ func (w *Workspace) insertColumn(at int, col Column) {
9941000 // CycleWidth steps the focused column through the presets. Fixed overflow
9951001 // ignores presets: it toggles the expanded column instead.
9961002 func (w *Workspace) CycleWidth() {
1003+ if w.policy().wraps {
1004+ return
1005+ }
9971006 if w.Overflow == OverflowFixed {
9981007 w.toggleExpanded()
9991008 return
@@ -1047,11 +1056,11 @@ func (w *Workspace) maximize(i int) {
10471056 // columns in fixed mode (including after Escape changed focus).
10481057 func (w *Workspace) unmaximize() {
10491058 for i := range w.Columns {
1050- if w.Overflow == OverflowFixed || i == w.Focus {
1059+ if w.policy().equalCells || i == w.Focus {
10511060 w.Columns[i].FullWidth = false
10521061 }
10531062 }
1054- if w.Overflow == OverflowFixed {
1063+ if w.policy().equalCells {
10551064 w.maximized = nil
10561065 }
10571066 }
@@ -1262,7 +1271,7 @@ func (w *Workspace) mayCover(id WindowID) bool {
12621271 }
12631272 w.left = 0
12641273 }
1265- if w.Overflow != OverflowFixed || w.stashHidden && w.stashIndex(id) >= 0 {
1274+ if !w.policy().equalCells || w.stashHidden && w.stashIndex(id) >= 0 {
12661275 return true
12671276 }
12681277 focused, ok := w.Focused()
@@ -1275,7 +1284,7 @@ func (w *Workspace) mayCover(id WindowID) bool {
12751284 // tiled neighbor scroll it off and show the target instead.
12761285 func (w *Workspace) pinned() bool {
12771286 c := w.cover()
1278- return c != 0 && (w.Overflow == OverflowFixed || w.isFloat(c))
1287+ return c != 0 && (w.policy().equalCells || w.isFloat(c))
12791288 }
12801289
12811290 // leaveCover runs a focus move from the covering fullscreen window as if it
@@ -1333,12 +1342,12 @@ func (w *Workspace) cover() WindowID {
13331342 if w.fullscreen == 0 {
13341343 return 0
13351344 }
1336- if w.isFloat(w.fullscreen) || w.Overflow == OverflowFixed {
1345+ if w.isFloat(w.fullscreen) || w.policy().equalCells {
13371346 return w.fullscreen
13381347 }
13391348 // Scroll overflow: only while its column is aligned on the output.
13401349 for i, c := range w.Columns {
1341- if slices.Contains(c.Windows, w.fullscreen) && w.columnX(i)-w.ViewX == 0 {
1350+ if slices.Contains(c.Windows, w.fullscreen) && w.columnX(i)-w.View == 0 {
13421351 return w.fullscreen
13431352 }
13441353 }
@@ -1349,7 +1358,7 @@ func (w *Workspace) cover() WindowID {
13491358 // column's layout: the others (fixed) or its own column (scroll). Column
13501359 // edits would change what the user cannot see.
13511360 func (w *Workspace) fullscreenHides() bool {
1352- return w.fullscreen != 0 && (w.Overflow == OverflowFixed || w.fullscreenColumn(w.Focus))
1361+ return w.fullscreen != 0 && (w.policy().equalCells || w.fullscreenColumn(w.Focus))
13531362 }
13541363
13551364 func (w *Workspace) fullscreenColumn(i int) bool {
@@ -1373,7 +1382,10 @@ func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {
13731382 }
13741383 g := w.gap()
13751384 // Fixed overflow never scrolls, so presets would push columns off screen.
1376- if w.Columns[i].Width == (Width{}) || w.Overflow == OverflowFixed {
1385+ if w.policy().wraps {
1386+ return w.cellWidth()
1387+ }
1388+ if w.Columns[i].Width == (Width{}) || w.policy().equalCells {
13771389 if len(w.Columns) == 1 {
13781390 return max(w.Usable.W-2*g, 0)
13791391 }
@@ -1391,27 +1403,31 @@ func (w *Workspace) columnX(i int) int {
13911403 return x
13921404 }
13931405 func (w *Workspace) scroll() {
1394- before := w.ViewX
1406+ before := w.View
13951407 defer func() { w.retarget(before) }()
1408+ if w.policy().wraps {
1409+ w.View = w.band(w.Focus) * w.Usable.H
1410+ return
1411+ }
13961412 if w.Overflow == OverflowFixed {
1397- w.ViewX = 0
1413+ w.View = 0
13981414 return
13991415 }
14001416 if len(w.Columns) == 0 {
14011417 return
14021418 }
1403- left := w.columnX(w.Focus) - w.ViewX
1419+ left := w.columnX(w.Focus) - w.View
14041420 width := w.columnWidth(w.Focus)
14051421 if w.fullscreenColumn(w.Focus) {
1406- w.ViewX += left - w.Output.X
1422+ w.View += left - w.Output.X
14071423 return
14081424 }
14091425 minX := w.Usable.X + w.gap()
14101426 maxX := w.Usable.X + w.Usable.W - w.gap()
14111427 if width > maxX-minX || left < minX {
1412- w.ViewX += left - minX
1428+ w.View += left - minX
14131429 } else if left+width > maxX {
1414- w.ViewX += left + width - maxX
1430+ w.View += left + width - maxX
14151431 }
14161432 }
14171433
@@ -1430,10 +1446,13 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
14301446 // when too small): the result aliases dst, so a caller passing a reused
14311447 // buffer must be done with the previous result.
14321448 func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
1449+ if w.policy().wraps {
1450+ return w.cascadeRectsInto(dst, ignoreFullWidth)
1451+ }
14331452 g := w.gap()
14341453 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)
14351454 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
1436- view := w.ViewX + w.shiftPixels()
1455+ view := w.View + w.shiftPixels()
14371456 x := w.Usable.X + g
14381457 for i := range w.Columns {
14391458 width := w.columnWidthFor(i, ignoreFullWidth)
@@ -1618,12 +1637,12 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
16181637 full := w.fullscreen == id && id != 0
16191638 // Fixed overflow cannot scroll to other columns while one fills
16201639 // the view. Keep them in place, but out of the scene.
1621- maximized := w.Overflow == OverflowFixed && w.Columns[w.Focus].FullWidth && i != w.Focus
1622- hidden := (cover != 0 && id != cover) || ((fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full) || maximized
1640+ maximized := w.policy().equalCells && w.Columns[w.Focus].FullWidth && i != w.Focus && w.sameBand(i, w.Focus)
1641+ hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || maximized
16231642 if full {
16241643 // Scroll mode aligns the view on the column; fixed never scrolls.
16251644 r = w.Output
1626- if w.Overflow != OverflowFixed {
1645+ if w.Overflow == OverflowScroll || w.Overflow == "" {
16271646 r.X = col.X
16281647 }
16291648 }
@@ -149,6 +149,8 @@ type Overflow string
149 const (149 const (
150 // OverflowScroll opens further columns to the right; the view scrolls.150 // OverflowScroll opens further columns to the right; the view scrolls.
151 OverflowScroll Overflow = "scroll"151 OverflowScroll Overflow = "scroll"
152+ // OverflowCascade wraps columns into vertically scrolling viewport bands.
153+ OverflowCascade Overflow = "cascade"
152 // OverflowFixed keeps every column on screen: past the max, each new154 // OverflowFixed keeps every column on screen: past the max, each new
153 // column splits the newest one, alternating top/bottom and left/right155 // column splits the newest one, alternating top/bottom and left/right
154 // (dwindle spiral).156 // (dwindle spiral).
@@ -169,8 +171,9 @@ type Workspace struct {
169 // first; stack() filters removed windows at read time.171 // first; stack() filters removed windows at read time.
170 maximized []WindowID172 maximized []WindowID
171 Focus int173 Focus int
172- ViewX int174+ View int
173- // view slides the columns on screen past ViewX, in logical pixels.175+ // View is the settled offset along the layout's content axis.
176+ // view is its presentation offset, in logical pixels.
174 view slide177 view slide
175 // Output is the effective viewport in monitor coordinates: the whole178 // Output is the effective viewport in monitor coordinates: the whole
176 // monitor unless the workspace has a size override, then a centered179 // monitor unless the workspace has a size override, then a centered
@@ -495,7 +498,7 @@ func (w *Workspace) AddWindow(id WindowID) {
495 // A new window never moves the view off a covering fullscreen498 // A new window never moves the view off a covering fullscreen
496 // window (ADR 011): it waits, hidden, after the focused column.499 // window (ADR 011): it waits, hidden, after the focused column.
497 at := len(w.Columns)500 at := len(w.Columns)
498- if w.Overflow != OverflowFixed {501+ if !w.policy().appendLast {
499 // 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.
500 at = min(w.Focus+1, len(w.Columns))503 at = min(w.Focus+1, len(w.Columns))
501 }504 }
@@ -508,13 +511,13 @@ func (w *Workspace) AddWindow(id WindowID) {
508 // 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.
509 func (w *Workspace) addColumn(col Column) {512 func (w *Workspace) addColumn(col Column) {
510 at := 0513 at := 0
511- if w.Overflow == OverflowFixed {514+ if w.policy().appendLast {
512- // The spiral depends only on window order: new windows go last.515+ // Ordered overflow depends only on window order: new windows go last.
513 at = len(w.Columns)516 at = len(w.Columns)
514 } else if len(w.Columns) > 0 {517 } else if len(w.Columns) > 0 {
515 at = w.Focus + 1518 at = w.Focus + 1
516 }519 }
517- if w.Overflow == OverflowFixed && len(w.Columns) > 0 {520+ if w.policy().equalCells && len(w.Columns) > 0 {
518 w.unmaximize()521 w.unmaximize()
519 }522 }
520 w.Columns = append(w.Columns, Column{})523 w.Columns = append(w.Columns, Column{})
@@ -618,7 +621,7 @@ func (w *Workspace) RemoveWindow(id WindowID) {
618 w.Columns = append(w.Columns[:i], w.Columns[i+1:]...)621 w.Columns = append(w.Columns[:i], w.Columns[i+1:]...)
619 if len(w.Columns) == 0 {622 if len(w.Columns) == 0 {
620 w.Focus = 0623 w.Focus = 0
621- w.ViewX = 0624+ w.View = 0
622 return625 return
623 }626 }
624 if i < w.Focus || (i == w.Focus && i > 0) {627 if i < w.Focus || (i == w.Focus && i > 0) {
@@ -665,7 +668,7 @@ func (w *Workspace) FocusID(id WindowID) bool {
665 w.dropStashOver()668 w.dropStashOver()
666 w.floatFocus, w.stashFocus = false, false669 w.floatFocus, w.stashFocus = false, false
667 w.raiseColumns()670 w.raiseColumns()
668- if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) && !w.Columns[i].FullWidth {671+ if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) && !w.Columns[i].FullWidth {
669 w.unmaximize()672 w.unmaximize()
670 }673 }
671 w.Focus = i674 w.Focus = i
@@ -710,7 +713,7 @@ func (w *Workspace) focusColumn(dir int) {
710 }713 }
711 if i := w.columnToward(dir); i >= 0 {714 if i := w.columnToward(dir); i >= 0 {
712 w.raiseColumns()715 w.raiseColumns()
713- if w.Overflow == OverflowFixed {716+ if w.policy().equalCells {
714 w.unmaximize()717 w.unmaximize()
715 }718 }
716 w.Focus = i719 w.Focus = i
@@ -774,6 +777,9 @@ func (w *Workspace) focusWindow(dir int) bool {
774 w.scroll()777 w.scroll()
775 return true778 return true
776 }779 }
780+ if w.policy().wraps && w.focusBand(dir) {
781+ return true
782+ }
777 if w.onScreenFocus() {783 if w.onScreenFocus() {
778 if i := w.screenNeighbor(0, dir); i >= 0 {784 if i := w.screenNeighbor(0, dir); i >= 0 {
779 w.Focus = i785 w.Focus = i
@@ -865,7 +871,7 @@ func (w *Workspace) MoveWindow(dir int) {
865 // usable width, clamped to 10-100%. An auto column becomes explicit. Fixed871 // usable width, clamped to 10-100%. An auto column becomes explicit. Fixed
866 // overflow shares the width equally: nothing changes there.872 // overflow shares the width equally: nothing changes there.
867 func (w *Workspace) ResizeColumn(pct int) {873 func (w *Workspace) ResizeColumn(pct int) {
868- if w.onFloat() || len(w.Columns) == 0 || w.Overflow == OverflowFixed || w.fullscreenHides() {874+ if w.onFloat() || len(w.Columns) == 0 || w.policy().equalCells || w.fullscreenHides() {
869 return875 return
870 }876 }
871 w.unmaximize()877 w.unmaximize()
@@ -973,7 +979,7 @@ func (w *Workspace) takeColumn() (Column, bool) {
973 w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)979 w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)
974 w.Focus = min(w.Focus, max(len(w.Columns)-1, 0))980 w.Focus = min(w.Focus, max(len(w.Columns)-1, 0))
975 if len(w.Columns) == 0 {981 if len(w.Columns) == 0 {
976- w.ViewX = 0982+ w.View = 0
977 } else {983 } else {
978 w.scroll()984 w.scroll()
979 }985 }
@@ -983,7 +989,7 @@ func (w *Workspace) takeColumn() (Column, bool) {
983 // insertColumn adds a column at index at (clamped) and focuses it.989 // insertColumn adds a column at index at (clamped) and focuses it.
984 func (w *Workspace) insertColumn(at int, col Column) {990 func (w *Workspace) insertColumn(at int, col Column) {
985 at = min(max(at, 0), len(w.Columns))991 at = min(max(at, 0), len(w.Columns))
986- if w.Overflow == OverflowFixed && len(w.Columns) > 0 {992+ if w.policy().equalCells && len(w.Columns) > 0 {
987 w.unmaximize()993 w.unmaximize()
988 }994 }
989 w.Columns = slices.Insert(w.Columns, at, col)995 w.Columns = slices.Insert(w.Columns, at, col)
@@ -994,6 +1000,9 @@ func (w *Workspace) insertColumn(at int, col Column) {
994 // CycleWidth steps the focused column through the presets. Fixed overflow1000 // CycleWidth steps the focused column through the presets. Fixed overflow
995 // ignores presets: it toggles the expanded column instead.1001 // ignores presets: it toggles the expanded column instead.
996 func (w *Workspace) CycleWidth() {1002 func (w *Workspace) CycleWidth() {
1003+ if w.policy().wraps {
1004+ return
1005+ }
997 if w.Overflow == OverflowFixed {1006 if w.Overflow == OverflowFixed {
998 w.toggleExpanded()1007 w.toggleExpanded()
999 return1008 return
@@ -1047,11 +1056,11 @@ func (w *Workspace) maximize(i int) {
1047 // columns in fixed mode (including after Escape changed focus).1056 // columns in fixed mode (including after Escape changed focus).
1048 func (w *Workspace) unmaximize() {1057 func (w *Workspace) unmaximize() {
1049 for i := range w.Columns {1058 for i := range w.Columns {
1050- if w.Overflow == OverflowFixed || i == w.Focus {1059+ if w.policy().equalCells || i == w.Focus {
1051 w.Columns[i].FullWidth = false1060 w.Columns[i].FullWidth = false
1052 }1061 }
1053 }1062 }
1054- if w.Overflow == OverflowFixed {1063+ if w.policy().equalCells {
1055 w.maximized = nil1064 w.maximized = nil
1056 }1065 }
1057 }1066 }
@@ -1262,7 +1271,7 @@ func (w *Workspace) mayCover(id WindowID) bool {
1262 }1271 }
1263 w.left = 01272 w.left = 0
1264 }1273 }
1265- if w.Overflow != OverflowFixed || w.stashHidden && w.stashIndex(id) >= 0 {1274+ if !w.policy().equalCells || w.stashHidden && w.stashIndex(id) >= 0 {
1266 return true1275 return true
1267 }1276 }
1268 focused, ok := w.Focused()1277 focused, ok := w.Focused()
@@ -1275,7 +1284,7 @@ func (w *Workspace) mayCover(id WindowID) bool {
1275 // tiled neighbor scroll it off and show the target instead.1284 // tiled neighbor scroll it off and show the target instead.
1276 func (w *Workspace) pinned() bool {1285 func (w *Workspace) pinned() bool {
1277 c := w.cover()1286 c := w.cover()
1278- return c != 0 && (w.Overflow == OverflowFixed || w.isFloat(c))1287+ return c != 0 && (w.policy().equalCells || w.isFloat(c))
1279 }1288 }
1280 1289
1281 // leaveCover runs a focus move from the covering fullscreen window as if it1290 // leaveCover runs a focus move from the covering fullscreen window as if it
@@ -1333,12 +1342,12 @@ func (w *Workspace) cover() WindowID {
1333 if w.fullscreen == 0 {1342 if w.fullscreen == 0 {
1334 return 01343 return 0
1335 }1344 }
1336- if w.isFloat(w.fullscreen) || w.Overflow == OverflowFixed {1345+ if w.isFloat(w.fullscreen) || w.policy().equalCells {
1337 return w.fullscreen1346 return w.fullscreen
1338 }1347 }
1339 // Scroll overflow: only while its column is aligned on the output.1348 // Scroll overflow: only while its column is aligned on the output.
1340 for i, c := range w.Columns {1349 for i, c := range w.Columns {
1341- if slices.Contains(c.Windows, w.fullscreen) && w.columnX(i)-w.ViewX == 0 {1350+ if slices.Contains(c.Windows, w.fullscreen) && w.columnX(i)-w.View == 0 {
1342 return w.fullscreen1351 return w.fullscreen
1343 }1352 }
1344 }1353 }
@@ -1349,7 +1358,7 @@ func (w *Workspace) cover() WindowID {
1349 // column's layout: the others (fixed) or its own column (scroll). Column1358 // column's layout: the others (fixed) or its own column (scroll). Column
1350 // edits would change what the user cannot see.1359 // edits would change what the user cannot see.
1351 func (w *Workspace) fullscreenHides() bool {1360 func (w *Workspace) fullscreenHides() bool {
1352- return w.fullscreen != 0 && (w.Overflow == OverflowFixed || w.fullscreenColumn(w.Focus))1361+ return w.fullscreen != 0 && (w.policy().equalCells || w.fullscreenColumn(w.Focus))
1353 }1362 }
1354 1363
1355 func (w *Workspace) fullscreenColumn(i int) bool {1364 func (w *Workspace) fullscreenColumn(i int) bool {
@@ -1373,7 +1382,10 @@ func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {
1373 }1382 }
1374 g := w.gap()1383 g := w.gap()
1375 // Fixed overflow never scrolls, so presets would push columns off screen.1384 // Fixed overflow never scrolls, so presets would push columns off screen.
1376- if w.Columns[i].Width == (Width{}) || w.Overflow == OverflowFixed {1385+ if w.policy().wraps {
1386+ return w.cellWidth()
1387+ }
1388+ if w.Columns[i].Width == (Width{}) || w.policy().equalCells {
1377 if len(w.Columns) == 1 {1389 if len(w.Columns) == 1 {
1378 return max(w.Usable.W-2*g, 0)1390 return max(w.Usable.W-2*g, 0)
1379 }1391 }
@@ -1391,27 +1403,31 @@ func (w *Workspace) columnX(i int) int {
1391 return x1403 return x
1392 }1404 }
1393 func (w *Workspace) scroll() {1405 func (w *Workspace) scroll() {
1394- before := w.ViewX1406+ before := w.View
1395 defer func() { w.retarget(before) }()1407 defer func() { w.retarget(before) }()
1408+ if w.policy().wraps {
1409+ w.View = w.band(w.Focus) * w.Usable.H
1410+ return
1411+ }
1396 if w.Overflow == OverflowFixed {1412 if w.Overflow == OverflowFixed {
1397- w.ViewX = 01413+ w.View = 0
1398 return1414 return
1399 }1415 }
1400 if len(w.Columns) == 0 {1416 if len(w.Columns) == 0 {
1401 return1417 return
1402 }1418 }
1403- left := w.columnX(w.Focus) - w.ViewX1419+ left := w.columnX(w.Focus) - w.View
1404 width := w.columnWidth(w.Focus)1420 width := w.columnWidth(w.Focus)
1405 if w.fullscreenColumn(w.Focus) {1421 if w.fullscreenColumn(w.Focus) {
1406- w.ViewX += left - w.Output.X1422+ w.View += left - w.Output.X
1407 return1423 return
1408 }1424 }
1409 minX := w.Usable.X + w.gap()1425 minX := w.Usable.X + w.gap()
1410 maxX := w.Usable.X + w.Usable.W - w.gap()1426 maxX := w.Usable.X + w.Usable.W - w.gap()
1411 if width > maxX-minX || left < minX {1427 if width > maxX-minX || left < minX {
1412- w.ViewX += left - minX1428+ w.View += left - minX
1413 } else if left+width > maxX {1429 } else if left+width > maxX {
1414- w.ViewX += left + width - maxX1430+ w.View += left + width - maxX
1415 }1431 }
1416 }1432 }
1417 1433
@@ -1430,10 +1446,13 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
1430 // when too small): the result aliases dst, so a caller passing a reused1446 // when too small): the result aliases dst, so a caller passing a reused
1431 // buffer must be done with the previous result.1447 // buffer must be done with the previous result.
1432 func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {1448 func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
1449+ if w.policy().wraps {
1450+ return w.cascadeRectsInto(dst, ignoreFullWidth)
1451+ }
1433 g := w.gap()1452 g := w.gap()
1434 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)1453 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)
1435 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]1454 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
1436- view := w.ViewX + w.shiftPixels()1455+ view := w.View + w.shiftPixels()
1437 x := w.Usable.X + g1456 x := w.Usable.X + g
1438 for i := range w.Columns {1457 for i := range w.Columns {
1439 width := w.columnWidthFor(i, ignoreFullWidth)1458 width := w.columnWidthFor(i, ignoreFullWidth)
@@ -1618,12 +1637,12 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
1618 full := w.fullscreen == id && id != 01637 full := w.fullscreen == id && id != 0
1619 // Fixed overflow cannot scroll to other columns while one fills1638 // Fixed overflow cannot scroll to other columns while one fills
1620 // the view. Keep them in place, but out of the scene.1639 // the view. Keep them in place, but out of the scene.
1621- maximized := w.Overflow == OverflowFixed && w.Columns[w.Focus].FullWidth && i != w.Focus1640+ maximized := w.policy().equalCells && w.Columns[w.Focus].FullWidth && i != w.Focus && w.sameBand(i, w.Focus)
1622- hidden := (cover != 0 && id != cover) || ((fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full) || maximized1641+ hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || maximized
1623 if full {1642 if full {
1624 // Scroll mode aligns the view on the column; fixed never scrolls.1643 // Scroll mode aligns the view on the column; fixed never scrolls.
1625 r = w.Output1644 r = w.Output
1626- if w.Overflow != OverflowFixed {1645+ if w.Overflow == OverflowScroll || w.Overflow == "" {
1627 r.X = col.X1646 r.X = col.X
1628 }1647 }
1629 }1648 }
A internal/core/layout_policy.go +40 -0
@@ -0,0 +1,40 @@
1+package core
2+
3+// layoutAxis is a logical navigation axis, independent of rendering.
4+type layoutAxis uint8
5+
6+const (
7+ verticalAxis layoutAxis = iota
8+ horizontalAxis
9+)
10+
11+func (a layoutAxis) span(r Rect) int {
12+ if a == verticalAxis {
13+ return r.H
14+ }
15+ return r.W
16+}
17+func (a layoutAxis) offset(r *Rect, pixels int) {
18+ if a == verticalAxis {
19+ r.Y += pixels
20+ } else {
21+ r.X += pixels
22+ }
23+}
24+
25+// layoutPolicy keeps geometry capabilities distinct from workspace navigation.
26+// Window state remains owned by Workspace; policies contain no mutable state.
27+type layoutPolicy struct {
28+ content, workspace layoutAxis
29+ equalCells, appendLast, wraps bool
30+}
31+
32+func (w *Workspace) policy() layoutPolicy {
33+ if w.Overflow == OverflowCascade {
34+ return layoutPolicy{content: verticalAxis, workspace: horizontalAxis, equalCells: true, appendLast: true, wraps: true}
35+ }
36+ if w.Overflow == OverflowFixed {
37+ return layoutPolicy{content: horizontalAxis, workspace: verticalAxis, equalCells: true, appendLast: true}
38+ }
39+ return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}
40+}
@@ -0,0 +1,40 @@
1+package core
2+
3+// layoutAxis is a logical navigation axis, independent of rendering.
4+type layoutAxis uint8
5+
6+const (
7+ verticalAxis layoutAxis = iota
8+ horizontalAxis
9+)
10+
11+func (a layoutAxis) span(r Rect) int {
12+ if a == verticalAxis {
13+ return r.H
14+ }
15+ return r.W
16+}
17+func (a layoutAxis) offset(r *Rect, pixels int) {
18+ if a == verticalAxis {
19+ r.Y += pixels
20+ } else {
21+ r.X += pixels
22+ }
23+}
24+
25+// layoutPolicy keeps geometry capabilities distinct from workspace navigation.
26+// Window state remains owned by Workspace; policies contain no mutable state.
27+type layoutPolicy struct {
28+ content, workspace layoutAxis
29+ equalCells, appendLast, wraps bool
30+}
31+
32+func (w *Workspace) policy() layoutPolicy {
33+ if w.Overflow == OverflowCascade {
34+ return layoutPolicy{content: verticalAxis, workspace: horizontalAxis, equalCells: true, appendLast: true, wraps: true}
35+ }
36+ if w.Overflow == OverflowFixed {
37+ return layoutPolicy{content: horizontalAxis, workspace: verticalAxis, equalCells: true, appendLast: true}
38+ }
39+ return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}
40+}
M internal/core/layout_test.go +12 -12
@@ -48,16 +48,16 @@ func TestOperations(t *testing.T) {
4848 w.AddWindow(2)
4949 w.AddWindow(3)
5050 // Three auto columns, two visible: each (100-3*5)/2 = 42 wide.
51- if w.Focus != 2 || w.ViewX != 46 || w.columnWidth(0) != 42 {
51+ if w.Focus != 2 || w.View != 46 || w.columnWidth(0) != 42 {
5252 t.Fatalf("scroll: %+v", w)
5353 }
5454 w.FocusColumn(-1)
55- if w.ViewX != 46 {
56- t.Fatal(w.ViewX)
55+ if w.View != 46 {
56+ t.Fatal(w.View)
5757 }
5858 w.FocusColumn(-1)
59- if w.ViewX != 0 {
60- t.Fatal(w.ViewX)
59+ if w.View != 0 {
60+ t.Fatal(w.View)
6161 }
6262 w.MoveColumn(1)
6363 if w.Focus != 1 || w.Columns[1].Windows[0] != 1 {
@@ -264,7 +264,7 @@ func FuzzWorkspaceOps(f *testing.F) {
264264 if w.Focus < 0 || w.Focus >= len(w.Columns) {
265265 t.Fatal("focus")
266266 }
267- left := w.columnX(w.Focus) - w.ViewX
267+ left := w.columnX(w.Focus) - w.View
268268 width := w.columnWidth(w.Focus)
269269 minX, maxX := w.Usable.X+w.gap(), w.Usable.X+w.Usable.W-w.gap()
270270 if w.fullscreenColumn(w.Focus) {
@@ -352,18 +352,18 @@ func TestLargeWidthAndBounds(t *testing.T) {
352352 t.Fatal(w.Output)
353353 }
354354 }
355-func TestUnchangedUsablePreservesViewX(t *testing.T) {
355+func TestUnchangedUsablePreservesView(t *testing.T) {
356356 w := workspace()
357357 w.AddWindow(1)
358358 w.AddWindow(2)
359359 w.AddWindow(3)
360- before := w.ViewX
360+ before := w.View
361361 if before == 0 {
362362 t.Fatal("expected a scrolled workspace")
363363 }
364364 w.SetUsable(w.Usable)
365- if w.ViewX != before {
366- t.Fatalf("ViewX changed from %d to %d", before, w.ViewX)
365+ if w.View != before {
366+ t.Fatalf("View changed from %d to %d", before, w.View)
367367 }
368368 }
369369
@@ -428,8 +428,8 @@ func TestFixedOverflowSpiral(t *testing.T) {
428428 if !reflect.DeepEqual(got, want) {
429429 t.Fatalf("got %v\nwant %v", got, want)
430430 }
431- if w.ViewX != 0 {
432- t.Fatal("fixed overflow scrolled", w.ViewX)
431+ if w.View != 0 {
432+ t.Fatal("fixed overflow scrolled", w.View)
433433 }
434434 // Up to max-columns it is the plain equal split.
435435 w2 := &Workspace{MaxColumns: 2, Overflow: OverflowFixed}
@@ -48,16 +48,16 @@ func TestOperations(t *testing.T) {
48 w.AddWindow(2)48 w.AddWindow(2)
49 w.AddWindow(3)49 w.AddWindow(3)
50 // Three auto columns, two visible: each (100-3*5)/2 = 42 wide.50 // Three auto columns, two visible: each (100-3*5)/2 = 42 wide.
51- if w.Focus != 2 || w.ViewX != 46 || w.columnWidth(0) != 42 {51+ if w.Focus != 2 || w.View != 46 || w.columnWidth(0) != 42 {
52 t.Fatalf("scroll: %+v", w)52 t.Fatalf("scroll: %+v", w)
53 }53 }
54 w.FocusColumn(-1)54 w.FocusColumn(-1)
55- if w.ViewX != 46 {55+ if w.View != 46 {
56- t.Fatal(w.ViewX)56+ t.Fatal(w.View)
57 }57 }
58 w.FocusColumn(-1)58 w.FocusColumn(-1)
59- if w.ViewX != 0 {59+ if w.View != 0 {
60- t.Fatal(w.ViewX)60+ t.Fatal(w.View)
61 }61 }
62 w.MoveColumn(1)62 w.MoveColumn(1)
63 if w.Focus != 1 || w.Columns[1].Windows[0] != 1 {63 if w.Focus != 1 || w.Columns[1].Windows[0] != 1 {
@@ -264,7 +264,7 @@ func FuzzWorkspaceOps(f *testing.F) {
264 if w.Focus < 0 || w.Focus >= len(w.Columns) {264 if w.Focus < 0 || w.Focus >= len(w.Columns) {
265 t.Fatal("focus")265 t.Fatal("focus")
266 }266 }
267- left := w.columnX(w.Focus) - w.ViewX267+ left := w.columnX(w.Focus) - w.View
268 width := w.columnWidth(w.Focus)268 width := w.columnWidth(w.Focus)
269 minX, maxX := w.Usable.X+w.gap(), w.Usable.X+w.Usable.W-w.gap()269 minX, maxX := w.Usable.X+w.gap(), w.Usable.X+w.Usable.W-w.gap()
270 if w.fullscreenColumn(w.Focus) {270 if w.fullscreenColumn(w.Focus) {
@@ -352,18 +352,18 @@ func TestLargeWidthAndBounds(t *testing.T) {
352 t.Fatal(w.Output)352 t.Fatal(w.Output)
353 }353 }
354 }354 }
355-func TestUnchangedUsablePreservesViewX(t *testing.T) {355+func TestUnchangedUsablePreservesView(t *testing.T) {
356 w := workspace()356 w := workspace()
357 w.AddWindow(1)357 w.AddWindow(1)
358 w.AddWindow(2)358 w.AddWindow(2)
359 w.AddWindow(3)359 w.AddWindow(3)
360- before := w.ViewX360+ before := w.View
361 if before == 0 {361 if before == 0 {
362 t.Fatal("expected a scrolled workspace")362 t.Fatal("expected a scrolled workspace")
363 }363 }
364 w.SetUsable(w.Usable)364 w.SetUsable(w.Usable)
365- if w.ViewX != before {365+ if w.View != before {
366- t.Fatalf("ViewX changed from %d to %d", before, w.ViewX)366+ t.Fatalf("View changed from %d to %d", before, w.View)
367 }367 }
368 }368 }
369 369
@@ -428,8 +428,8 @@ func TestFixedOverflowSpiral(t *testing.T) {
428 if !reflect.DeepEqual(got, want) {428 if !reflect.DeepEqual(got, want) {
429 t.Fatalf("got %v\nwant %v", got, want)429 t.Fatalf("got %v\nwant %v", got, want)
430 }430 }
431- if w.ViewX != 0 {431+ if w.View != 0 {
432- t.Fatal("fixed overflow scrolled", w.ViewX)432+ t.Fatal("fixed overflow scrolled", w.View)
433 }433 }
434 // Up to max-columns it is the plain equal split.434 // Up to max-columns it is the plain equal split.
435 w2 := &Workspace{MaxColumns: 2, Overflow: OverflowFixed}435 w2 := &Workspace{MaxColumns: 2, Overflow: OverflowFixed}
M internal/core/monitor.go +6 -0
@@ -37,6 +37,8 @@ type Monitor struct {
3737 // switchView slides the view between numbered workspaces: the view is
3838 // at Active+switchView.off, in workspaces.
3939 switchView slide
40+ // switchAxis is frozen from the source workspace for each transition.
41+ switchAxis layoutAxis
4042 // switchList is the numbered list a landing slide measures from (the
4143 // one its swipe began on); switchView.off is then from the current
4244 // workspace's place in it.
@@ -508,10 +510,14 @@ func (m *Monitor) applyNamed() {
508510 }
509511 m.each(func(w *Workspace) {
510512 s := specs[w.Name]
513+ before := w.Overflow
511514 w.Overflow = m.template.Overflow
512515 if s.Overflow != "" {
513516 w.Overflow = s.Overflow
514517 }
518+ if before != w.Overflow && w.policy().equalCells {
519+ w.unmaximize()
520+ }
515521 w.SetMaxColumns(m.template.MaxColumns)
516522 if s.MaxColumns > 0 {
517523 w.SetMaxColumns(s.MaxColumns)
@@ -37,6 +37,8 @@ type Monitor struct {
37 // switchView slides the view between numbered workspaces: the view is37 // switchView slides the view between numbered workspaces: the view is
38 // at Active+switchView.off, in workspaces.38 // at Active+switchView.off, in workspaces.
39 switchView slide39 switchView slide
40+ // switchAxis is frozen from the source workspace for each transition.
41+ switchAxis layoutAxis
40 // switchList is the numbered list a landing slide measures from (the42 // switchList is the numbered list a landing slide measures from (the
41 // one its swipe began on); switchView.off is then from the current43 // one its swipe began on); switchView.off is then from the current
42 // workspace's place in it.44 // workspace's place in it.
@@ -508,10 +510,14 @@ func (m *Monitor) applyNamed() {
508 }510 }
509 m.each(func(w *Workspace) {511 m.each(func(w *Workspace) {
510 s := specs[w.Name]512 s := specs[w.Name]
513+ before := w.Overflow
511 w.Overflow = m.template.Overflow514 w.Overflow = m.template.Overflow
512 if s.Overflow != "" {515 if s.Overflow != "" {
513 w.Overflow = s.Overflow516 w.Overflow = s.Overflow
514 }517 }
518+ if before != w.Overflow && w.policy().equalCells {
519+ w.unmaximize()
520+ }
515 w.SetMaxColumns(m.template.MaxColumns)521 w.SetMaxColumns(m.template.MaxColumns)
516 if s.MaxColumns > 0 {522 if s.MaxColumns > 0 {
517 w.SetMaxColumns(s.MaxColumns)523 w.SetMaxColumns(s.MaxColumns)
M internal/core/monitor_test.go +2 -2
@@ -196,8 +196,8 @@ func TestEqualSharesAndBorderless(t *testing.T) {
196196 }
197197 // A fourth column scrolls: still 1/3 each.
198198 m.AddWindow(4)
199- if w := m.Current(); w.columnWidth(3) != 33 || w.ViewX == 0 {
200- t.Fatal(w.ViewX)
199+ if w := m.Current(); w.columnWidth(3) != 33 || w.View == 0 {
200+ t.Fatal(w.View)
201201 }
202202 // A preset on one column is kept; auto columns keep their share.
203203 m.SetPresets([]Width{{Num: 1, Den: 2}})
@@ -196,8 +196,8 @@ func TestEqualSharesAndBorderless(t *testing.T) {
196 }196 }
197 // A fourth column scrolls: still 1/3 each.197 // A fourth column scrolls: still 1/3 each.
198 m.AddWindow(4)198 m.AddWindow(4)
199- if w := m.Current(); w.columnWidth(3) != 33 || w.ViewX == 0 {199+ if w := m.Current(); w.columnWidth(3) != 33 || w.View == 0 {
200- t.Fatal(w.ViewX)200+ t.Fatal(w.View)
201 }201 }
202 // A preset on one column is kept; auto columns keep their share.202 // A preset on one column is kept; auto columns keep their share.
203 m.SetPresets([]Width{{Num: 1, Den: 2}})203 m.SetPresets([]Width{{Num: 1, Den: 2}})
M internal/core/motion_test.go +3 -3
@@ -433,14 +433,14 @@ func TestMotionVelocity(t *testing.T) {
433433
434434 func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) {
435435 start := time.Unix(10, 0)
436- w := &Workspace{ViewX: 100, view: slide{off: -300}}
436+ w := &Workspace{View: 100, view: slide{off: -300}}
437437 w.view.motion = newMotion(viewSpring(w.view.off, 2000), start, 2)
438438 w.view.motion.at(start.Add(20 * time.Millisecond))
439439 v := w.view.motion.velocity()
440440 if v <= 0 {
441441 t.Fatalf("velocity %v", v)
442442 }
443- w.ViewX = 40
443+ w.View = 40
444444 w.retarget(100)
445445 if w.view.off != -240 || !w.view.motion.on || w.view.motion.slow != 2 {
446446 t.Fatalf("shift %v motion %+v", w.view.off, w.view.motion)
@@ -452,7 +452,7 @@ func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) {
452452 t.Fatalf("spring velocity %v, want %v in spring time", got, 2*v)
453453 }
454454 var idle Workspace
455- idle.ViewX = 5
455+ idle.View = 5
456456 idle.retarget(0)
457457 if idle.view.motion.on || idle.view.off != 0 {
458458 t.Fatal("retarget without a slide must not start one")
@@ -433,14 +433,14 @@ func TestMotionVelocity(t *testing.T) {
433 433
434 func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) {434 func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) {
435 start := time.Unix(10, 0)435 start := time.Unix(10, 0)
436- w := &Workspace{ViewX: 100, view: slide{off: -300}}436+ w := &Workspace{View: 100, view: slide{off: -300}}
437 w.view.motion = newMotion(viewSpring(w.view.off, 2000), start, 2)437 w.view.motion = newMotion(viewSpring(w.view.off, 2000), start, 2)
438 w.view.motion.at(start.Add(20 * time.Millisecond))438 w.view.motion.at(start.Add(20 * time.Millisecond))
439 v := w.view.motion.velocity()439 v := w.view.motion.velocity()
440 if v <= 0 {440 if v <= 0 {
441 t.Fatalf("velocity %v", v)441 t.Fatalf("velocity %v", v)
442 }442 }
443- w.ViewX = 40443+ w.View = 40
444 w.retarget(100)444 w.retarget(100)
445 if w.view.off != -240 || !w.view.motion.on || w.view.motion.slow != 2 {445 if w.view.off != -240 || !w.view.motion.on || w.view.motion.slow != 2 {
446 t.Fatalf("shift %v motion %+v", w.view.off, w.view.motion)446 t.Fatalf("shift %v motion %+v", w.view.off, w.view.motion)
@@ -452,7 +452,7 @@ func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) {
452 t.Fatalf("spring velocity %v, want %v in spring time", got, 2*v)452 t.Fatalf("spring velocity %v, want %v in spring time", got, 2*v)
453 }453 }
454 var idle Workspace454 var idle Workspace
455- idle.ViewX = 5455+ idle.View = 5
456 idle.retarget(0)456 idle.retarget(0)
457 if idle.view.motion.on || idle.view.off != 0 {457 if idle.view.motion.on || idle.view.off != 0 {
458 t.Fatal("retarget without a slide must not start one")458 t.Fatal("retarget without a slide must not start one")
M internal/core/overview.go +6 -2
@@ -293,7 +293,7 @@ func restoreRow(w *Workspace, s rowSnapshot) {
293293 // Fixed overflow maximizes at most one column: only the one holding
294294 // the anchor window, wherever active binds moved it. Scroll overflow
295295 // keeps each column's own width.
296- if w.Overflow == OverflowFixed {
296+ if w.policy().equalCells {
297297 w.unmaximize()
298298 for i := range w.Columns {
299299 if s.fullWidth != 0 && slices.Contains(w.Columns[i].Windows, s.fullWidth) {
@@ -748,7 +748,11 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
748748 maximized := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth
749749 for i, c := range w.Columns {
750750 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}
751- if w.Overflow == OverflowFixed {
751+ if w.policy().wraps {
752+ width := w.cellWidth()
753+ r.X = w.band(i)*w.Usable.W + w.cellX(i)
754+ r.W = width
755+ } else if w.Overflow == OverflowFixed {
752756 r = rects[i]
753757 r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y
754758 } else if w.fullscreenColumn(i) {
@@ -293,7 +293,7 @@ func restoreRow(w *Workspace, s rowSnapshot) {
293 // Fixed overflow maximizes at most one column: only the one holding293 // Fixed overflow maximizes at most one column: only the one holding
294 // the anchor window, wherever active binds moved it. Scroll overflow294 // the anchor window, wherever active binds moved it. Scroll overflow
295 // keeps each column's own width.295 // keeps each column's own width.
296- if w.Overflow == OverflowFixed {296+ if w.policy().equalCells {
297 w.unmaximize()297 w.unmaximize()
298 for i := range w.Columns {298 for i := range w.Columns {
299 if s.fullWidth != 0 && slices.Contains(w.Columns[i].Windows, s.fullWidth) {299 if s.fullWidth != 0 && slices.Contains(w.Columns[i].Windows, s.fullWidth) {
@@ -748,7 +748,11 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
748 maximized := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth748 maximized := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth
749 for i, c := range w.Columns {749 for i, c := range w.Columns {
750 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}750 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}
751- if w.Overflow == OverflowFixed {751+ if w.policy().wraps {
752+ width := w.cellWidth()
753+ r.X = w.band(i)*w.Usable.W + w.cellX(i)
754+ r.W = width
755+ } else if w.Overflow == OverflowFixed {
752 r = rects[i]756 r = rects[i]
753 r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y757 r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y
754 } else if w.fullscreenColumn(i) {758 } else if w.fullscreenColumn(i) {
M internal/core/overview_behavior_test.go +3 -3
@@ -158,14 +158,14 @@ func TestOverviewBrowsedRowsRestored(t *testing.T) {
158158 for _, ov := range []Overflow{OverflowScroll, OverflowFixed} {
159159 for name, path := range paths {
160160 m, ws2 := browsedMonitor(ov)
161- view := ws2.ViewX
161+ view := ws2.View
162162 m.ToggleOverview()
163163 for _, d := range path {
164164 m.OverviewMove(d[0], d[1])
165165 }
166166 m.CancelOverview()
167- if id, _ := ws2.Focused(); id != 5 || ws2.ViewX != view || m.Current() != m.Workspaces[0] {
168- t.Fatalf("%v %s: focus %d view %v (want %v) current %v", ov, name, id, ws2.ViewX, view, m.Active)
167+ if id, _ := ws2.Focused(); id != 5 || ws2.View != view || m.Current() != m.Workspaces[0] {
168+ t.Fatalf("%v %s: focus %d view %v (want %v) current %v", ov, name, id, ws2.View, view, m.Active)
169169 }
170170 }
171171 }
@@ -158,14 +158,14 @@ func TestOverviewBrowsedRowsRestored(t *testing.T) {
158 for _, ov := range []Overflow{OverflowScroll, OverflowFixed} {158 for _, ov := range []Overflow{OverflowScroll, OverflowFixed} {
159 for name, path := range paths {159 for name, path := range paths {
160 m, ws2 := browsedMonitor(ov)160 m, ws2 := browsedMonitor(ov)
161- view := ws2.ViewX161+ view := ws2.View
162 m.ToggleOverview()162 m.ToggleOverview()
163 for _, d := range path {163 for _, d := range path {
164 m.OverviewMove(d[0], d[1])164 m.OverviewMove(d[0], d[1])
165 }165 }
166 m.CancelOverview()166 m.CancelOverview()
167- if id, _ := ws2.Focused(); id != 5 || ws2.ViewX != view || m.Current() != m.Workspaces[0] {167+ if id, _ := ws2.Focused(); id != 5 || ws2.View != view || m.Current() != m.Workspaces[0] {
168- t.Fatalf("%v %s: focus %d view %v (want %v) current %v", ov, name, id, ws2.ViewX, view, m.Active)168+ t.Fatalf("%v %s: focus %d view %v (want %v) current %v", ov, name, id, ws2.View, view, m.Active)
169 }169 }
170 }170 }
171 }171 }
M internal/core/overview_stack.go +1 -1
@@ -180,7 +180,7 @@ func (m *Monitor) hiddenColumn(w *Workspace) int {
180180 // selectOverviewColumn retains the existing ordinary-row live focus semantics.
181181 func (w *Workspace) selectOverviewColumn(i int) {
182182 w.floatFocus, w.stashFocus = false, false
183- if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) {
183+ if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) {
184184 w.unmaximize()
185185 }
186186 w.Focus = i
@@ -180,7 +180,7 @@ func (m *Monitor) hiddenColumn(w *Workspace) int {
180 // selectOverviewColumn retains the existing ordinary-row live focus semantics.180 // selectOverviewColumn retains the existing ordinary-row live focus semantics.
181 func (w *Workspace) selectOverviewColumn(i int) {181 func (w *Workspace) selectOverviewColumn(i int) {
182 w.floatFocus, w.stashFocus = false, false182 w.floatFocus, w.stashFocus = false, false
183- if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) {183+ if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) {
184 w.unmaximize()184 w.unmaximize()
185 }185 }
186 w.Focus = i186 w.Focus = i
M internal/core/separators_test.go +1 -1
@@ -125,7 +125,7 @@ func TestSeparatorsScroll(t *testing.T) {
125125 if before[1] != 0 || before[3]&ports.SideRight != 0 {
126126 t.Fatalf("scrolled tiles reserve off-output lines: %v", before)
127127 }
128- if w.ViewX == 0 {
128+ if w.View == 0 {
129129 t.Fatal("expected a scrolled view")
130130 }
131131 m.Apply(ActionFocusColumnLeft)
@@ -125,7 +125,7 @@ func TestSeparatorsScroll(t *testing.T) {
125 if before[1] != 0 || before[3]&ports.SideRight != 0 {125 if before[1] != 0 || before[3]&ports.SideRight != 0 {
126 t.Fatalf("scrolled tiles reserve off-output lines: %v", before)126 t.Fatalf("scrolled tiles reserve off-output lines: %v", before)
127 }127 }
128- if w.ViewX == 0 {128+ if w.View == 0 {
129 t.Fatal("expected a scrolled view")129 t.Fatal("expected a scrolled view")
130 }130 }
131 m.Apply(ActionFocusColumnLeft)131 m.Apply(ActionFocusColumnLeft)
M internal/core/slide.go +15 -9
@@ -8,7 +8,7 @@ import (
88
99 // Slides move what is on screen smoothly: a workspace's columns (view), its
1010 // stash (stashView) and the monitor between workspaces (switchView). All
11-// are pure presentation over the settled state (ViewX, Focus, stashAt,
11+// are pure presentation over the settled state (View, Focus, stashAt,
1212 // Active): the layout ends where the state says as soon as a slide stops.
1313
1414 // slide is a view offset that a swipe sets while the fingers follow it, and
@@ -43,14 +43,14 @@ func (w *Workspace) slidable() bool {
4343 return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat()
4444 }
4545
46-// retarget keeps the view where it is on screen when ViewX moved from
46+// retarget keeps the view where it is on screen when View moved from
4747 // before during a landing slide: the slide heads for the new view.
4848 // Without a landing slide the view moves at once, as it always has.
4949 func (w *Workspace) retarget(before int) {
50- if w.ViewX == before || !w.view.motion.on {
50+ if w.View == before || !w.view.motion.on {
5151 return
5252 }
53- w.view.off += float64(before - w.ViewX)
53+ w.view.off += float64(before - w.View)
5454 w.view.motion = newMotion(viewSpring(w.view.off, w.view.motion.velocity()), time.Time{}, w.view.motion.slow)
5555 }
5656
@@ -61,7 +61,7 @@ func (w *Workspace) shiftPixels() int { return int(math.Round(w.view.off)) }
6161 // (slideLayout rounds the offset times the height).
6262 func (m *Monitor) switchSpring(off, velocity float64) spring {
6363 s := workspaceSpring(off, velocity)
64- if h := m.template.Output.H; h > 0 {
64+ if h := m.switchAxis.span(m.template.Output); h > 0 {
6565 s.Snap = pixelSnap / float64(h)
6666 }
6767 return s
@@ -70,7 +70,7 @@ func (m *Monitor) switchSpring(off, velocity float64) spring {
7070 // swipeScale turns touchpad distance into pixels: viewSwipeMovement
7171 // scrolls one usable width.
7272 func (w *Workspace) swipeScale() float64 {
73- return float64(w.Usable.W) / viewSwipeMovement
73+ return float64(w.policy().content.span(w.Usable)) / viewSwipeMovement
7474 }
7575
7676 // snapPoints are the sorted views that align a column edge with the usable
@@ -78,6 +78,9 @@ func (w *Workspace) swipeScale() float64 {
7878 // snapSpacing of the usable width merge into the first: a swipe step
7979 // always moves the view visibly.
8080 func (w *Workspace) snapPoints() []float64 {
81+ if w.policy().wraps {
82+ return w.cascadePoints()
83+ }
8184 g := w.gap()
8285 minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g
8386 last := len(w.Columns) - 1
@@ -113,13 +116,16 @@ const snapSpacing = 0.1
113116 // fully shown at the current view, else the furthest fully shown column
114117 // toward the swipe (forward is rightward).
115118 func (w *Workspace) snapFocus(view int, forward bool) (focus int) {
119+ if w.policy().wraps {
120+ return w.cascadeFocus(view)
121+ }
116122 g := w.gap()
117123 minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g
118124 full := func(i int) bool {
119125 x := w.columnX(i) - view
120126 return x >= minX && x+w.columnWidth(i) <= maxX
121127 }
122- if full(w.Focus) && view == w.ViewX {
128+ if full(w.Focus) && view == w.View {
123129 return w.Focus
124130 }
125131 focus = -1
@@ -183,13 +189,13 @@ func (m *Monitor) slideLayout(cur []Placement) []Placement {
183189 if base < 0 {
184190 return cur
185191 }
186- h := float64(m.template.Output.H)
192+ h := float64(m.switchAxis.span(m.template.Output))
187193 pos := float64(base) + m.switchView.off
188194 offset := func(p []Placement, k int) []Placement {
189195 dy := int(math.Round((float64(k) - pos) * h))
190196 for i := range p {
191197 if !p[i].Hidden {
192- p[i].Rect.Y += dy
198+ m.switchAxis.offset(&p[i].Rect, dy)
193199 }
194200 }
195201 return p
@@ -8,7 +8,7 @@ import (
8 8
9 // Slides move what is on screen smoothly: a workspace's columns (view), its9 // Slides move what is on screen smoothly: a workspace's columns (view), its
10 // stash (stashView) and the monitor between workspaces (switchView). All10 // stash (stashView) and the monitor between workspaces (switchView). All
11-// are pure presentation over the settled state (ViewX, Focus, stashAt,11+// are pure presentation over the settled state (View, Focus, stashAt,
12 // Active): the layout ends where the state says as soon as a slide stops.12 // Active): the layout ends where the state says as soon as a slide stops.
13 13
14 // slide is a view offset that a swipe sets while the fingers follow it, and14 // slide is a view offset that a swipe sets while the fingers follow it, and
@@ -43,14 +43,14 @@ 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()
44 }44 }
45 45
46-// retarget keeps the view where it is on screen when ViewX moved from46+// retarget keeps the view where it is on screen when View moved from
47 // before during a landing slide: the slide heads for the new view.47 // before during a landing slide: the slide heads for the new view.
48 // Without a landing slide the view moves at once, as it always has.48 // Without a landing slide the view moves at once, as it always has.
49 func (w *Workspace) retarget(before int) {49 func (w *Workspace) retarget(before int) {
50- if w.ViewX == before || !w.view.motion.on {50+ if w.View == before || !w.view.motion.on {
51 return51 return
52 }52 }
53- w.view.off += float64(before - w.ViewX)53+ w.view.off += float64(before - w.View)
54 w.view.motion = newMotion(viewSpring(w.view.off, w.view.motion.velocity()), time.Time{}, w.view.motion.slow)54 w.view.motion = newMotion(viewSpring(w.view.off, w.view.motion.velocity()), time.Time{}, w.view.motion.slow)
55 }55 }
56 56
@@ -61,7 +61,7 @@ func (w *Workspace) shiftPixels() int { return int(math.Round(w.view.off)) }
61 // (slideLayout rounds the offset times the height).61 // (slideLayout rounds the offset times the height).
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.template.Output.H; h > 0 {64+ if h := m.switchAxis.span(m.template.Output); h > 0 {
65 s.Snap = pixelSnap / float64(h)65 s.Snap = pixelSnap / float64(h)
66 }66 }
67 return s67 return s
@@ -70,7 +70,7 @@ func (m *Monitor) switchSpring(off, velocity float64) spring {
70 // swipeScale turns touchpad distance into pixels: viewSwipeMovement70 // swipeScale turns touchpad distance into pixels: viewSwipeMovement
71 // scrolls one usable width.71 // scrolls one usable width.
72 func (w *Workspace) swipeScale() float64 {72 func (w *Workspace) swipeScale() float64 {
73- return float64(w.Usable.W) / viewSwipeMovement73+ return float64(w.policy().content.span(w.Usable)) / viewSwipeMovement
74 }74 }
75 75
76 // snapPoints are the sorted views that align a column edge with the usable76 // snapPoints are the sorted views that align a column edge with the usable
@@ -78,6 +78,9 @@ func (w *Workspace) swipeScale() float64 {
78 // snapSpacing of the usable width merge into the first: a swipe step78 // snapSpacing of the usable width merge into the first: a swipe step
79 // always moves the view visibly.79 // always moves the view visibly.
80 func (w *Workspace) snapPoints() []float64 {80 func (w *Workspace) snapPoints() []float64 {
81+ if w.policy().wraps {
82+ return w.cascadePoints()
83+ }
81 g := w.gap()84 g := w.gap()
82 minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g85 minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g
83 last := len(w.Columns) - 186 last := len(w.Columns) - 1
@@ -113,13 +116,16 @@ const snapSpacing = 0.1
113 // fully shown at the current view, else the furthest fully shown column116 // fully shown at the current view, else the furthest fully shown column
114 // toward the swipe (forward is rightward).117 // toward the swipe (forward is rightward).
115 func (w *Workspace) snapFocus(view int, forward bool) (focus int) {118 func (w *Workspace) snapFocus(view int, forward bool) (focus int) {
119+ if w.policy().wraps {
120+ return w.cascadeFocus(view)
121+ }
116 g := w.gap()122 g := w.gap()
117 minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g123 minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g
118 full := func(i int) bool {124 full := func(i int) bool {
119 x := w.columnX(i) - view125 x := w.columnX(i) - view
120 return x >= minX && x+w.columnWidth(i) <= maxX126 return x >= minX && x+w.columnWidth(i) <= maxX
121 }127 }
122- if full(w.Focus) && view == w.ViewX {128+ if full(w.Focus) && view == w.View {
123 return w.Focus129 return w.Focus
124 }130 }
125 focus = -1131 focus = -1
@@ -183,13 +189,13 @@ func (m *Monitor) slideLayout(cur []Placement) []Placement {
183 if base < 0 {189 if base < 0 {
184 return cur190 return cur
185 }191 }
186- h := float64(m.template.Output.H)192+ h := float64(m.switchAxis.span(m.template.Output))
187 pos := float64(base) + m.switchView.off193 pos := float64(base) + m.switchView.off
188 offset := func(p []Placement, k int) []Placement {194 offset := func(p []Placement, k int) []Placement {
189 dy := int(math.Round((float64(k) - pos) * h))195 dy := int(math.Round((float64(k) - pos) * h))
190 for i := range p {196 for i := range p {
191 if !p[i].Hidden {197 if !p[i].Hidden {
192- p[i].Rect.Y += dy198+ m.switchAxis.offset(&p[i].Rect, dy)
193 }199 }
194 }200 }
195 return p201 return p
M internal/core/stash.go +3 -0
@@ -132,6 +132,9 @@ func (w *Workspace) restore(id WindowID, back *origPlace) {
132132 }
133133 c.Windows = slices.Insert(c.Windows, row, id)
134134 c.Focus = row
135+ if w.policy().equalCells && w.Focus != i {
136+ w.unmaximize()
137+ }
135138 w.Focus = i
136139 w.scroll()
137140 return
@@ -132,6 +132,9 @@ func (w *Workspace) restore(id WindowID, back *origPlace) {
132 }132 }
133 c.Windows = slices.Insert(c.Windows, row, id)133 c.Windows = slices.Insert(c.Windows, row, id)
134 c.Focus = row134 c.Focus = row
135+ if w.policy().equalCells && w.Focus != i {
136+ w.unmaximize()
137+ }
135 w.Focus = i138 w.Focus = i
136 w.scroll()139 w.scroll()
137 return140 return
M internal/core/stash_test.go +3 -3
@@ -334,9 +334,9 @@ func TestActivateHiddenStashEndsScrollFullscreen(t *testing.T) {
334334 if id, _ := w.Focused(); id != 3 || w.fullscreen != 0 {
335335 t.Fatalf("focused %d fullscreen %d", id, w.fullscreen)
336336 }
337- view := w.ViewX
337+ view := w.View
338338 w.scroll()
339- if w.ViewX != view {
340- t.Fatalf("view %d, settled %d", view, w.ViewX)
339+ if w.View != view {
340+ t.Fatalf("view %d, settled %d", view, w.View)
341341 }
342342 }
@@ -334,9 +334,9 @@ func TestActivateHiddenStashEndsScrollFullscreen(t *testing.T) {
334 if id, _ := w.Focused(); id != 3 || w.fullscreen != 0 {334 if id, _ := w.Focused(); id != 3 || w.fullscreen != 0 {
335 t.Fatalf("focused %d fullscreen %d", id, w.fullscreen)335 t.Fatalf("focused %d fullscreen %d", id, w.fullscreen)
336 }336 }
337- view := w.ViewX337+ view := w.View
338 w.scroll()338 w.scroll()
339- if w.ViewX != view {339+ if w.View != view {
340- t.Fatalf("view %d, settled %d", view, w.ViewX)340+ t.Fatalf("view %d, settled %d", view, w.View)
341 }341 }
342 }342 }
M internal/core/transition.go +13 -7
@@ -28,15 +28,16 @@ type viewShot struct {
2828 // overview is set when the screen showed its overview: ws is then the
2929 // row on screen, the camera fields stay zero, and rects are the cards.
3030 overview bool
31- // viewX is ws's settled view and view where its columns were on screen
32- // (viewX plus shift).
33- viewX int
34- view float64
31+ // settledView is ws's settled view and view where its columns were on screen
32+ // (settledView plus shift).
33+ settledView int
34+ view float64
3535 // viewV and switchV are the speeds the running view and workspace
3636 // springs have at the snapshot's time (units per second, 0 without
3737 // one), not at their last frame. An action may stop them
3838 // (Monitor.Focus), so the transition reads them here.
3939 viewV, switchV float64
40+ switchAxis layoutAxis
4041 // stashShown, stashAt, stashLen and stashPos are whether the stash was
4142 // on screen, its selection, size and drawn position (selection plus
4243 // stashView.off, in stash windows); stashV is its slide's speed.
@@ -621,7 +622,11 @@ func (c *Core) snapshot(now time.Time) []viewShot {
621622 s.ws, s.overview = w, m.ov.open
622623 if !m.ov.open {
623624 // The overview does not scroll or slide: no camera.
624- s.viewX, s.view = w.ViewX, float64(w.ViewX)+w.view.off
625+ s.settledView, s.view = w.View, float64(w.View)+w.view.off
626+ s.switchAxis = w.policy().workspace
627+ if m.switchView.busy() {
628+ s.switchAxis = m.switchAxis
629+ }
625630 }
626631 if w.view.motion.on {
627632 _, s.viewV = w.view.motion.sampleAt(now)
@@ -728,12 +733,12 @@ func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) {
728733 w.stashView.motion = c.spring(w.stashSpring(w.stashView.off, b.stashV), now)
729734 return
730735 }
731- if w.ViewX == b.viewX || !w.slidable() {
736+ if w.View == b.settledView || !w.slidable() {
732737 return
733738 }
734739 // A running landing slide was retargeted by scroll(): its velocity
735740 // carries over, and the spring starts now like every action's.
736- w.view.off = b.view - float64(w.ViewX)
741+ w.view.off = b.view - float64(w.View)
737742 w.view.motion = c.spring(viewSpring(w.view.off, b.viewV), now)
738743 case b.ok && m.shown == nil && movedFrom(b, before) == nil:
739744 // A screen that received its workspace from another monitor does
@@ -748,6 +753,7 @@ func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) {
748753 // The snapshot's list is reused by the next action: the slide keeps
749754 // its own copy.
750755 m.switchView.off, m.switchList = off, slices.Clone(b.list)
756+ m.switchAxis = b.switchAxis
751757 if off == 0 || m.framedSwitch() {
752758 m.stopSwitch()
753759 return
@@ -28,15 +28,16 @@ type viewShot struct {
28 // overview is set when the screen showed its overview: ws is then the28 // overview is set when the screen showed its overview: ws is then the
29 // row on screen, the camera fields stay zero, and rects are the cards.29 // row on screen, the camera fields stay zero, and rects are the cards.
30 overview bool30 overview bool
31- // viewX is ws's settled view and view where its columns were on screen31+ // settledView is ws's settled view and view where its columns were on screen
32- // (viewX plus shift).32+ // (settledView plus shift).
33- viewX int33+ settledView int
34- view float6434+ view float64
35 // viewV and switchV are the speeds the running view and workspace35 // viewV and switchV are the speeds the running view and workspace
36 // springs have at the snapshot's time (units per second, 0 without36 // springs have at the snapshot's time (units per second, 0 without
37 // one), not at their last frame. An action may stop them37 // one), not at their last frame. An action may stop them
38 // (Monitor.Focus), so the transition reads them here.38 // (Monitor.Focus), so the transition reads them here.
39 viewV, switchV float6439 viewV, switchV float64
40+ switchAxis layoutAxis
40 // stashShown, stashAt, stashLen and stashPos are whether the stash was41 // stashShown, stashAt, stashLen and stashPos are whether the stash was
41 // on screen, its selection, size and drawn position (selection plus42 // on screen, its selection, size and drawn position (selection plus
42 // stashView.off, in stash windows); stashV is its slide's speed.43 // stashView.off, in stash windows); stashV is its slide's speed.
@@ -621,7 +622,11 @@ func (c *Core) snapshot(now time.Time) []viewShot {
621 s.ws, s.overview = w, m.ov.open622 s.ws, s.overview = w, m.ov.open
622 if !m.ov.open {623 if !m.ov.open {
623 // The overview does not scroll or slide: no camera.624 // The overview does not scroll or slide: no camera.
624- s.viewX, s.view = w.ViewX, float64(w.ViewX)+w.view.off625+ s.settledView, s.view = w.View, float64(w.View)+w.view.off
626+ s.switchAxis = w.policy().workspace
627+ if m.switchView.busy() {
628+ s.switchAxis = m.switchAxis
629+ }
625 }630 }
626 if w.view.motion.on {631 if w.view.motion.on {
627 _, s.viewV = w.view.motion.sampleAt(now)632 _, s.viewV = w.view.motion.sampleAt(now)
@@ -728,12 +733,12 @@ func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) {
728 w.stashView.motion = c.spring(w.stashSpring(w.stashView.off, b.stashV), now)733 w.stashView.motion = c.spring(w.stashSpring(w.stashView.off, b.stashV), now)
729 return734 return
730 }735 }
731- if w.ViewX == b.viewX || !w.slidable() {736+ if w.View == b.settledView || !w.slidable() {
732 return737 return
733 }738 }
734 // A running landing slide was retargeted by scroll(): its velocity739 // A running landing slide was retargeted by scroll(): its velocity
735 // carries over, and the spring starts now like every action's.740 // carries over, and the spring starts now like every action's.
736- w.view.off = b.view - float64(w.ViewX)741+ w.view.off = b.view - float64(w.View)
737 w.view.motion = c.spring(viewSpring(w.view.off, b.viewV), now)742 w.view.motion = c.spring(viewSpring(w.view.off, b.viewV), now)
738 case b.ok && m.shown == nil && movedFrom(b, before) == nil:743 case b.ok && m.shown == nil && movedFrom(b, before) == nil:
739 // A screen that received its workspace from another monitor does744 // A screen that received its workspace from another monitor does
@@ -748,6 +753,7 @@ func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) {
748 // The snapshot's list is reused by the next action: the slide keeps753 // The snapshot's list is reused by the next action: the slide keeps
749 // its own copy.754 // its own copy.
750 m.switchView.off, m.switchList = off, slices.Clone(b.list)755 m.switchView.off, m.switchList = off, slices.Clone(b.list)
756+ m.switchAxis = b.switchAxis
751 if off == 0 || m.framedSwitch() {757 if off == 0 || m.framedSwitch() {
752 m.stopSwitch()758 m.stopSwitch()
753 return759 return
M internal/core/transition_chain_internal_test.go +2 -2
@@ -210,11 +210,11 @@ func TestNoCameraTransitionWhileSwipeFollows(t *testing.T) {
210210 w.view.off = 25
211211 c.swipe = &swipeGesture{screen: sc, ws: w, mode: swipeColumns}
212212 before := c.snapshot(ic.now)
213- viewX := w.ViewX
213+ viewX := w.View
214214 for range 3 {
215215 c.applyAction(ActionFocusColumnLeft)
216216 }
217- if w.ViewX == viewX {
217+ if w.View == viewX {
218218 t.Fatal("the focus did not move the view: the case checks nothing")
219219 }
220220 c.transition(before, ic.now)
@@ -210,11 +210,11 @@ func TestNoCameraTransitionWhileSwipeFollows(t *testing.T) {
210 w.view.off = 25210 w.view.off = 25
211 c.swipe = &swipeGesture{screen: sc, ws: w, mode: swipeColumns}211 c.swipe = &swipeGesture{screen: sc, ws: w, mode: swipeColumns}
212 before := c.snapshot(ic.now)212 before := c.snapshot(ic.now)
213- viewX := w.ViewX213+ viewX := w.View
214 for range 3 {214 for range 3 {
215 c.applyAction(ActionFocusColumnLeft)215 c.applyAction(ActionFocusColumnLeft)
216 }216 }
217- if w.ViewX == viewX {217+ if w.View == viewX {
218 t.Fatal("the focus did not move the view: the case checks nothing")218 t.Fatal("the focus did not move the view: the case checks nothing")
219 }219 }
220 c.transition(before, ic.now)220 c.transition(before, ic.now)
M internal/ports/config.go +2 -2
@@ -64,8 +64,8 @@ type OutputAnchor struct {
6464 type LayoutRules struct {
6565 // MaxColumns is how many columns share the screen at once.
6666 MaxColumns int
67- // Overflow is "scroll" (further columns scroll) or "fixed" (they split
68- // the last one).
67+ // Overflow is "scroll" (horizontal scrolling), "fixed" (spiral splits),
68+ // or "cascade" (vertically scrolling bands of columns).
6969 Overflow string
7070 }
7171
@@ -64,8 +64,8 @@ type OutputAnchor struct {
64 type LayoutRules struct {64 type LayoutRules struct {
65 // MaxColumns is how many columns share the screen at once.65 // MaxColumns is how many columns share the screen at once.
66 MaxColumns int66 MaxColumns int
67- // Overflow is "scroll" (further columns scroll) or "fixed" (they split67+ // Overflow is "scroll" (horizontal scrolling), "fixed" (spiral splits),
68- // the last one).68+ // or "cascade" (vertically scrolling bands of columns).
69 Overflow string69 Overflow string
70 }70 }
71 71

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 26

Tags 10

↑↓ moveenter openesc close