detached d0eb8af

feat(layout): add axis-aware cascade bands and navigation

commit
d0eb8af122de713259b7c721f7cbe9eafae085bf
author
brice · · 2026-10-06 07:44 UTC
parent
2decdfb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
24 files · +397 -90
M docs/config.md +11 -1
@@ -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)) |
@@ -187,6 +187,16 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
187187 - `none` removes a default bind.
188188 - `spawn <command>` runs a program without a shell; see [Running commands](desktop.md#running-commands).
189189
190+### Cascade
191+
192+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.
193+
194+Left/right focus follows column order across band boundaries. Up/down visits stacked windows first, then the closest column in the adjacent band. Column width presets and resizing do nothing; window height shares still apply. Maximizing a column hides its same-band siblings until focus moves or maximization ends.
195+
196+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.
197+
198+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.
199+
190200 ### Actions
191201
192202 | 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)) |
@@ -187,6 +187,16 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
187 - `none` removes a default bind.187 - `none` removes a default bind.
188 - `spawn <command>` runs a program without a shell; see [Running commands](desktop.md#running-commands).188 - `spawn <command>` runs a program without a shell; see [Running commands](desktop.md#running-commands).
189 189
190+### Cascade
191+
192+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.
193+
194+Left/right focus follows column order across band boundaries. Up/down visits stacked windows first, then the closest column in the adjacent band. Column width presets and resizing do nothing; window height shares still apply. Maximizing a column hides its same-band siblings until focus moves or maximization ends.
195+
196+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.
197+
198+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.
199+
190 ### Actions200 ### Actions
191 201
192 | Action | Effect |202 | Action | Effect |
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 +49 -0
@@ -0,0 +1,49 @@
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) bandNeighbor(dir int) int {
7+ k := max(w.MaxColumns, 1)
8+ b := w.band(w.Focus) + dir
9+ if b < 0 || b*k >= len(w.Columns) {
10+ return -1
11+ }
12+ return min(b*k+w.Focus%k, len(w.Columns)-1)
13+}
14+
15+func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
16+ k, g := max(w.MaxColumns, 1), w.gap()
17+ width := max((w.Usable.W-g*(k+1))/k, 0)
18+ rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
19+ view := w.View + w.shiftPixels()
20+ for i := range w.Columns {
21+ r := Rect{X: w.Usable.X + g + (i%k)*(width+g), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: width, H: max(w.Usable.H-2*g, 0)}
22+ if w.Columns[i].FullWidth && !ignoreFullWidth {
23+ r.X = w.Usable.X + g
24+ r.W = max(w.Usable.W-2*g, 0)
25+ }
26+ rects[i] = r
27+ }
28+ return rects
29+}
30+
31+func (w *Workspace) cascadePoints() []float64 {
32+ if len(w.Columns) == 0 || w.Usable.H <= 0 {
33+ return []float64{0}
34+ }
35+ points := make([]float64, w.band(len(w.Columns)-1)+1)
36+ for i := range points {
37+ points[i] = float64(i * w.Usable.H)
38+ }
39+ return points
40+}
41+
42+func (w *Workspace) cascadeFocus(view int) int {
43+ if len(w.Columns) == 0 {
44+ return 0
45+ }
46+ k := max(w.MaxColumns, 1)
47+ b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1))
48+ return min(b*k+w.Focus%k, len(w.Columns)-1)
49+}
@@ -0,0 +1,49 @@
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) bandNeighbor(dir int) int {
7+ k := max(w.MaxColumns, 1)
8+ b := w.band(w.Focus) + dir
9+ if b < 0 || b*k >= len(w.Columns) {
10+ return -1
11+ }
12+ return min(b*k+w.Focus%k, len(w.Columns)-1)
13+}
14+
15+func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
16+ k, g := max(w.MaxColumns, 1), w.gap()
17+ width := max((w.Usable.W-g*(k+1))/k, 0)
18+ rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
19+ view := w.View + w.shiftPixels()
20+ for i := range w.Columns {
21+ r := Rect{X: w.Usable.X + g + (i%k)*(width+g), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: width, H: max(w.Usable.H-2*g, 0)}
22+ if w.Columns[i].FullWidth && !ignoreFullWidth {
23+ r.X = w.Usable.X + g
24+ r.W = max(w.Usable.W-2*g, 0)
25+ }
26+ rects[i] = r
27+ }
28+ return rects
29+}
30+
31+func (w *Workspace) cascadePoints() []float64 {
32+ if len(w.Columns) == 0 || w.Usable.H <= 0 {
33+ return []float64{0}
34+ }
35+ points := make([]float64, w.band(len(w.Columns)-1)+1)
36+ for i := range points {
37+ points[i] = float64(i * w.Usable.H)
38+ }
39+ return points
40+}
41+
42+func (w *Workspace) cascadeFocus(view int) int {
43+ if len(w.Columns) == 0 {
44+ return 0
45+ }
46+ k := max(w.MaxColumns, 1)
47+ b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1))
48+ return min(b*k+w.Focus%k, len(w.Columns)-1)
49+}
A internal/core/cascade_swipe_test.go +29 -0
@@ -0,0 +1,29 @@
1+package core_test
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "testing"
6+)
7+
8+func TestCascadeSwipeAxes(t *testing.T) {
9+ r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "cascade"; c.Layout.MaxColumns = 3 })
10+ for i := 1; i <= 7; i++ {
11+ r.mapWindow(t, ports.WindowID(i))
12+ }
13+ r.begin()
14+ s := r.move(t, 0, -400)
15+ band, ok := rectOf(s, 7)
16+ if !ok || band.X != 0 || band.Y <= 0 {
17+ t.Fatalf("band swipe must move vertically: %v, shown=%v", band, ok)
18+ }
19+ r.end(t, false)
20+ r.settleAll(t, r.outs...)
21+ r.begin()
22+ s = r.move(t, 400, 0)
23+ workspace, ok := rectOf(s, 7)
24+ if !ok || workspace.X == 0 || workspace.Y != 0 {
25+ t.Fatalf("workspace swipe must move horizontally: %v, shown=%v", workspace, ok)
26+ }
27+ r.end(t, true)
28+ r.settleAll(t, r.outs...)
29+}
@@ -0,0 +1,29 @@
1+package core_test
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "testing"
6+)
7+
8+func TestCascadeSwipeAxes(t *testing.T) {
9+ r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "cascade"; c.Layout.MaxColumns = 3 })
10+ for i := 1; i <= 7; i++ {
11+ r.mapWindow(t, ports.WindowID(i))
12+ }
13+ r.begin()
14+ s := r.move(t, 0, -400)
15+ band, ok := rectOf(s, 7)
16+ if !ok || band.X != 0 || band.Y <= 0 {
17+ t.Fatalf("band swipe must move vertically: %v, shown=%v", band, ok)
18+ }
19+ r.end(t, false)
20+ r.settleAll(t, r.outs...)
21+ r.begin()
22+ s = r.move(t, 400, 0)
23+ workspace, ok := rectOf(s, 7)
24+ if !ok || workspace.X == 0 || workspace.Y != 0 {
25+ t.Fatalf("workspace swipe must move horizontally: %v, shown=%v", workspace, ok)
26+ }
27+ r.end(t, true)
28+ r.settleAll(t, r.outs...)
29+}
A internal/core/cascade_test.go +113 -0
@@ -0,0 +1,113 @@
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 TestCascadeViewportSnapAndPolicy(t *testing.T) {
98+ w := cascadeWorkspace(7)
99+ w.FocusID(3)
100+ if w.snapFocus(600, true) != 5 {
101+ t.Fatalf("nearest cell=%d", w.snapFocus(600, true))
102+ }
103+ if w.snapFocus(1200, true) != 6 {
104+ t.Fatal("short band neighbour")
105+ }
106+ if w.policy().content != verticalAxis || w.policy().workspace != horizontalAxis {
107+ t.Fatal("axes")
108+ }
109+ w.SetUsable(Rect{})
110+ if got := w.snapPoints(); len(got) != 1 || got[0] != 0 {
111+ t.Fatalf("zero area=%v", got)
112+ }
113+}
@@ -0,0 +1,113 @@
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 TestCascadeViewportSnapAndPolicy(t *testing.T) {
98+ w := cascadeWorkspace(7)
99+ w.FocusID(3)
100+ if w.snapFocus(600, true) != 5 {
101+ t.Fatalf("nearest cell=%d", w.snapFocus(600, true))
102+ }
103+ if w.snapFocus(1200, true) != 6 {
104+ t.Fatal("short band neighbour")
105+ }
106+ if w.policy().content != verticalAxis || w.policy().workspace != horizontalAxis {
107+ t.Fatal("axes")
108+ }
109+ w.SetUsable(Rect{})
110+ if got := w.snapPoints(); len(got) != 1 || got[0] != 0 {
111+ t.Fatalf("zero area=%v", got)
112+ }
113+}
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 +26 -7
@@ -191,7 +191,7 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool {
191191 g.mode = swipeDropped
192192 return true
193193 }
194- g.ws.view.off = g.snap.pos() - float64(g.ws.ViewX)
194+ g.ws.view.off = g.snap.pos() - float64(g.ws.View)
195195 return true
196196 case swipeWorkspaces:
197197 if g.listChanged(m) {
@@ -241,16 +241,17 @@ func (c *Core) decide(g *swipeGesture) {
241241 }
242242 w.stashView.motion = motion{}
243243 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():
244+ case g.horizontal == (w.policy().content == horizontalAxis) && w.slidable():
245245 g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints()
246246 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:
247+ 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 == (w.policy().workspace == horizontalAxis) && m.shown == nil:
249249 // A landing slide measured in an older list lands at once first.
250250 if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) {
251251 m.stopSwitch()
252252 }
253253 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]
254+ m.switchAxis = w.policy().workspace
254255 g.list = slices.Clone(m.Workspaces)
255256 m.switchView.motion, m.switchList = motion{}, nil
256257 g.snap = newSnapSwipe(float64(m.Active)+m.switchView.off, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand)
@@ -282,18 +283,18 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
282283 w.view.stop()
283284 return false
284285 }
285- shown := float64(w.ViewX) + w.view.off
286+ shown := float64(w.View) + w.view.off
286287 target, velocity := g.snap.end(e.Cancelled, e.Time)
287288 if !e.Cancelled {
288289 view := int(math.Round(target))
289290 w.Focus = w.snapFocus(view, target >= shown)
290- w.ViewX = view
291+ w.View = view
291292 // A column wider than the view aligns as focus moves align it.
292293 w.scroll()
293294 c.focusScreen = c.screenIndex(g.screen.name())
294295 c.keyboard.takeBack()
295296 }
296- w.view.off = shown - float64(w.ViewX)
297+ w.view.off = shown - float64(w.View)
297298 if c.animOn() {
298299 w.view.motion = c.spring(viewSpring(w.view.off, velocity), now)
299300 } else {
@@ -380,6 +381,18 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
380381 if step == 0 {
381382 return false
382383 }
384+ if g.screen.mon.Current().Overflow == OverflowCascade && !g.horizontal {
385+ w := g.screen.mon.Current()
386+ if c.cur() != g.screen || w.onFloat() || w.fullscreen != 0 {
387+ return false
388+ }
389+ if next := w.bandNeighbor(step); next >= 0 {
390+ w.Focus = next
391+ w.scroll()
392+ return true
393+ }
394+ return false
395+ }
383396 a := ActionFocusWorkspaceDown
384397 switch {
385398 case g.horizontal && step < 0:
@@ -389,6 +402,12 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
389402 case step < 0:
390403 a = ActionFocusWorkspaceUp
391404 }
405+ if g.screen.mon.Current().Overflow == OverflowCascade && g.horizontal {
406+ a = ActionFocusWorkspaceDown
407+ if step < 0 {
408+ a = ActionFocusWorkspaceUp
409+ }
410+ }
392411 // Focus actions act on the focused screen: the swipe's, unless the
393412 // pointer took the focus to another output meanwhile.
394413 if c.cur() != g.screen {
@@ -191,7 +191,7 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool {
191 g.mode = swipeDropped191 g.mode = swipeDropped
192 return true192 return true
193 }193 }
194- g.ws.view.off = g.snap.pos() - float64(g.ws.ViewX)194+ g.ws.view.off = g.snap.pos() - float64(g.ws.View)
195 return true195 return true
196 case swipeWorkspaces:196 case swipeWorkspaces:
197 if g.listChanged(m) {197 if g.listChanged(m) {
@@ -241,16 +241,17 @@ func (c *Core) decide(g *swipeGesture) {
241 }241 }
242 w.stashView.motion = motion{}242 w.stashView.motion = motion{}
243 g.snap = newSnapSwipe(float64(w.stashAt)+w.stashView.off, float64(w.stashAt), 1/stashSwipeMovement, indexPoints(len(w.Stash)), workspaceBand)243 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():244+ case g.horizontal == (w.policy().content == horizontalAxis) && w.slidable():
245 g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints()245 g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints()
246 w.view.motion = motion{}246 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)))247+ 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:248+ case g.horizontal == (w.policy().workspace == horizontalAxis) && m.shown == nil:
249 // A landing slide measured in an older list lands at once first.249 // A landing slide measured in an older list lands at once first.
250 if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) {250 if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) {
251 m.stopSwitch()251 m.stopSwitch()
252 }252 }
253 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]253 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]
254+ m.switchAxis = w.policy().workspace
254 g.list = slices.Clone(m.Workspaces)255 g.list = slices.Clone(m.Workspaces)
255 m.switchView.motion, m.switchList = motion{}, nil256 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)257 g.snap = newSnapSwipe(float64(m.Active)+m.switchView.off, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand)
@@ -282,18 +283,18 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
282 w.view.stop()283 w.view.stop()
283 return false284 return false
284 }285 }
285- shown := float64(w.ViewX) + w.view.off286+ shown := float64(w.View) + w.view.off
286 target, velocity := g.snap.end(e.Cancelled, e.Time)287 target, velocity := g.snap.end(e.Cancelled, e.Time)
287 if !e.Cancelled {288 if !e.Cancelled {
288 view := int(math.Round(target))289 view := int(math.Round(target))
289 w.Focus = w.snapFocus(view, target >= shown)290 w.Focus = w.snapFocus(view, target >= shown)
290- w.ViewX = view291+ w.View = view
291 // A column wider than the view aligns as focus moves align it.292 // A column wider than the view aligns as focus moves align it.
292 w.scroll()293 w.scroll()
293 c.focusScreen = c.screenIndex(g.screen.name())294 c.focusScreen = c.screenIndex(g.screen.name())
294 c.keyboard.takeBack()295 c.keyboard.takeBack()
295 }296 }
296- w.view.off = shown - float64(w.ViewX)297+ w.view.off = shown - float64(w.View)
297 if c.animOn() {298 if c.animOn() {
298 w.view.motion = c.spring(viewSpring(w.view.off, velocity), now)299 w.view.motion = c.spring(viewSpring(w.view.off, velocity), now)
299 } else {300 } else {
@@ -380,6 +381,18 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
380 if step == 0 {381 if step == 0 {
381 return false382 return false
382 }383 }
384+ if g.screen.mon.Current().Overflow == OverflowCascade && !g.horizontal {
385+ w := g.screen.mon.Current()
386+ if c.cur() != g.screen || w.onFloat() || w.fullscreen != 0 {
387+ return false
388+ }
389+ if next := w.bandNeighbor(step); next >= 0 {
390+ w.Focus = next
391+ w.scroll()
392+ return true
393+ }
394+ return false
395+ }
383 a := ActionFocusWorkspaceDown396 a := ActionFocusWorkspaceDown
384 switch {397 switch {
385 case g.horizontal && step < 0:398 case g.horizontal && step < 0:
@@ -389,6 +402,12 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
389 case step < 0:402 case step < 0:
390 a = ActionFocusWorkspaceUp403 a = ActionFocusWorkspaceUp
391 }404 }
405+ if g.screen.mon.Current().Overflow == OverflowCascade && g.horizontal {
406+ a = ActionFocusWorkspaceDown
407+ if step < 0 {
408+ a = ActionFocusWorkspaceUp
409+ }
410+ }
392 // Focus actions act on the focused screen: the swipe's, unless the411 // Focus actions act on the focused screen: the swipe's, unless the
393 // pointer took the focus to another output meanwhile.412 // pointer took the focus to another output meanwhile.
394 if c.cur() != g.screen {413 if c.cur() != g.screen {
M internal/core/layout.go +56 -28
@@ -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,8 +511,8 @@ 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
@@ -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,18 @@ func (w *Workspace) focusWindow(dir int) bool {
774777 w.scroll()
775778 return true
776779 }
780+ if w.Overflow == OverflowCascade {
781+ if i := w.bandNeighbor(dir); i >= 0 {
782+ w.Focus = i
783+ w.Columns[i].Focus = 0
784+ if dir < 0 {
785+ w.Columns[i].Focus = len(w.Columns[i].Windows) - 1
786+ }
787+ w.raiseColumns()
788+ w.scroll()
789+ return true
790+ }
791+ }
777792 if w.onScreenFocus() {
778793 if i := w.screenNeighbor(0, dir); i >= 0 {
779794 w.Focus = i
@@ -865,7 +880,7 @@ func (w *Workspace) MoveWindow(dir int) {
865880 // usable width, clamped to 10-100%. An auto column becomes explicit. Fixed
866881 // overflow shares the width equally: nothing changes there.
867882 func (w *Workspace) ResizeColumn(pct int) {
868- if w.onFloat() || len(w.Columns) == 0 || w.Overflow == OverflowFixed || w.fullscreenHides() {
883+ if w.onFloat() || len(w.Columns) == 0 || w.policy().equalCells || w.fullscreenHides() {
869884 return
870885 }
871886 w.unmaximize()
@@ -973,7 +988,7 @@ func (w *Workspace) takeColumn() (Column, bool) {
973988 w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)
974989 w.Focus = min(w.Focus, max(len(w.Columns)-1, 0))
975990 if len(w.Columns) == 0 {
976- w.ViewX = 0
991+ w.View = 0
977992 } else {
978993 w.scroll()
979994 }
@@ -994,6 +1009,9 @@ func (w *Workspace) insertColumn(at int, col Column) {
9941009 // CycleWidth steps the focused column through the presets. Fixed overflow
9951010 // ignores presets: it toggles the expanded column instead.
9961011 func (w *Workspace) CycleWidth() {
1012+ if w.Overflow == OverflowCascade {
1013+ return
1014+ }
9971015 if w.Overflow == OverflowFixed {
9981016 w.toggleExpanded()
9991017 return
@@ -1047,11 +1065,11 @@ func (w *Workspace) maximize(i int) {
10471065 // columns in fixed mode (including after Escape changed focus).
10481066 func (w *Workspace) unmaximize() {
10491067 for i := range w.Columns {
1050- if w.Overflow == OverflowFixed || i == w.Focus {
1068+ if w.policy().equalCells || i == w.Focus {
10511069 w.Columns[i].FullWidth = false
10521070 }
10531071 }
1054- if w.Overflow == OverflowFixed {
1072+ if w.policy().equalCells {
10551073 w.maximized = nil
10561074 }
10571075 }
@@ -1262,7 +1280,7 @@ func (w *Workspace) mayCover(id WindowID) bool {
12621280 }
12631281 w.left = 0
12641282 }
1265- if w.Overflow != OverflowFixed || w.stashHidden && w.stashIndex(id) >= 0 {
1283+ if !w.policy().equalCells || w.stashHidden && w.stashIndex(id) >= 0 {
12661284 return true
12671285 }
12681286 focused, ok := w.Focused()
@@ -1275,7 +1293,7 @@ func (w *Workspace) mayCover(id WindowID) bool {
12751293 // tiled neighbor scroll it off and show the target instead.
12761294 func (w *Workspace) pinned() bool {
12771295 c := w.cover()
1278- return c != 0 && (w.Overflow == OverflowFixed || w.isFloat(c))
1296+ return c != 0 && (w.policy().equalCells || w.isFloat(c))
12791297 }
12801298
12811299 // leaveCover runs a focus move from the covering fullscreen window as if it
@@ -1333,12 +1351,12 @@ func (w *Workspace) cover() WindowID {
13331351 if w.fullscreen == 0 {
13341352 return 0
13351353 }
1336- if w.isFloat(w.fullscreen) || w.Overflow == OverflowFixed {
1354+ if w.isFloat(w.fullscreen) || w.policy().equalCells {
13371355 return w.fullscreen
13381356 }
13391357 // Scroll overflow: only while its column is aligned on the output.
13401358 for i, c := range w.Columns {
1341- if slices.Contains(c.Windows, w.fullscreen) && w.columnX(i)-w.ViewX == 0 {
1359+ if slices.Contains(c.Windows, w.fullscreen) && w.columnX(i)-w.View == 0 {
13421360 return w.fullscreen
13431361 }
13441362 }
@@ -1349,7 +1367,7 @@ func (w *Workspace) cover() WindowID {
13491367 // column's layout: the others (fixed) or its own column (scroll). Column
13501368 // edits would change what the user cannot see.
13511369 func (w *Workspace) fullscreenHides() bool {
1352- return w.fullscreen != 0 && (w.Overflow == OverflowFixed || w.fullscreenColumn(w.Focus))
1370+ return w.fullscreen != 0 && (w.policy().equalCells || w.fullscreenColumn(w.Focus))
13531371 }
13541372
13551373 func (w *Workspace) fullscreenColumn(i int) bool {
@@ -1373,7 +1391,10 @@ func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {
13731391 }
13741392 g := w.gap()
13751393 // Fixed overflow never scrolls, so presets would push columns off screen.
1376- if w.Columns[i].Width == (Width{}) || w.Overflow == OverflowFixed {
1394+ if w.Overflow == OverflowCascade {
1395+ return max((w.Usable.W-g*(max(w.MaxColumns, 1)+1))/max(w.MaxColumns, 1), 0)
1396+ }
1397+ if w.Columns[i].Width == (Width{}) || w.policy().equalCells {
13771398 if len(w.Columns) == 1 {
13781399 return max(w.Usable.W-2*g, 0)
13791400 }
@@ -1391,27 +1412,31 @@ func (w *Workspace) columnX(i int) int {
13911412 return x
13921413 }
13931414 func (w *Workspace) scroll() {
1394- before := w.ViewX
1415+ before := w.View
13951416 defer func() { w.retarget(before) }()
1417+ if w.Overflow == OverflowCascade {
1418+ w.View = w.band(w.Focus) * w.Usable.H
1419+ return
1420+ }
13961421 if w.Overflow == OverflowFixed {
1397- w.ViewX = 0
1422+ w.View = 0
13981423 return
13991424 }
14001425 if len(w.Columns) == 0 {
14011426 return
14021427 }
1403- left := w.columnX(w.Focus) - w.ViewX
1428+ left := w.columnX(w.Focus) - w.View
14041429 width := w.columnWidth(w.Focus)
14051430 if w.fullscreenColumn(w.Focus) {
1406- w.ViewX += left - w.Output.X
1431+ w.View += left - w.Output.X
14071432 return
14081433 }
14091434 minX := w.Usable.X + w.gap()
14101435 maxX := w.Usable.X + w.Usable.W - w.gap()
14111436 if width > maxX-minX || left < minX {
1412- w.ViewX += left - minX
1437+ w.View += left - minX
14131438 } else if left+width > maxX {
1414- w.ViewX += left + width - maxX
1439+ w.View += left + width - maxX
14151440 }
14161441 }
14171442
@@ -1430,10 +1455,13 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
14301455 // when too small): the result aliases dst, so a caller passing a reused
14311456 // buffer must be done with the previous result.
14321457 func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
1458+ if w.Overflow == OverflowCascade {
1459+ return w.cascadeRectsInto(dst, ignoreFullWidth)
1460+ }
14331461 g := w.gap()
14341462 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)
14351463 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
1436- view := w.ViewX + w.shiftPixels()
1464+ view := w.View + w.shiftPixels()
14371465 x := w.Usable.X + g
14381466 for i := range w.Columns {
14391467 width := w.columnWidthFor(i, ignoreFullWidth)
@@ -1618,12 +1646,12 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
16181646 full := w.fullscreen == id && id != 0
16191647 // Fixed overflow cannot scroll to other columns while one fills
16201648 // 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
1649+ maximized := (w.Overflow == OverflowFixed || w.Overflow == OverflowCascade) && w.Columns[w.Focus].FullWidth && i != w.Focus && (w.Overflow == OverflowFixed || w.band(i) == w.band(w.Focus))
1650+ hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || maximized
16231651 if full {
16241652 // Scroll mode aligns the view on the column; fixed never scrolls.
16251653 r = w.Output
1626- if w.Overflow != OverflowFixed {
1654+ if w.Overflow == OverflowScroll || w.Overflow == "" {
16271655 r.X = col.X
16281656 }
16291657 }
@@ -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,8 +511,8 @@ 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
@@ -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,18 @@ func (w *Workspace) focusWindow(dir int) bool {
774 w.scroll()777 w.scroll()
775 return true778 return true
776 }779 }
780+ if w.Overflow == OverflowCascade {
781+ if i := w.bandNeighbor(dir); i >= 0 {
782+ w.Focus = i
783+ w.Columns[i].Focus = 0
784+ if dir < 0 {
785+ w.Columns[i].Focus = len(w.Columns[i].Windows) - 1
786+ }
787+ w.raiseColumns()
788+ w.scroll()
789+ return true
790+ }
791+ }
777 if w.onScreenFocus() {792 if w.onScreenFocus() {
778 if i := w.screenNeighbor(0, dir); i >= 0 {793 if i := w.screenNeighbor(0, dir); i >= 0 {
779 w.Focus = i794 w.Focus = i
@@ -865,7 +880,7 @@ func (w *Workspace) MoveWindow(dir int) {
865 // usable width, clamped to 10-100%. An auto column becomes explicit. Fixed880 // usable width, clamped to 10-100%. An auto column becomes explicit. Fixed
866 // overflow shares the width equally: nothing changes there.881 // overflow shares the width equally: nothing changes there.
867 func (w *Workspace) ResizeColumn(pct int) {882 func (w *Workspace) ResizeColumn(pct int) {
868- if w.onFloat() || len(w.Columns) == 0 || w.Overflow == OverflowFixed || w.fullscreenHides() {883+ if w.onFloat() || len(w.Columns) == 0 || w.policy().equalCells || w.fullscreenHides() {
869 return884 return
870 }885 }
871 w.unmaximize()886 w.unmaximize()
@@ -973,7 +988,7 @@ func (w *Workspace) takeColumn() (Column, bool) {
973 w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)988 w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)
974 w.Focus = min(w.Focus, max(len(w.Columns)-1, 0))989 w.Focus = min(w.Focus, max(len(w.Columns)-1, 0))
975 if len(w.Columns) == 0 {990 if len(w.Columns) == 0 {
976- w.ViewX = 0991+ w.View = 0
977 } else {992 } else {
978 w.scroll()993 w.scroll()
979 }994 }
@@ -994,6 +1009,9 @@ func (w *Workspace) insertColumn(at int, col Column) {
994 // CycleWidth steps the focused column through the presets. Fixed overflow1009 // CycleWidth steps the focused column through the presets. Fixed overflow
995 // ignores presets: it toggles the expanded column instead.1010 // ignores presets: it toggles the expanded column instead.
996 func (w *Workspace) CycleWidth() {1011 func (w *Workspace) CycleWidth() {
1012+ if w.Overflow == OverflowCascade {
1013+ return
1014+ }
997 if w.Overflow == OverflowFixed {1015 if w.Overflow == OverflowFixed {
998 w.toggleExpanded()1016 w.toggleExpanded()
999 return1017 return
@@ -1047,11 +1065,11 @@ func (w *Workspace) maximize(i int) {
1047 // columns in fixed mode (including after Escape changed focus).1065 // columns in fixed mode (including after Escape changed focus).
1048 func (w *Workspace) unmaximize() {1066 func (w *Workspace) unmaximize() {
1049 for i := range w.Columns {1067 for i := range w.Columns {
1050- if w.Overflow == OverflowFixed || i == w.Focus {1068+ if w.policy().equalCells || i == w.Focus {
1051 w.Columns[i].FullWidth = false1069 w.Columns[i].FullWidth = false
1052 }1070 }
1053 }1071 }
1054- if w.Overflow == OverflowFixed {1072+ if w.policy().equalCells {
1055 w.maximized = nil1073 w.maximized = nil
1056 }1074 }
1057 }1075 }
@@ -1262,7 +1280,7 @@ func (w *Workspace) mayCover(id WindowID) bool {
1262 }1280 }
1263 w.left = 01281 w.left = 0
1264 }1282 }
1265- if w.Overflow != OverflowFixed || w.stashHidden && w.stashIndex(id) >= 0 {1283+ if !w.policy().equalCells || w.stashHidden && w.stashIndex(id) >= 0 {
1266 return true1284 return true
1267 }1285 }
1268 focused, ok := w.Focused()1286 focused, ok := w.Focused()
@@ -1275,7 +1293,7 @@ func (w *Workspace) mayCover(id WindowID) bool {
1275 // tiled neighbor scroll it off and show the target instead.1293 // tiled neighbor scroll it off and show the target instead.
1276 func (w *Workspace) pinned() bool {1294 func (w *Workspace) pinned() bool {
1277 c := w.cover()1295 c := w.cover()
1278- return c != 0 && (w.Overflow == OverflowFixed || w.isFloat(c))1296+ return c != 0 && (w.policy().equalCells || w.isFloat(c))
1279 }1297 }
1280 1298
1281 // leaveCover runs a focus move from the covering fullscreen window as if it1299 // leaveCover runs a focus move from the covering fullscreen window as if it
@@ -1333,12 +1351,12 @@ func (w *Workspace) cover() WindowID {
1333 if w.fullscreen == 0 {1351 if w.fullscreen == 0 {
1334 return 01352 return 0
1335 }1353 }
1336- if w.isFloat(w.fullscreen) || w.Overflow == OverflowFixed {1354+ if w.isFloat(w.fullscreen) || w.policy().equalCells {
1337 return w.fullscreen1355 return w.fullscreen
1338 }1356 }
1339 // Scroll overflow: only while its column is aligned on the output.1357 // Scroll overflow: only while its column is aligned on the output.
1340 for i, c := range w.Columns {1358 for i, c := range w.Columns {
1341- if slices.Contains(c.Windows, w.fullscreen) && w.columnX(i)-w.ViewX == 0 {1359+ if slices.Contains(c.Windows, w.fullscreen) && w.columnX(i)-w.View == 0 {
1342 return w.fullscreen1360 return w.fullscreen
1343 }1361 }
1344 }1362 }
@@ -1349,7 +1367,7 @@ func (w *Workspace) cover() WindowID {
1349 // column's layout: the others (fixed) or its own column (scroll). Column1367 // column's layout: the others (fixed) or its own column (scroll). Column
1350 // edits would change what the user cannot see.1368 // edits would change what the user cannot see.
1351 func (w *Workspace) fullscreenHides() bool {1369 func (w *Workspace) fullscreenHides() bool {
1352- return w.fullscreen != 0 && (w.Overflow == OverflowFixed || w.fullscreenColumn(w.Focus))1370+ return w.fullscreen != 0 && (w.policy().equalCells || w.fullscreenColumn(w.Focus))
1353 }1371 }
1354 1372
1355 func (w *Workspace) fullscreenColumn(i int) bool {1373 func (w *Workspace) fullscreenColumn(i int) bool {
@@ -1373,7 +1391,10 @@ func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {
1373 }1391 }
1374 g := w.gap()1392 g := w.gap()
1375 // Fixed overflow never scrolls, so presets would push columns off screen.1393 // Fixed overflow never scrolls, so presets would push columns off screen.
1376- if w.Columns[i].Width == (Width{}) || w.Overflow == OverflowFixed {1394+ if w.Overflow == OverflowCascade {
1395+ return max((w.Usable.W-g*(max(w.MaxColumns, 1)+1))/max(w.MaxColumns, 1), 0)
1396+ }
1397+ if w.Columns[i].Width == (Width{}) || w.policy().equalCells {
1377 if len(w.Columns) == 1 {1398 if len(w.Columns) == 1 {
1378 return max(w.Usable.W-2*g, 0)1399 return max(w.Usable.W-2*g, 0)
1379 }1400 }
@@ -1391,27 +1412,31 @@ func (w *Workspace) columnX(i int) int {
1391 return x1412 return x
1392 }1413 }
1393 func (w *Workspace) scroll() {1414 func (w *Workspace) scroll() {
1394- before := w.ViewX1415+ before := w.View
1395 defer func() { w.retarget(before) }()1416 defer func() { w.retarget(before) }()
1417+ if w.Overflow == OverflowCascade {
1418+ w.View = w.band(w.Focus) * w.Usable.H
1419+ return
1420+ }
1396 if w.Overflow == OverflowFixed {1421 if w.Overflow == OverflowFixed {
1397- w.ViewX = 01422+ w.View = 0
1398 return1423 return
1399 }1424 }
1400 if len(w.Columns) == 0 {1425 if len(w.Columns) == 0 {
1401 return1426 return
1402 }1427 }
1403- left := w.columnX(w.Focus) - w.ViewX1428+ left := w.columnX(w.Focus) - w.View
1404 width := w.columnWidth(w.Focus)1429 width := w.columnWidth(w.Focus)
1405 if w.fullscreenColumn(w.Focus) {1430 if w.fullscreenColumn(w.Focus) {
1406- w.ViewX += left - w.Output.X1431+ w.View += left - w.Output.X
1407 return1432 return
1408 }1433 }
1409 minX := w.Usable.X + w.gap()1434 minX := w.Usable.X + w.gap()
1410 maxX := w.Usable.X + w.Usable.W - w.gap()1435 maxX := w.Usable.X + w.Usable.W - w.gap()
1411 if width > maxX-minX || left < minX {1436 if width > maxX-minX || left < minX {
1412- w.ViewX += left - minX1437+ w.View += left - minX
1413 } else if left+width > maxX {1438 } else if left+width > maxX {
1414- w.ViewX += left + width - maxX1439+ w.View += left + width - maxX
1415 }1440 }
1416 }1441 }
1417 1442
@@ -1430,10 +1455,13 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
1430 // when too small): the result aliases dst, so a caller passing a reused1455 // when too small): the result aliases dst, so a caller passing a reused
1431 // buffer must be done with the previous result.1456 // buffer must be done with the previous result.
1432 func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {1457 func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
1458+ if w.Overflow == OverflowCascade {
1459+ return w.cascadeRectsInto(dst, ignoreFullWidth)
1460+ }
1433 g := w.gap()1461 g := w.gap()
1434 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)1462 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)]1463 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
1436- view := w.ViewX + w.shiftPixels()1464+ view := w.View + w.shiftPixels()
1437 x := w.Usable.X + g1465 x := w.Usable.X + g
1438 for i := range w.Columns {1466 for i := range w.Columns {
1439 width := w.columnWidthFor(i, ignoreFullWidth)1467 width := w.columnWidthFor(i, ignoreFullWidth)
@@ -1618,12 +1646,12 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
1618 full := w.fullscreen == id && id != 01646 full := w.fullscreen == id && id != 0
1619 // Fixed overflow cannot scroll to other columns while one fills1647 // Fixed overflow cannot scroll to other columns while one fills
1620 // the view. Keep them in place, but out of the scene.1648 // the view. Keep them in place, but out of the scene.
1621- maximized := w.Overflow == OverflowFixed && w.Columns[w.Focus].FullWidth && i != w.Focus1649+ maximized := (w.Overflow == OverflowFixed || w.Overflow == OverflowCascade) && w.Columns[w.Focus].FullWidth && i != w.Focus && (w.Overflow == OverflowFixed || w.band(i) == w.band(w.Focus))
1622- hidden := (cover != 0 && id != cover) || ((fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full) || maximized1650+ hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || maximized
1623 if full {1651 if full {
1624 // Scroll mode aligns the view on the column; fixed never scrolls.1652 // Scroll mode aligns the view on the column; fixed never scrolls.
1625 r = w.Output1653 r = w.Output
1626- if w.Overflow != OverflowFixed {1654+ if w.Overflow == OverflowScroll || w.Overflow == "" {
1627 r.X = col.X1655 r.X = col.X
1628 }1656 }
1629 }1657 }
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, spiral 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}
35+ }
36+ if w.Overflow == OverflowFixed {
37+ return layoutPolicy{content: horizontalAxis, workspace: verticalAxis, equalCells: true, appendLast: true, spiral: 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, spiral 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}
35+ }
36+ if w.Overflow == OverflowFixed {
37+ return layoutPolicy{content: horizontalAxis, workspace: verticalAxis, equalCells: true, appendLast: true, spiral: true}
38+ }
39+ return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}
40+}
M internal/core/layout_test.go +11 -11
@@ -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) {
@@ -357,13 +357,13 @@ func TestUnchangedUsablePreservesViewX(t *testing.T) {
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("ViewX 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) {
@@ -357,13 +357,13 @@ func TestUnchangedUsablePreservesViewX(t *testing.T) {
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("ViewX 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 +2 -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.
@@ -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.
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 -1
@@ -748,7 +748,12 @@ 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.Overflow == OverflowCascade {
752+ k := max(w.MaxColumns, 1)
753+ width := max((w.Usable.W-g*(k+1))/k, 0)
754+ r.X = w.band(i)*w.Usable.W + g + (i%k)*(width+g)
755+ r.W = width
756+ } else if w.Overflow == OverflowFixed {
752757 r = rects[i]
753758 r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y
754759 } else if w.fullscreenColumn(i) {
@@ -748,7 +748,12 @@ 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.Overflow == OverflowCascade {
752+ k := max(w.MaxColumns, 1)
753+ width := max((w.Usable.W-g*(k+1))/k, 0)
754+ r.X = w.band(i)*w.Usable.W + g + (i%k)*(width+g)
755+ r.W = width
756+ } else if w.Overflow == OverflowFixed {
752 r = rects[i]757 r = rects[i]
753 r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y758 r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y
754 } else if w.fullscreenColumn(i) {759 } 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/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 +13 -7
@@ -47,10 +47,10 @@ func (w *Workspace) slidable() bool {
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.Overflow == OverflowCascade {
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.Overflow == OverflowCascade {
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
@@ -47,10 +47,10 @@ func (w *Workspace) slidable() bool {
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.Overflow == OverflowCascade {
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.Overflow == OverflowCascade {
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_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 +9 -3
@@ -37,6 +37,7 @@ type viewShot struct {
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.viewX, 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.viewX || !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
@@ -37,6 +37,7 @@ type viewShot struct {
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.viewX, 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.viewX || !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 25

Tags 10

↑↓ moveenter openesc close