detached 8c17b30

fix(layout): preserve cascade focus and transition invariants

commit
8c17b301a23a063d339628b8eb3d7e2f70e50541
author
brice · · 2026-10-06 08:06 UTC
parent
d0eb8af
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +210 -78
M docs/config.md +5 -4
@@ -139,15 +139,16 @@ bind.Cmd+p = workspace presentation
139139 With `animations = on`, a three-finger swipe follows the fingers and moves one step at most, so a quick swipe never skips a column or a workspace:
140140
141141 - Left or right scrolls the columns (`scroll` overflow) to the next column edge; the focus moves to a column fully on screen.
142-- Up or down slides to the next numbered workspace.
142+- Up or down slides to the next numbered workspace in scroll and fixed layouts.
143+- In [Cascade](#cascade), up/down scrolls one band and left/right switches workspaces.
143144
144145 The view sticks a little near each step and catches up between them. Past the next step it resists; lift and swipe again to go further. When the fingers lift, a quick swipe goes on to the next step and a slow one settles on the closest.
145146
146-With `natural-scroll = off`, a swipe left shows the columns to the left and a swipe up shows the workspace above. `natural-scroll = on` moves the content with the fingers, so both are reversed.
147+In scroll and fixed layouts with `natural-scroll = off`, a swipe left shows the columns to the left and a swipe up shows the workspace above. `natural-scroll = on` moves the content with the fingers, so both are reversed.
147148
148-With `animations = off`, nothing moves during the swipe: when the fingers lift, a quick or long enough swipe runs `focus-column-left/right` or `focus-workspace-up/down`.
149+With `animations = off`, nothing moves during the swipe: when the fingers lift, a quick or long enough swipe navigates content or workspaces along the layout's axes. Cascade band swipes stop at the first and last band.
149150
150-Over the shown stash a sideways swipe slides it with the fingers, with a stop on each window, and moves one window at most. Where the view cannot scroll (`fixed` overflow, a floating or fullscreen window, a named workspace), a quick swipe runs `focus-column-left/right` or `focus-workspace-up/down` when the fingers lift.
151+Over the shown stash a sideways swipe slides it with the fingers, with a stop on each window, and moves one window at most. In scroll and fixed layouts where the view cannot scroll (a floating or fullscreen window, fixed overflow, or a named workspace), a quick swipe runs `focus-column-left/right` or `focus-workspace-up/down` when the fingers lift. Cascade keeps horizontal workspace navigation; vertical band swipes do nothing while a floating or fullscreen window has focus.
151152
152153 Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia.
153154
@@ -139,15 +139,16 @@ bind.Cmd+p = workspace presentation
139 With `animations = on`, a three-finger swipe follows the fingers and moves one step at most, so a quick swipe never skips a column or a workspace:139 With `animations = on`, a three-finger swipe follows the fingers and moves one step at most, so a quick swipe never skips a column or a workspace:
140 140
141 - Left or right scrolls the columns (`scroll` overflow) to the next column edge; the focus moves to a column fully on screen.141 - Left or right scrolls the columns (`scroll` overflow) to the next column edge; the focus moves to a column fully on screen.
142-- Up or down slides to the next numbered workspace.142+- Up or down slides to the next numbered workspace in scroll and fixed layouts.
143+- In [Cascade](#cascade), up/down scrolls one band and left/right switches workspaces.
143 144
144 The view sticks a little near each step and catches up between them. Past the next step it resists; lift and swipe again to go further. When the fingers lift, a quick swipe goes on to the next step and a slow one settles on the closest.145 The view sticks a little near each step and catches up between them. Past the next step it resists; lift and swipe again to go further. When the fingers lift, a quick swipe goes on to the next step and a slow one settles on the closest.
145 146
146-With `natural-scroll = off`, a swipe left shows the columns to the left and a swipe up shows the workspace above. `natural-scroll = on` moves the content with the fingers, so both are reversed.147+In scroll and fixed layouts with `natural-scroll = off`, a swipe left shows the columns to the left and a swipe up shows the workspace above. `natural-scroll = on` moves the content with the fingers, so both are reversed.
147 148
148-With `animations = off`, nothing moves during the swipe: when the fingers lift, a quick or long enough swipe runs `focus-column-left/right` or `focus-workspace-up/down`.149+With `animations = off`, nothing moves during the swipe: when the fingers lift, a quick or long enough swipe navigates content or workspaces along the layout's axes. Cascade band swipes stop at the first and last band.
149 150
150-Over the shown stash a sideways swipe slides it with the fingers, with a stop on each window, and moves one window at most. Where the view cannot scroll (`fixed` overflow, a floating or fullscreen window, a named workspace), a quick swipe runs `focus-column-left/right` or `focus-workspace-up/down` when the fingers lift.151+Over the shown stash a sideways swipe slides it with the fingers, with a stop on each window, and moves one window at most. In scroll and fixed layouts where the view cannot scroll (a floating or fullscreen window, fixed overflow, or a named workspace), a quick swipe runs `focus-column-left/right` or `focus-workspace-up/down` when the fingers lift. Cascade keeps horizontal workspace navigation; vertical band swipes do nothing while a floating or fullscreen window has focus.
151 152
152 Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia.153 Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia.
153 154
A internal/adapters/config/cascade_test.go +20 -0
@@ -0,0 +1,20 @@
1+package config
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestCascadeLayoutInheritance(t *testing.T) {
9+ c, w := parseString(t, "layout.overflow = cascade\nlayout.DP-1.overflow = cascade\nworkspace.work.overflow = cascade\nbind.cmd+w = workspace work\n")
10+ if len(w) != 0 {
11+ t.Fatalf("warnings=%v", w)
12+ }
13+ if c.Layout.Overflow != "cascade" || c.Layout.Outputs[0].Overflow != "cascade" || c.Workspaces[0].Overflow != "cascade" {
14+ t.Fatalf("cascade inheritance=%+v", c.Layout)
15+ }
16+ _, warnings, err := Parse(strings.NewReader("layout.overflow = unknown\n"))
17+ if err != nil || len(warnings) != 1 || !strings.Contains(warnings[0].Msg, "must be scroll, fixed or cascade") {
18+ t.Fatalf("invalid overflow diagnostic: %v, %v", warnings, err)
19+ }
20+}
@@ -0,0 +1,20 @@
1+package config
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestCascadeLayoutInheritance(t *testing.T) {
9+ c, w := parseString(t, "layout.overflow = cascade\nlayout.DP-1.overflow = cascade\nworkspace.work.overflow = cascade\nbind.cmd+w = workspace work\n")
10+ if len(w) != 0 {
11+ t.Fatalf("warnings=%v", w)
12+ }
13+ if c.Layout.Overflow != "cascade" || c.Layout.Outputs[0].Overflow != "cascade" || c.Workspaces[0].Overflow != "cascade" {
14+ t.Fatalf("cascade inheritance=%+v", c.Layout)
15+ }
16+ _, warnings, err := Parse(strings.NewReader("layout.overflow = unknown\n"))
17+ if err != nil || len(warnings) != 1 || !strings.Contains(warnings[0].Msg, "must be scroll, fixed or cascade") {
18+ t.Fatalf("invalid overflow diagnostic: %v, %v", warnings, err)
19+ }
20+}
M internal/core/cascade.go +28 -5
@@ -2,19 +2,43 @@ package core
22
33 import "slices"
44
5-func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) }
5+func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) }
6+func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) }
7+func (w *Workspace) cellWidth() int {
8+ k := max(w.MaxColumns, 1)
9+ return max((w.Usable.W-w.gap()*(k+1))/k, 0)
10+}
11+func (w *Workspace) cellInBand(b int) int {
12+ k := max(w.MaxColumns, 1)
13+ return min(b*k+w.Focus%k, len(w.Columns)-1)
14+}
15+func (w *Workspace) focusBand(dir int) bool {
16+ i := w.bandNeighbor(dir)
17+ if i < 0 {
18+ return false
19+ }
20+ w.unmaximize()
21+ w.Focus = i
22+ w.Columns[i].Focus = 0
23+ if dir < 0 {
24+ w.Columns[i].Focus = len(w.Columns[i].Windows) - 1
25+ }
26+ w.raiseColumns()
27+ w.scroll()
28+ return true
29+}
630 func (w *Workspace) bandNeighbor(dir int) int {
731 k := max(w.MaxColumns, 1)
832 b := w.band(w.Focus) + dir
933 if b < 0 || b*k >= len(w.Columns) {
1034 return -1
1135 }
12- return min(b*k+w.Focus%k, len(w.Columns)-1)
36+ return w.cellInBand(b)
1337 }
1438
1539 func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
1640 k, g := max(w.MaxColumns, 1), w.gap()
17- width := max((w.Usable.W-g*(k+1))/k, 0)
41+ width := w.cellWidth()
1842 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
1943 view := w.View + w.shiftPixels()
2044 for i := range w.Columns {
@@ -43,7 +67,6 @@ func (w *Workspace) cascadeFocus(view int) int {
4367 if len(w.Columns) == 0 {
4468 return 0
4569 }
46- k := max(w.MaxColumns, 1)
4770 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)
71+ return w.cellInBand(b)
4972 }
@@ -2,19 +2,43 @@ package core
2 2
3 import "slices"3 import "slices"
4 4
5-func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) }5+func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) }
6+func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) }
7+func (w *Workspace) cellWidth() int {
8+ k := max(w.MaxColumns, 1)
9+ return max((w.Usable.W-w.gap()*(k+1))/k, 0)
10+}
11+func (w *Workspace) cellInBand(b int) int {
12+ k := max(w.MaxColumns, 1)
13+ return min(b*k+w.Focus%k, len(w.Columns)-1)
14+}
15+func (w *Workspace) focusBand(dir int) bool {
16+ i := w.bandNeighbor(dir)
17+ if i < 0 {
18+ return false
19+ }
20+ w.unmaximize()
21+ w.Focus = i
22+ w.Columns[i].Focus = 0
23+ if dir < 0 {
24+ w.Columns[i].Focus = len(w.Columns[i].Windows) - 1
25+ }
26+ w.raiseColumns()
27+ w.scroll()
28+ return true
29+}
6 func (w *Workspace) bandNeighbor(dir int) int {30 func (w *Workspace) bandNeighbor(dir int) int {
7 k := max(w.MaxColumns, 1)31 k := max(w.MaxColumns, 1)
8 b := w.band(w.Focus) + dir32 b := w.band(w.Focus) + dir
9 if b < 0 || b*k >= len(w.Columns) {33 if b < 0 || b*k >= len(w.Columns) {
10 return -134 return -1
11 }35 }
12- return min(b*k+w.Focus%k, len(w.Columns)-1)36+ return w.cellInBand(b)
13 }37 }
14 38
15 func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {39 func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
16 k, g := max(w.MaxColumns, 1), w.gap()40 k, g := max(w.MaxColumns, 1), w.gap()
17- width := max((w.Usable.W-g*(k+1))/k, 0)41+ width := w.cellWidth()
18 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]42 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
19 view := w.View + w.shiftPixels()43 view := w.View + w.shiftPixels()
20 for i := range w.Columns {44 for i := range w.Columns {
@@ -43,7 +67,6 @@ func (w *Workspace) cascadeFocus(view int) int {
43 if len(w.Columns) == 0 {67 if len(w.Columns) == 0 {
44 return 068 return 0
45 }69 }
46- k := max(w.MaxColumns, 1)
47 b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1))70 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)71+ return w.cellInBand(b)
49 }72 }
M internal/core/cascade_swipe_test.go +21 -2
@@ -1,8 +1,10 @@
11 package core_test
22
33 import (
4- "github.com/bnema/neferwl/internal/ports"
54 "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
68 )
79
810 func TestCascadeSwipeAxes(t *testing.T) {
@@ -21,9 +23,26 @@ func TestCascadeSwipeAxes(t *testing.T) {
2123 r.begin()
2224 s = r.move(t, 400, 0)
2325 workspace, ok := rectOf(s, 7)
24- if !ok || workspace.X == 0 || workspace.Y != 0 {
26+ if !ok || workspace.X >= 0 || workspace.Y != 0 {
2527 t.Fatalf("workspace swipe must move horizontally: %v, shown=%v", workspace, ok)
2628 }
2729 r.end(t, true)
2830 r.settleAll(t, r.outs...)
2931 }
32+
33+func TestCascadeDiscreteSwipeRevealsBand(t *testing.T) {
34+ r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "cascade"; c.Layout.MaxColumns = 3; c.Animations.On = false })
35+ for i := 1; i <= 4; i++ {
36+ r.mapWindow(t, ports.WindowID(i))
37+ }
38+ r.begin()
39+ for range 10 {
40+ r.at += 8 * time.Millisecond
41+ r.input <- ports.SwipeUpdate{DY: -40, Time: r.at}
42+ }
43+ s := r.end(t, false)
44+ p, ok := rectOf(s, 1)
45+ if !ok || p.Y != 0 {
46+ t.Fatalf("band not revealed: %v shown=%v", p, ok)
47+ }
48+}
@@ -1,8 +1,10 @@
1 package core_test1 package core_test
2 2
3 import (3 import (
4- "github.com/bnema/neferwl/internal/ports"
5 "testing"4 "testing"
5+ "time"
6+
7+ "github.com/bnema/neferwl/internal/ports"
6 )8 )
7 9
8 func TestCascadeSwipeAxes(t *testing.T) {10 func TestCascadeSwipeAxes(t *testing.T) {
@@ -21,9 +23,26 @@ func TestCascadeSwipeAxes(t *testing.T) {
21 r.begin()23 r.begin()
22 s = r.move(t, 400, 0)24 s = r.move(t, 400, 0)
23 workspace, ok := rectOf(s, 7)25 workspace, ok := rectOf(s, 7)
24- if !ok || workspace.X == 0 || workspace.Y != 0 {26+ if !ok || workspace.X >= 0 || workspace.Y != 0 {
25 t.Fatalf("workspace swipe must move horizontally: %v, shown=%v", workspace, ok)27 t.Fatalf("workspace swipe must move horizontally: %v, shown=%v", workspace, ok)
26 }28 }
27 r.end(t, true)29 r.end(t, true)
28 r.settleAll(t, r.outs...)30 r.settleAll(t, r.outs...)
29 }31 }
32+
33+func TestCascadeDiscreteSwipeRevealsBand(t *testing.T) {
34+ r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "cascade"; c.Layout.MaxColumns = 3; c.Animations.On = false })
35+ for i := 1; i <= 4; i++ {
36+ r.mapWindow(t, ports.WindowID(i))
37+ }
38+ r.begin()
39+ for range 10 {
40+ r.at += 8 * time.Millisecond
41+ r.input <- ports.SwipeUpdate{DY: -40, Time: r.at}
42+ }
43+ s := r.end(t, false)
44+ p, ok := rectOf(s, 1)
45+ if !ok || p.Y != 0 {
46+ t.Fatalf("band not revealed: %v shown=%v", p, ok)
47+ }
48+}
M internal/core/cascade_test.go +61 -4
@@ -94,7 +94,67 @@ func TestCascadeCovers(t *testing.T) {
9494 }
9595 }
9696
97-func TestCascadeViewportSnapAndPolicy(t *testing.T) {
97+func TestCascadeClearsMaximizationOnBandChanges(t *testing.T) {
98+ for _, move := range []struct {
99+ name string
100+ run func(*Workspace)
101+ }{
102+ {"focus", func(w *Workspace) { w.FocusWindow(1) }},
103+ {"append", func(w *Workspace) { w.AddWindow(5) }},
104+ {"insert", func(w *Workspace) { w.insertColumn(4, Column{Windows: []WindowID{5}}) }},
105+ } {
106+ t.Run(move.name, func(t *testing.T) {
107+ w := cascadeWorkspace(4)
108+ w.FocusID(1)
109+ w.ToggleFullWidth()
110+ move.run(w)
111+ for _, col := range w.Columns {
112+ if col.FullWidth {
113+ t.Fatal("stale maximization")
114+ }
115+ }
116+ })
117+ }
118+}
119+
120+func TestCascadeGeometryReusesBuffer(t *testing.T) {
121+ w := cascadeWorkspace(7)
122+ buf := w.columnRectsInto(nil, false)
123+ if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 {
124+ t.Fatalf("allocations=%v", n)
125+ }
126+}
127+
128+func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) {
129+ m := NewMonitor()
130+ m.SetOutput(900, 600)
131+ m.Current().Overflow = OverflowCascade
132+ m.AddWindow(1)
133+ m.Focus(1)
134+ m.AddWindow(2)
135+ m.Current().Overflow = OverflowScroll
136+ m.Focus(0)
137+ m.switchAxis = m.Current().policy().workspace
138+ m.switchView.off = .5
139+ ps := m.layoutInto(nil)
140+ for _, p := range ps {
141+ if p.ID == 1 && (p.Rect.X != -450 || p.Rect.Y != 0) {
142+ t.Fatalf("cascade source offset=%v", p.Rect)
143+ }
144+ }
145+ m.stopSwitch()
146+ m.Focus(1)
147+ m.switchAxis = m.Current().policy().workspace
148+ m.switchView.off = -.5
149+ ps = m.layoutInto(nil)
150+ for _, p := range ps {
151+ if p.ID == 2 && (p.Rect.X != 0 || p.Rect.Y != 300) {
152+ t.Fatalf("scroll source offset=%v", p.Rect)
153+ }
154+ }
155+}
156+
157+func TestCascadeViewportSnap(t *testing.T) {
98158 w := cascadeWorkspace(7)
99159 w.FocusID(3)
100160 if w.snapFocus(600, true) != 5 {
@@ -103,9 +163,6 @@ func TestCascadeViewportSnapAndPolicy(t *testing.T) {
103163 if w.snapFocus(1200, true) != 6 {
104164 t.Fatal("short band neighbour")
105165 }
106- if w.policy().content != verticalAxis || w.policy().workspace != horizontalAxis {
107- t.Fatal("axes")
108- }
109166 w.SetUsable(Rect{})
110167 if got := w.snapPoints(); len(got) != 1 || got[0] != 0 {
111168 t.Fatalf("zero area=%v", got)
@@ -94,7 +94,67 @@ func TestCascadeCovers(t *testing.T) {
94 }94 }
95 }95 }
96 96
97-func TestCascadeViewportSnapAndPolicy(t *testing.T) {97+func TestCascadeClearsMaximizationOnBandChanges(t *testing.T) {
98+ for _, move := range []struct {
99+ name string
100+ run func(*Workspace)
101+ }{
102+ {"focus", func(w *Workspace) { w.FocusWindow(1) }},
103+ {"append", func(w *Workspace) { w.AddWindow(5) }},
104+ {"insert", func(w *Workspace) { w.insertColumn(4, Column{Windows: []WindowID{5}}) }},
105+ } {
106+ t.Run(move.name, func(t *testing.T) {
107+ w := cascadeWorkspace(4)
108+ w.FocusID(1)
109+ w.ToggleFullWidth()
110+ move.run(w)
111+ for _, col := range w.Columns {
112+ if col.FullWidth {
113+ t.Fatal("stale maximization")
114+ }
115+ }
116+ })
117+ }
118+}
119+
120+func TestCascadeGeometryReusesBuffer(t *testing.T) {
121+ w := cascadeWorkspace(7)
122+ buf := w.columnRectsInto(nil, false)
123+ if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 {
124+ t.Fatalf("allocations=%v", n)
125+ }
126+}
127+
128+func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) {
129+ m := NewMonitor()
130+ m.SetOutput(900, 600)
131+ m.Current().Overflow = OverflowCascade
132+ m.AddWindow(1)
133+ m.Focus(1)
134+ m.AddWindow(2)
135+ m.Current().Overflow = OverflowScroll
136+ m.Focus(0)
137+ m.switchAxis = m.Current().policy().workspace
138+ m.switchView.off = .5
139+ ps := m.layoutInto(nil)
140+ for _, p := range ps {
141+ if p.ID == 1 && (p.Rect.X != -450 || p.Rect.Y != 0) {
142+ t.Fatalf("cascade source offset=%v", p.Rect)
143+ }
144+ }
145+ m.stopSwitch()
146+ m.Focus(1)
147+ m.switchAxis = m.Current().policy().workspace
148+ m.switchView.off = -.5
149+ ps = m.layoutInto(nil)
150+ for _, p := range ps {
151+ if p.ID == 2 && (p.Rect.X != 0 || p.Rect.Y != 300) {
152+ t.Fatalf("scroll source offset=%v", p.Rect)
153+ }
154+ }
155+}
156+
157+func TestCascadeViewportSnap(t *testing.T) {
98 w := cascadeWorkspace(7)158 w := cascadeWorkspace(7)
99 w.FocusID(3)159 w.FocusID(3)
100 if w.snapFocus(600, true) != 5 {160 if w.snapFocus(600, true) != 5 {
@@ -103,9 +163,6 @@ func TestCascadeViewportSnapAndPolicy(t *testing.T) {
103 if w.snapFocus(1200, true) != 6 {163 if w.snapFocus(1200, true) != 6 {
104 t.Fatal("short band neighbour")164 t.Fatal("short band neighbour")
105 }165 }
106- if w.policy().content != verticalAxis || w.policy().workspace != horizontalAxis {
107- t.Fatal("axes")
108- }
109 w.SetUsable(Rect{})166 w.SetUsable(Rect{})
110 if got := w.snapPoints(); len(got) != 1 || got[0] != 0 {167 if got := w.snapPoints(); len(got) != 1 || got[0] != 0 {
111 t.Fatalf("zero area=%v", got)168 t.Fatalf("zero area=%v", got)
M internal/core/gesture.go +45 -24
@@ -90,7 +90,7 @@ type swipeGesture struct {
9090 // listChanged reports whether the numbered workspaces or the active one
9191 // changed since a workspace slide began, or the overview opened over it.
9292 func (g *swipeGesture) listChanged(m *Monitor) bool {
93- return m.overviewOpens != g.opens || m.shown != nil || m.Workspaces[m.Active] != g.ws || !slices.Equal(m.Workspaces, g.list)
93+ return m.overviewOpens != g.opens || m.shown != nil || m.Workspaces[m.Active] != g.ws || g.horizontal != (g.ws.policy().workspace == horizontalAxis) || !slices.Equal(m.Workspaces, g.list)
9494 }
9595
9696 // columnsChanged reports whether a column swipe lost its workspace: it is
@@ -98,7 +98,25 @@ func (g *swipeGesture) listChanged(m *Monitor) bool {
9898 // panel, a window mapped or resized, other gaps), or the overview opened
9999 // over it.
100100 func (g *swipeGesture) columnsChanged(m *Monitor) bool {
101- return m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || !slices.Equal(g.ws.snapPoints(), g.points)
101+ if m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || g.horizontal != (g.ws.policy().content == horizontalAxis) {
102+ return true
103+ }
104+ if g.ws.policy().wraps {
105+ n := 1
106+ if len(g.ws.Columns) > 0 && g.ws.Usable.H > 0 {
107+ n = g.ws.band(len(g.ws.Columns)-1) + 1
108+ }
109+ if len(g.points) != n {
110+ return true
111+ }
112+ for i, p := range g.points {
113+ if p != float64(i*g.ws.Usable.H) {
114+ return true
115+ }
116+ }
117+ return false
118+ }
119+ return !slices.Equal(g.ws.snapPoints(), g.points)
102120 }
103121
104122 // stashChanged reports whether a stash swipe lost its stash: it is no
@@ -251,7 +269,11 @@ func (c *Core) decide(g *swipeGesture) {
251269 m.stopSwitch()
252270 }
253271 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]
254- m.switchAxis = w.policy().workspace
272+ axis := w.policy().workspace
273+ if m.switchView.busy() && m.switchAxis != axis {
274+ m.stopSwitch()
275+ }
276+ m.switchAxis = axis
255277 g.list = slices.Clone(m.Workspaces)
256278 m.switchView.motion, m.switchList = motion{}, nil
257279 g.snap = newSnapSwipe(float64(m.Active)+m.switchView.off, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand)
@@ -287,7 +309,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
287309 target, velocity := g.snap.end(e.Cancelled, e.Time)
288310 if !e.Cancelled {
289311 view := int(math.Round(target))
290- w.Focus = w.snapFocus(view, target >= shown)
312+ focus := w.snapFocus(view, target >= shown)
313+ if focus != w.Focus && w.policy().equalCells {
314+ w.unmaximize()
315+ }
316+ w.Focus = focus
291317 w.View = view
292318 // A column wider than the view aligns as focus moves align it.
293319 w.scroll()
@@ -381,32 +407,27 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
381407 if step == 0 {
382408 return false
383409 }
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- }
396- a := ActionFocusWorkspaceDown
397- switch {
398- case g.horizontal && step < 0:
410+ w := g.screen.mon.Current()
411+ p := w.policy()
412+ workspace := g.horizontal == (p.workspace == horizontalAxis)
413+ a := ActionFocusColumnRight
414+ if step < 0 {
399415 a = ActionFocusColumnLeft
400- case g.horizontal:
401- a = ActionFocusColumnRight
402- case step < 0:
403- a = ActionFocusWorkspaceUp
404416 }
405- if g.screen.mon.Current().Overflow == OverflowCascade && g.horizontal {
417+ if workspace {
406418 a = ActionFocusWorkspaceDown
407419 if step < 0 {
408420 a = ActionFocusWorkspaceUp
409421 }
422+ } else if p.wraps {
423+ // A content swipe cannot escape the last band to another workspace.
424+ if w.onFloat() || w.fullscreen != 0 || w.bandNeighbor(step) < 0 {
425+ return false
426+ }
427+ a = ActionFocusWindowDown
428+ if step < 0 {
429+ a = ActionFocusWindowUp
430+ }
410431 }
411432 // Focus actions act on the focused screen: the swipe's, unless the
412433 // pointer took the focus to another output meanwhile.
@@ -90,7 +90,7 @@ type swipeGesture struct {
90 // listChanged reports whether the numbered workspaces or the active one90 // listChanged reports whether the numbered workspaces or the active one
91 // changed since a workspace slide began, or the overview opened over it.91 // changed since a workspace slide began, or the overview opened over it.
92 func (g *swipeGesture) listChanged(m *Monitor) bool {92 func (g *swipeGesture) listChanged(m *Monitor) bool {
93- return m.overviewOpens != g.opens || m.shown != nil || m.Workspaces[m.Active] != g.ws || !slices.Equal(m.Workspaces, g.list)93+ return m.overviewOpens != g.opens || m.shown != nil || m.Workspaces[m.Active] != g.ws || g.horizontal != (g.ws.policy().workspace == horizontalAxis) || !slices.Equal(m.Workspaces, g.list)
94 }94 }
95 95
96 // columnsChanged reports whether a column swipe lost its workspace: it is96 // columnsChanged reports whether a column swipe lost its workspace: it is
@@ -98,7 +98,25 @@ func (g *swipeGesture) listChanged(m *Monitor) bool {
98 // panel, a window mapped or resized, other gaps), or the overview opened98 // panel, a window mapped or resized, other gaps), or the overview opened
99 // over it.99 // over it.
100 func (g *swipeGesture) columnsChanged(m *Monitor) bool {100 func (g *swipeGesture) columnsChanged(m *Monitor) bool {
101- return m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || !slices.Equal(g.ws.snapPoints(), g.points)101+ if m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || g.horizontal != (g.ws.policy().content == horizontalAxis) {
102+ return true
103+ }
104+ if g.ws.policy().wraps {
105+ n := 1
106+ if len(g.ws.Columns) > 0 && g.ws.Usable.H > 0 {
107+ n = g.ws.band(len(g.ws.Columns)-1) + 1
108+ }
109+ if len(g.points) != n {
110+ return true
111+ }
112+ for i, p := range g.points {
113+ if p != float64(i*g.ws.Usable.H) {
114+ return true
115+ }
116+ }
117+ return false
118+ }
119+ return !slices.Equal(g.ws.snapPoints(), g.points)
102 }120 }
103 121
104 // stashChanged reports whether a stash swipe lost its stash: it is no122 // stashChanged reports whether a stash swipe lost its stash: it is no
@@ -251,7 +269,11 @@ func (c *Core) decide(g *swipeGesture) {
251 m.stopSwitch()269 m.stopSwitch()
252 }270 }
253 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]271 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]
254- m.switchAxis = w.policy().workspace272+ axis := w.policy().workspace
273+ if m.switchView.busy() && m.switchAxis != axis {
274+ m.stopSwitch()
275+ }
276+ m.switchAxis = axis
255 g.list = slices.Clone(m.Workspaces)277 g.list = slices.Clone(m.Workspaces)
256 m.switchView.motion, m.switchList = motion{}, nil278 m.switchView.motion, m.switchList = motion{}, nil
257 g.snap = newSnapSwipe(float64(m.Active)+m.switchView.off, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand)279 g.snap = newSnapSwipe(float64(m.Active)+m.switchView.off, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand)
@@ -287,7 +309,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
287 target, velocity := g.snap.end(e.Cancelled, e.Time)309 target, velocity := g.snap.end(e.Cancelled, e.Time)
288 if !e.Cancelled {310 if !e.Cancelled {
289 view := int(math.Round(target))311 view := int(math.Round(target))
290- w.Focus = w.snapFocus(view, target >= shown)312+ focus := w.snapFocus(view, target >= shown)
313+ if focus != w.Focus && w.policy().equalCells {
314+ w.unmaximize()
315+ }
316+ w.Focus = focus
291 w.View = view317 w.View = view
292 // A column wider than the view aligns as focus moves align it.318 // A column wider than the view aligns as focus moves align it.
293 w.scroll()319 w.scroll()
@@ -381,32 +407,27 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
381 if step == 0 {407 if step == 0 {
382 return false408 return false
383 }409 }
384- if g.screen.mon.Current().Overflow == OverflowCascade && !g.horizontal {410+ w := g.screen.mon.Current()
385- w := g.screen.mon.Current()411+ p := w.policy()
386- if c.cur() != g.screen || w.onFloat() || w.fullscreen != 0 {412+ workspace := g.horizontal == (p.workspace == horizontalAxis)
387- return false413+ a := ActionFocusColumnRight
388- }414+ if step < 0 {
389- if next := w.bandNeighbor(step); next >= 0 {
390- w.Focus = next
391- w.scroll()
392- return true
393- }
394- return false
395- }
396- a := ActionFocusWorkspaceDown
397- switch {
398- case g.horizontal && step < 0:
399 a = ActionFocusColumnLeft415 a = ActionFocusColumnLeft
400- case g.horizontal:
401- a = ActionFocusColumnRight
402- case step < 0:
403- a = ActionFocusWorkspaceUp
404 }416 }
405- if g.screen.mon.Current().Overflow == OverflowCascade && g.horizontal {417+ if workspace {
406 a = ActionFocusWorkspaceDown418 a = ActionFocusWorkspaceDown
407 if step < 0 {419 if step < 0 {
408 a = ActionFocusWorkspaceUp420 a = ActionFocusWorkspaceUp
409 }421 }
422+ } else if p.wraps {
423+ // A content swipe cannot escape the last band to another workspace.
424+ if w.onFloat() || w.fullscreen != 0 || w.bandNeighbor(step) < 0 {
425+ return false
426+ }
427+ a = ActionFocusWindowDown
428+ if step < 0 {
429+ a = ActionFocusWindowUp
430+ }
410 }431 }
411 // Focus actions act on the focused screen: the swipe's, unless the432 // Focus actions act on the focused screen: the swipe's, unless the
412 // pointer took the focus to another output meanwhile.433 // pointer took the focus to another output meanwhile.
M internal/core/layout.go +10 -19
@@ -517,7 +517,7 @@ func (w *Workspace) addColumn(col Column) {
517517 } else if len(w.Columns) > 0 {
518518 at = w.Focus + 1
519519 }
520- if w.Overflow == OverflowFixed && len(w.Columns) > 0 {
520+ if w.policy().equalCells && len(w.Columns) > 0 {
521521 w.unmaximize()
522522 }
523523 w.Columns = append(w.Columns, Column{})
@@ -777,17 +777,8 @@ func (w *Workspace) focusWindow(dir int) bool {
777777 w.scroll()
778778 return true
779779 }
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- }
780+ if w.policy().wraps && w.focusBand(dir) {
781+ return true
791782 }
792783 if w.onScreenFocus() {
793784 if i := w.screenNeighbor(0, dir); i >= 0 {
@@ -998,7 +989,7 @@ func (w *Workspace) takeColumn() (Column, bool) {
998989 // insertColumn adds a column at index at (clamped) and focuses it.
999990 func (w *Workspace) insertColumn(at int, col Column) {
1000991 at = min(max(at, 0), len(w.Columns))
1001- if w.Overflow == OverflowFixed && len(w.Columns) > 0 {
992+ if w.policy().equalCells && len(w.Columns) > 0 {
1002993 w.unmaximize()
1003994 }
1004995 w.Columns = slices.Insert(w.Columns, at, col)
@@ -1009,7 +1000,7 @@ func (w *Workspace) insertColumn(at int, col Column) {
10091000 // CycleWidth steps the focused column through the presets. Fixed overflow
10101001 // ignores presets: it toggles the expanded column instead.
10111002 func (w *Workspace) CycleWidth() {
1012- if w.Overflow == OverflowCascade {
1003+ if w.policy().wraps {
10131004 return
10141005 }
10151006 if w.Overflow == OverflowFixed {
@@ -1391,8 +1382,8 @@ func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {
13911382 }
13921383 g := w.gap()
13931384 // Fixed overflow never scrolls, so presets would push columns off screen.
1394- if w.Overflow == OverflowCascade {
1395- return max((w.Usable.W-g*(max(w.MaxColumns, 1)+1))/max(w.MaxColumns, 1), 0)
1385+ if w.policy().wraps {
1386+ return w.cellWidth()
13961387 }
13971388 if w.Columns[i].Width == (Width{}) || w.policy().equalCells {
13981389 if len(w.Columns) == 1 {
@@ -1414,7 +1405,7 @@ func (w *Workspace) columnX(i int) int {
14141405 func (w *Workspace) scroll() {
14151406 before := w.View
14161407 defer func() { w.retarget(before) }()
1417- if w.Overflow == OverflowCascade {
1408+ if w.policy().wraps {
14181409 w.View = w.band(w.Focus) * w.Usable.H
14191410 return
14201411 }
@@ -1455,7 +1446,7 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
14551446 // when too small): the result aliases dst, so a caller passing a reused
14561447 // buffer must be done with the previous result.
14571448 func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
1458- if w.Overflow == OverflowCascade {
1449+ if w.policy().wraps {
14591450 return w.cascadeRectsInto(dst, ignoreFullWidth)
14601451 }
14611452 g := w.gap()
@@ -1646,7 +1637,7 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
16461637 full := w.fullscreen == id && id != 0
16471638 // Fixed overflow cannot scroll to other columns while one fills
16481639 // the view. Keep them in place, but out of the scene.
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))
1640+ maximized := w.policy().equalCells && w.Columns[w.Focus].FullWidth && i != w.Focus && w.sameBand(i, w.Focus)
16501641 hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || maximized
16511642 if full {
16521643 // Scroll mode aligns the view on the column; fixed never scrolls.
@@ -517,7 +517,7 @@ func (w *Workspace) addColumn(col Column) {
517 } else if len(w.Columns) > 0 {517 } else if len(w.Columns) > 0 {
518 at = w.Focus + 1518 at = w.Focus + 1
519 }519 }
520- if w.Overflow == OverflowFixed && len(w.Columns) > 0 {520+ if w.policy().equalCells && len(w.Columns) > 0 {
521 w.unmaximize()521 w.unmaximize()
522 }522 }
523 w.Columns = append(w.Columns, Column{})523 w.Columns = append(w.Columns, Column{})
@@ -777,17 +777,8 @@ func (w *Workspace) focusWindow(dir int) bool {
777 w.scroll()777 w.scroll()
778 return true778 return true
779 }779 }
780- if w.Overflow == OverflowCascade {780+ if w.policy().wraps && w.focusBand(dir) {
781- if i := w.bandNeighbor(dir); i >= 0 {781+ return true
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 }782 }
792 if w.onScreenFocus() {783 if w.onScreenFocus() {
793 if i := w.screenNeighbor(0, dir); i >= 0 {784 if i := w.screenNeighbor(0, dir); i >= 0 {
@@ -998,7 +989,7 @@ func (w *Workspace) takeColumn() (Column, bool) {
998 // insertColumn adds a column at index at (clamped) and focuses it.989 // insertColumn adds a column at index at (clamped) and focuses it.
999 func (w *Workspace) insertColumn(at int, col Column) {990 func (w *Workspace) insertColumn(at int, col Column) {
1000 at = min(max(at, 0), len(w.Columns))991 at = min(max(at, 0), len(w.Columns))
1001- if w.Overflow == OverflowFixed && len(w.Columns) > 0 {992+ if w.policy().equalCells && len(w.Columns) > 0 {
1002 w.unmaximize()993 w.unmaximize()
1003 }994 }
1004 w.Columns = slices.Insert(w.Columns, at, col)995 w.Columns = slices.Insert(w.Columns, at, col)
@@ -1009,7 +1000,7 @@ func (w *Workspace) insertColumn(at int, col Column) {
1009 // CycleWidth steps the focused column through the presets. Fixed overflow1000 // CycleWidth steps the focused column through the presets. Fixed overflow
1010 // ignores presets: it toggles the expanded column instead.1001 // ignores presets: it toggles the expanded column instead.
1011 func (w *Workspace) CycleWidth() {1002 func (w *Workspace) CycleWidth() {
1012- if w.Overflow == OverflowCascade {1003+ if w.policy().wraps {
1013 return1004 return
1014 }1005 }
1015 if w.Overflow == OverflowFixed {1006 if w.Overflow == OverflowFixed {
@@ -1391,8 +1382,8 @@ func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {
1391 }1382 }
1392 g := w.gap()1383 g := w.gap()
1393 // Fixed overflow never scrolls, so presets would push columns off screen.1384 // Fixed overflow never scrolls, so presets would push columns off screen.
1394- if w.Overflow == OverflowCascade {1385+ if w.policy().wraps {
1395- return max((w.Usable.W-g*(max(w.MaxColumns, 1)+1))/max(w.MaxColumns, 1), 0)1386+ return w.cellWidth()
1396 }1387 }
1397 if w.Columns[i].Width == (Width{}) || w.policy().equalCells {1388 if w.Columns[i].Width == (Width{}) || w.policy().equalCells {
1398 if len(w.Columns) == 1 {1389 if len(w.Columns) == 1 {
@@ -1414,7 +1405,7 @@ func (w *Workspace) columnX(i int) int {
1414 func (w *Workspace) scroll() {1405 func (w *Workspace) scroll() {
1415 before := w.View1406 before := w.View
1416 defer func() { w.retarget(before) }()1407 defer func() { w.retarget(before) }()
1417- if w.Overflow == OverflowCascade {1408+ if w.policy().wraps {
1418 w.View = w.band(w.Focus) * w.Usable.H1409 w.View = w.band(w.Focus) * w.Usable.H
1419 return1410 return
1420 }1411 }
@@ -1455,7 +1446,7 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
1455 // when too small): the result aliases dst, so a caller passing a reused1446 // when too small): the result aliases dst, so a caller passing a reused
1456 // buffer must be done with the previous result.1447 // buffer must be done with the previous result.
1457 func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {1448 func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
1458- if w.Overflow == OverflowCascade {1449+ if w.policy().wraps {
1459 return w.cascadeRectsInto(dst, ignoreFullWidth)1450 return w.cascadeRectsInto(dst, ignoreFullWidth)
1460 }1451 }
1461 g := w.gap()1452 g := w.gap()
@@ -1646,7 +1637,7 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
1646 full := w.fullscreen == id && id != 01637 full := w.fullscreen == id && id != 0
1647 // Fixed overflow cannot scroll to other columns while one fills1638 // Fixed overflow cannot scroll to other columns while one fills
1648 // the view. Keep them in place, but out of the scene.1639 // the view. Keep them in place, but out of the scene.
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))1640+ maximized := w.policy().equalCells && w.Columns[w.Focus].FullWidth && i != w.Focus && w.sameBand(i, w.Focus)
1650 hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || maximized1641 hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || maximized
1651 if full {1642 if full {
1652 // Scroll mode aligns the view on the column; fixed never scrolls.1643 // Scroll mode aligns the view on the column; fixed never scrolls.
M internal/core/layout_policy.go +4 -4
@@ -25,16 +25,16 @@ func (a layoutAxis) offset(r *Rect, pixels int) {
2525 // layoutPolicy keeps geometry capabilities distinct from workspace navigation.
2626 // Window state remains owned by Workspace; policies contain no mutable state.
2727 type layoutPolicy struct {
28- content, workspace layoutAxis
29- equalCells, appendLast, spiral bool
28+ content, workspace layoutAxis
29+ equalCells, appendLast, wraps bool
3030 }
3131
3232 func (w *Workspace) policy() layoutPolicy {
3333 if w.Overflow == OverflowCascade {
34- return layoutPolicy{content: verticalAxis, workspace: horizontalAxis, equalCells: true, appendLast: true}
34+ return layoutPolicy{content: verticalAxis, workspace: horizontalAxis, equalCells: true, appendLast: true, wraps: true}
3535 }
3636 if w.Overflow == OverflowFixed {
37- return layoutPolicy{content: horizontalAxis, workspace: verticalAxis, equalCells: true, appendLast: true, spiral: true}
37+ return layoutPolicy{content: horizontalAxis, workspace: verticalAxis, equalCells: true, appendLast: true}
3838 }
3939 return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}
4040 }
@@ -25,16 +25,16 @@ func (a layoutAxis) offset(r *Rect, pixels int) {
25 // layoutPolicy keeps geometry capabilities distinct from workspace navigation.25 // layoutPolicy keeps geometry capabilities distinct from workspace navigation.
26 // Window state remains owned by Workspace; policies contain no mutable state.26 // Window state remains owned by Workspace; policies contain no mutable state.
27 type layoutPolicy struct {27 type layoutPolicy struct {
28- content, workspace layoutAxis28+ content, workspace layoutAxis
29- equalCells, appendLast, spiral bool29+ equalCells, appendLast, wraps bool
30 }30 }
31 31
32 func (w *Workspace) policy() layoutPolicy {32 func (w *Workspace) policy() layoutPolicy {
33 if w.Overflow == OverflowCascade {33 if w.Overflow == OverflowCascade {
34- return layoutPolicy{content: verticalAxis, workspace: horizontalAxis, equalCells: true, appendLast: true}34+ return layoutPolicy{content: verticalAxis, workspace: horizontalAxis, equalCells: true, appendLast: true, wraps: true}
35 }35 }
36 if w.Overflow == OverflowFixed {36 if w.Overflow == OverflowFixed {
37- return layoutPolicy{content: horizontalAxis, workspace: verticalAxis, equalCells: true, appendLast: true, spiral: true}37+ return layoutPolicy{content: horizontalAxis, workspace: verticalAxis, equalCells: true, appendLast: true}
38 }38 }
39 return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}39 return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}
40 }40 }
M internal/core/layout_test.go +2 -2
@@ -352,7 +352,7 @@ func TestLargeWidthAndBounds(t *testing.T) {
352352 t.Fatal(w.Output)
353353 }
354354 }
355-func TestUnchangedUsablePreservesViewX(t *testing.T) {
355+func TestUnchangedUsablePreservesView(t *testing.T) {
356356 w := workspace()
357357 w.AddWindow(1)
358358 w.AddWindow(2)
@@ -363,7 +363,7 @@ func TestUnchangedUsablePreservesViewX(t *testing.T) {
363363 }
364364 w.SetUsable(w.Usable)
365365 if w.View != before {
366- t.Fatalf("ViewX changed from %d to %d", before, w.View)
366+ t.Fatalf("View changed from %d to %d", before, w.View)
367367 }
368368 }
369369
@@ -352,7 +352,7 @@ func TestLargeWidthAndBounds(t *testing.T) {
352 t.Fatal(w.Output)352 t.Fatal(w.Output)
353 }353 }
354 }354 }
355-func TestUnchangedUsablePreservesViewX(t *testing.T) {355+func TestUnchangedUsablePreservesView(t *testing.T) {
356 w := workspace()356 w := workspace()
357 w.AddWindow(1)357 w.AddWindow(1)
358 w.AddWindow(2)358 w.AddWindow(2)
@@ -363,7 +363,7 @@ func TestUnchangedUsablePreservesViewX(t *testing.T) {
363 }363 }
364 w.SetUsable(w.Usable)364 w.SetUsable(w.Usable)
365 if w.View != before {365 if w.View != before {
366- t.Fatalf("ViewX changed from %d to %d", before, w.View)366+ t.Fatalf("View changed from %d to %d", before, w.View)
367 }367 }
368 }368 }
369 369
M internal/core/overview.go +3 -3
@@ -293,7 +293,7 @@ func restoreRow(w *Workspace, s rowSnapshot) {
293293 // Fixed overflow maximizes at most one column: only the one holding
294294 // the anchor window, wherever active binds moved it. Scroll overflow
295295 // keeps each column's own width.
296- if w.Overflow == OverflowFixed {
296+ if w.policy().equalCells {
297297 w.unmaximize()
298298 for i := range w.Columns {
299299 if s.fullWidth != 0 && slices.Contains(w.Columns[i].Windows, s.fullWidth) {
@@ -748,9 +748,9 @@ 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 == OverflowCascade {
751+ if w.policy().wraps {
752752 k := max(w.MaxColumns, 1)
753- width := max((w.Usable.W-g*(k+1))/k, 0)
753+ width := w.cellWidth()
754754 r.X = w.band(i)*w.Usable.W + g + (i%k)*(width+g)
755755 r.W = width
756756 } else if w.Overflow == OverflowFixed {
@@ -293,7 +293,7 @@ func restoreRow(w *Workspace, s rowSnapshot) {
293 // Fixed overflow maximizes at most one column: only the one holding293 // Fixed overflow maximizes at most one column: only the one holding
294 // the anchor window, wherever active binds moved it. Scroll overflow294 // the anchor window, wherever active binds moved it. Scroll overflow
295 // keeps each column's own width.295 // keeps each column's own width.
296- if w.Overflow == OverflowFixed {296+ if w.policy().equalCells {
297 w.unmaximize()297 w.unmaximize()
298 for i := range w.Columns {298 for i := range w.Columns {
299 if s.fullWidth != 0 && slices.Contains(w.Columns[i].Windows, s.fullWidth) {299 if s.fullWidth != 0 && slices.Contains(w.Columns[i].Windows, s.fullWidth) {
@@ -748,9 +748,9 @@ 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 == OverflowCascade {751+ if w.policy().wraps {
752 k := max(w.MaxColumns, 1)752 k := max(w.MaxColumns, 1)
753- width := max((w.Usable.W-g*(k+1))/k, 0)753+ width := w.cellWidth()
754 r.X = w.band(i)*w.Usable.W + g + (i%k)*(width+g)754 r.X = w.band(i)*w.Usable.W + g + (i%k)*(width+g)
755 r.W = width755 r.W = width
756 } else if w.Overflow == OverflowFixed {756 } else if w.Overflow == OverflowFixed {
M internal/core/overview_stack.go +1 -1
@@ -180,7 +180,7 @@ func (m *Monitor) hiddenColumn(w *Workspace) int {
180180 // selectOverviewColumn retains the existing ordinary-row live focus semantics.
181181 func (w *Workspace) selectOverviewColumn(i int) {
182182 w.floatFocus, w.stashFocus = false, false
183- if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) {
183+ if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) {
184184 w.unmaximize()
185185 }
186186 w.Focus = i
@@ -180,7 +180,7 @@ func (m *Monitor) hiddenColumn(w *Workspace) int {
180 // selectOverviewColumn retains the existing ordinary-row live focus semantics.180 // selectOverviewColumn retains the existing ordinary-row live focus semantics.
181 func (w *Workspace) selectOverviewColumn(i int) {181 func (w *Workspace) selectOverviewColumn(i int) {
182 w.floatFocus, w.stashFocus = false, false182 w.floatFocus, w.stashFocus = false, false
183- if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) {183+ if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) {
184 w.unmaximize()184 w.unmaximize()
185 }185 }
186 w.Focus = i186 w.Focus = i
M internal/core/slide.go +4 -4
@@ -8,7 +8,7 @@ import (
88
99 // Slides move what is on screen smoothly: a workspace's columns (view), its
1010 // stash (stashView) and the monitor between workspaces (switchView). All
11-// are pure presentation over the settled state (ViewX, Focus, stashAt,
11+// are pure presentation over the settled state (View, Focus, stashAt,
1212 // Active): the layout ends where the state says as soon as a slide stops.
1313
1414 // slide is a view offset that a swipe sets while the fingers follow it, and
@@ -43,7 +43,7 @@ func (w *Workspace) slidable() bool {
4343 return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat()
4444 }
4545
46-// retarget keeps the view where it is on screen when ViewX moved from
46+// retarget keeps the view where it is on screen when View moved from
4747 // before during a landing slide: the slide heads for the new view.
4848 // Without a landing slide the view moves at once, as it always has.
4949 func (w *Workspace) retarget(before int) {
@@ -78,7 +78,7 @@ 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 {
81+ if w.policy().wraps {
8282 return w.cascadePoints()
8383 }
8484 g := w.gap()
@@ -116,7 +116,7 @@ const snapSpacing = 0.1
116116 // fully shown at the current view, else the furthest fully shown column
117117 // toward the swipe (forward is rightward).
118118 func (w *Workspace) snapFocus(view int, forward bool) (focus int) {
119- if w.Overflow == OverflowCascade {
119+ if w.policy().wraps {
120120 return w.cascadeFocus(view)
121121 }
122122 g := w.gap()
@@ -8,7 +8,7 @@ import (
8 8
9 // Slides move what is on screen smoothly: a workspace's columns (view), its9 // Slides move what is on screen smoothly: a workspace's columns (view), its
10 // stash (stashView) and the monitor between workspaces (switchView). All10 // stash (stashView) and the monitor between workspaces (switchView). All
11-// are pure presentation over the settled state (ViewX, Focus, stashAt,11+// are pure presentation over the settled state (View, Focus, stashAt,
12 // Active): the layout ends where the state says as soon as a slide stops.12 // Active): the layout ends where the state says as soon as a slide stops.
13 13
14 // slide is a view offset that a swipe sets while the fingers follow it, and14 // slide is a view offset that a swipe sets while the fingers follow it, and
@@ -43,7 +43,7 @@ func (w *Workspace) slidable() bool {
43 return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat()43 return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat()
44 }44 }
45 45
46-// retarget keeps the view where it is on screen when ViewX moved from46+// retarget keeps the view where it is on screen when View moved from
47 // before during a landing slide: the slide heads for the new view.47 // before during a landing slide: the slide heads for the new view.
48 // Without a landing slide the view moves at once, as it always has.48 // Without a landing slide the view moves at once, as it always has.
49 func (w *Workspace) retarget(before int) {49 func (w *Workspace) retarget(before int) {
@@ -78,7 +78,7 @@ 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 {81+ if w.policy().wraps {
82 return w.cascadePoints()82 return w.cascadePoints()
83 }83 }
84 g := w.gap()84 g := w.gap()
@@ -116,7 +116,7 @@ const snapSpacing = 0.1
116 // fully shown at the current view, else the furthest fully shown column116 // fully shown at the current view, else the furthest fully shown column
117 // toward the swipe (forward is rightward).117 // toward the swipe (forward is rightward).
118 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 {119+ if w.policy().wraps {
120 return w.cascadeFocus(view)120 return w.cascadeFocus(view)
121 }121 }
122 g := w.gap()122 g := w.gap()
M internal/core/transition.go +6 -6
@@ -28,10 +28,10 @@ type viewShot struct {
2828 // overview is set when the screen showed its overview: ws is then the
2929 // row on screen, the camera fields stay zero, and rects are the cards.
3030 overview bool
31- // viewX is ws's settled view and view where its columns were on screen
32- // (viewX plus shift).
33- viewX int
34- view float64
31+ // settledView is ws's settled view and view where its columns were on screen
32+ // (settledView plus shift).
33+ settledView int
34+ view float64
3535 // viewV and switchV are the speeds the running view and workspace
3636 // springs have at the snapshot's time (units per second, 0 without
3737 // one), not at their last frame. An action may stop them
@@ -622,7 +622,7 @@ func (c *Core) snapshot(now time.Time) []viewShot {
622622 s.ws, s.overview = w, m.ov.open
623623 if !m.ov.open {
624624 // The overview does not scroll or slide: no camera.
625- s.viewX, s.view = w.View, float64(w.View)+w.view.off
625+ s.settledView, s.view = w.View, float64(w.View)+w.view.off
626626 s.switchAxis = w.policy().workspace
627627 if m.switchView.busy() {
628628 s.switchAxis = m.switchAxis
@@ -733,7 +733,7 @@ func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) {
733733 w.stashView.motion = c.spring(w.stashSpring(w.stashView.off, b.stashV), now)
734734 return
735735 }
736- if w.View == b.viewX || !w.slidable() {
736+ if w.View == b.settledView || !w.slidable() {
737737 return
738738 }
739739 // A running landing slide was retargeted by scroll(): its velocity
@@ -28,10 +28,10 @@ type viewShot struct {
28 // overview is set when the screen showed its overview: ws is then the28 // overview is set when the screen showed its overview: ws is then the
29 // row on screen, the camera fields stay zero, and rects are the cards.29 // row on screen, the camera fields stay zero, and rects are the cards.
30 overview bool30 overview bool
31- // viewX is ws's settled view and view where its columns were on screen31+ // settledView is ws's settled view and view where its columns were on screen
32- // (viewX plus shift).32+ // (settledView plus shift).
33- viewX int33+ settledView int
34- view float6434+ view float64
35 // viewV and switchV are the speeds the running view and workspace35 // viewV and switchV are the speeds the running view and workspace
36 // springs have at the snapshot's time (units per second, 0 without36 // springs have at the snapshot's time (units per second, 0 without
37 // one), not at their last frame. An action may stop them37 // one), not at their last frame. An action may stop them
@@ -622,7 +622,7 @@ func (c *Core) snapshot(now time.Time) []viewShot {
622 s.ws, s.overview = w, m.ov.open622 s.ws, s.overview = w, m.ov.open
623 if !m.ov.open {623 if !m.ov.open {
624 // The overview does not scroll or slide: no camera.624 // The overview does not scroll or slide: no camera.
625- s.viewX, s.view = w.View, float64(w.View)+w.view.off625+ s.settledView, s.view = w.View, float64(w.View)+w.view.off
626 s.switchAxis = w.policy().workspace626 s.switchAxis = w.policy().workspace
627 if m.switchView.busy() {627 if m.switchView.busy() {
628 s.switchAxis = m.switchAxis628 s.switchAxis = m.switchAxis
@@ -733,7 +733,7 @@ func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) {
733 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)
734 return734 return
735 }735 }
736- if w.View == b.viewX || !w.slidable() {736+ if w.View == b.settledView || !w.slidable() {
737 return737 return
738 }738 }
739 // A running landing slide was retargeted by scroll(): its velocity739 // A running landing slide was retargeted by scroll(): its velocity

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