detached aee8623

fix(overview): contain cascade previews and preserve mixed navigation

commit
aee8623d8c81a99389553a301c82d6062e7bb8ff
author
brice · · 2026-10-06 14:28 UTC
parent
d53d2a4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +65 -11
M internal/core/core.go +11 -4
@@ -287,11 +287,10 @@ func (c *Core) apply(cfg ports.Config) error {
287287 if err != nil {
288288 return err
289289 }
290- // A successful configuration update starts a fresh overview gesture.
291- // Selection and opening snapshots remain identity-based across layouts.
290+ // Reload is cold-path work; preserve gestures on unrelated changes.
291+ axes := make(map[*Monitor]layoutPolicy, len(c.screens))
292292 for _, s := range c.screens {
293- s.mon.ov.scrollX, s.mon.ov.scrollY = 0, 0
294- s.mon.ov.scrolled, s.mon.ov.sideways = false, false
293+ axes[s.mon] = s.mon.Current().policy()
295294 }
296295 c.cfg = cfg
297296 c.binds = binds
@@ -303,6 +302,14 @@ func (c *Core) apply(cfg ports.Config) error {
303302 c.settings(s.mon)
304303 }
305304 c.applyOutputConfig()
305+ for _, s := range c.screens {
306+ old := axes[s.mon]
307+ now := s.mon.Current().policy()
308+ if old.content != now.content || old.workspace != now.workspace {
309+ s.mon.ov.scrollX, s.mon.ov.scrollY = 0, 0
310+ s.mon.ov.scrolled, s.mon.ov.sideways = false, false
311+ }
312+ }
306313 return nil
307314 }
308315
@@ -287,11 +287,10 @@ func (c *Core) apply(cfg ports.Config) error {
287 if err != nil {287 if err != nil {
288 return err288 return err
289 }289 }
290- // A successful configuration update starts a fresh overview gesture.290+ // Reload is cold-path work; preserve gestures on unrelated changes.
291- // Selection and opening snapshots remain identity-based across layouts.291+ axes := make(map[*Monitor]layoutPolicy, len(c.screens))
292 for _, s := range c.screens {292 for _, s := range c.screens {
293- s.mon.ov.scrollX, s.mon.ov.scrollY = 0, 0293+ axes[s.mon] = s.mon.Current().policy()
294- s.mon.ov.scrolled, s.mon.ov.sideways = false, false
295 }294 }
296 c.cfg = cfg295 c.cfg = cfg
297 c.binds = binds296 c.binds = binds
@@ -303,6 +302,14 @@ func (c *Core) apply(cfg ports.Config) error {
303 c.settings(s.mon)302 c.settings(s.mon)
304 }303 }
305 c.applyOutputConfig()304 c.applyOutputConfig()
305+ for _, s := range c.screens {
306+ old := axes[s.mon]
307+ now := s.mon.Current().policy()
308+ if old.content != now.content || old.workspace != now.workspace {
309+ s.mon.ov.scrollX, s.mon.ov.scrollY = 0, 0
310+ s.mon.ov.scrolled, s.mon.ov.sideways = false, false
311+ }
312+ }
306 return nil313 return nil
307 }314 }
308 315
M internal/core/overview.go +20 -7
@@ -521,7 +521,9 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
521521 rows[w] = Rect{Y: y + hc + spacing}
522522 }
523523 if cur.policy().workspace == horizontalAxis {
524- dx := u.W + spacing
524+ width := int(math.Round(float64(cur.Usable.W)*cur.overviewZoom())) + cur.pileWidth()
525+ neighborWidth := int(math.Round(float64(w.Usable.W)*w.overviewZoom())) + w.pileWidth()
526+ dx := (width+neighborWidth)/2 + spacing
525527 if next < i {
526528 dx = -dx
527529 }
@@ -531,9 +533,10 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
531533 margin := u.W * 4 / 100
532534 r := Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1}
533535 if cur.policy().workspace == horizontalAxis {
534- x := u.X + u.W + spacing/2
536+ width := int(math.Round(float64(cur.Usable.W)*cur.overviewZoom())) + cur.pileWidth()
537+ x := u.X + u.W/2 + width/2 + spacing/2
535538 if next < i {
536- x = u.X - spacing/2
539+ x = u.X + u.W/2 - width/2 - spacing/2
537540 }
538541 r = Rect{X: x, Y: u.Y + u.H*4/100, W: 1, H: u.H * 92 / 100}
539542 }
@@ -865,8 +868,9 @@ func (w *Workspace) previewGeometry(y int, dim, lit bool, tiles []Placement, pre
865868 x = u.X + u.W/2 - scale(sel.X+sel.W/2)
866869 x = min(max(x, u.X+u.W-width), u.X)
867870 }
871+ top, bottom := y, y+min(scale(preview.bounds.H), u.H*8/10)
868872 if w.policy().wraps && preview.bounds.H > w.Usable.H {
869- room := min(scale(preview.bounds.H), u.H*8/10)
873+ room := bottom - top
870874 offset := room/2 - scale(sel.Y+sel.H/2)
871875 y += min(0, max(room-scale(preview.bounds.H), offset))
872876 }
@@ -877,6 +881,10 @@ func (w *Workspace) previewGeometry(y int, dim, lit bool, tiles []Placement, pre
877881 r := tiles[i].Rect
878882 x0, y0 := scale(r.X), scale(r.Y)
879883 tiles[i].Rect = Rect{X: x + x0, Y: y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0}
884+ if w.policy().wraps && (tiles[i].Rect.Y < top || tiles[i].Rect.Y+tiles[i].Rect.H > bottom) {
885+ tiles[i].Hidden, tiles[i].Focused, tiles[i].Preview = true, false, 0
886+ continue
887+ }
880888 tiles[i].Preview = z
881889 if dim {
882890 tiles[i].peeking(1)
@@ -1020,7 +1028,7 @@ const (
10201028 func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
10211029 // The accumulator uses logical content/workspace axes, independent of
10221030 // their physical orientation. Stash selection retains its own axes.
1023- if m.Current().policy().wraps && m.cardAt(m.Current()) < 0 {
1031+ if a.Source == ports.AxisFinger && m.Current().policy().wraps && m.cardAt(m.Current()) < 0 {
10241032 a.Vertical, a.Horizontal = a.Horizontal, a.Vertical
10251033 }
10261034 if a.Vertical.Stop || a.Horizontal.Stop {
@@ -1056,8 +1064,13 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
10561064 for {
10571065 switch {
10581066 case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX):
1059- m.OverviewMove(0, sign(m.ov.scrollY))
1060- m.ov.scrollY -= float64(sign(m.ov.scrollY)) * overviewScrollStep
1067+ step := sign(m.ov.scrollY)
1068+ axis := m.Current().policy().workspace
1069+ m.OverviewMove(0, step)
1070+ if axis != m.Current().policy().workspace {
1071+ return true
1072+ }
1073+ m.ov.scrollY -= float64(step) * overviewScrollStep
10611074 m.ov.scrollX = 0
10621075 if finger {
10631076 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
@@ -521,7 +521,9 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
521 rows[w] = Rect{Y: y + hc + spacing}521 rows[w] = Rect{Y: y + hc + spacing}
522 }522 }
523 if cur.policy().workspace == horizontalAxis {523 if cur.policy().workspace == horizontalAxis {
524- dx := u.W + spacing524+ width := int(math.Round(float64(cur.Usable.W)*cur.overviewZoom())) + cur.pileWidth()
525+ neighborWidth := int(math.Round(float64(w.Usable.W)*w.overviewZoom())) + w.pileWidth()
526+ dx := (width+neighborWidth)/2 + spacing
525 if next < i {527 if next < i {
526 dx = -dx528 dx = -dx
527 }529 }
@@ -531,9 +533,10 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
531 margin := u.W * 4 / 100533 margin := u.W * 4 / 100
532 r := Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1}534 r := Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1}
533 if cur.policy().workspace == horizontalAxis {535 if cur.policy().workspace == horizontalAxis {
534- x := u.X + u.W + spacing/2536+ width := int(math.Round(float64(cur.Usable.W)*cur.overviewZoom())) + cur.pileWidth()
537+ x := u.X + u.W/2 + width/2 + spacing/2
535 if next < i {538 if next < i {
536- x = u.X - spacing/2539+ x = u.X + u.W/2 - width/2 - spacing/2
537 }540 }
538 r = Rect{X: x, Y: u.Y + u.H*4/100, W: 1, H: u.H * 92 / 100}541 r = Rect{X: x, Y: u.Y + u.H*4/100, W: 1, H: u.H * 92 / 100}
539 }542 }
@@ -865,8 +868,9 @@ func (w *Workspace) previewGeometry(y int, dim, lit bool, tiles []Placement, pre
865 x = u.X + u.W/2 - scale(sel.X+sel.W/2)868 x = u.X + u.W/2 - scale(sel.X+sel.W/2)
866 x = min(max(x, u.X+u.W-width), u.X)869 x = min(max(x, u.X+u.W-width), u.X)
867 }870 }
871+ top, bottom := y, y+min(scale(preview.bounds.H), u.H*8/10)
868 if w.policy().wraps && preview.bounds.H > w.Usable.H {872 if w.policy().wraps && preview.bounds.H > w.Usable.H {
869- room := min(scale(preview.bounds.H), u.H*8/10)873+ room := bottom - top
870 offset := room/2 - scale(sel.Y+sel.H/2)874 offset := room/2 - scale(sel.Y+sel.H/2)
871 y += min(0, max(room-scale(preview.bounds.H), offset))875 y += min(0, max(room-scale(preview.bounds.H), offset))
872 }876 }
@@ -877,6 +881,10 @@ func (w *Workspace) previewGeometry(y int, dim, lit bool, tiles []Placement, pre
877 r := tiles[i].Rect881 r := tiles[i].Rect
878 x0, y0 := scale(r.X), scale(r.Y)882 x0, y0 := scale(r.X), scale(r.Y)
879 tiles[i].Rect = Rect{X: x + x0, Y: y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0}883 tiles[i].Rect = Rect{X: x + x0, Y: y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0}
884+ if w.policy().wraps && (tiles[i].Rect.Y < top || tiles[i].Rect.Y+tiles[i].Rect.H > bottom) {
885+ tiles[i].Hidden, tiles[i].Focused, tiles[i].Preview = true, false, 0
886+ continue
887+ }
880 tiles[i].Preview = z888 tiles[i].Preview = z
881 if dim {889 if dim {
882 tiles[i].peeking(1)890 tiles[i].peeking(1)
@@ -1020,7 +1028,7 @@ const (
1020 func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {1028 func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
1021 // The accumulator uses logical content/workspace axes, independent of1029 // The accumulator uses logical content/workspace axes, independent of
1022 // their physical orientation. Stash selection retains its own axes.1030 // their physical orientation. Stash selection retains its own axes.
1023- if m.Current().policy().wraps && m.cardAt(m.Current()) < 0 {1031+ if a.Source == ports.AxisFinger && m.Current().policy().wraps && m.cardAt(m.Current()) < 0 {
1024 a.Vertical, a.Horizontal = a.Horizontal, a.Vertical1032 a.Vertical, a.Horizontal = a.Horizontal, a.Vertical
1025 }1033 }
1026 if a.Vertical.Stop || a.Horizontal.Stop {1034 if a.Vertical.Stop || a.Horizontal.Stop {
@@ -1056,8 +1064,13 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
1056 for {1064 for {
1057 switch {1065 switch {
1058 case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX):1066 case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX):
1059- m.OverviewMove(0, sign(m.ov.scrollY))1067+ step := sign(m.ov.scrollY)
1060- m.ov.scrollY -= float64(sign(m.ov.scrollY)) * overviewScrollStep1068+ axis := m.Current().policy().workspace
1069+ m.OverviewMove(0, step)
1070+ if axis != m.Current().policy().workspace {
1071+ return true
1072+ }
1073+ m.ov.scrollY -= float64(step) * overviewScrollStep
1061 m.ov.scrollX = 01074 m.ov.scrollX = 0
1062 if finger {1075 if finger {
1063 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true1076 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
A internal/core/overview_bench_test.go +20 -0
@@ -0,0 +1,20 @@
1+package core
2+
3+import "testing"
4+
5+func BenchmarkOverviewCascade(b *testing.B) {
6+ m := NewMonitor()
7+ m.SetOutput(900, 600)
8+ m.SetMaxColumns(3)
9+ m.SetOverflow(OverflowCascade)
10+ for id := WindowID(1); id <= 7; id++ {
11+ m.AddWindow(id)
12+ }
13+ m.ToggleOverview()
14+ buf := m.Layout()
15+ b.ReportAllocs()
16+ b.ResetTimer()
17+ for range b.N {
18+ buf = m.layoutInto(buf[:0])
19+ }
20+}
@@ -0,0 +1,20 @@
1+package core
2+
3+import "testing"
4+
5+func BenchmarkOverviewCascade(b *testing.B) {
6+ m := NewMonitor()
7+ m.SetOutput(900, 600)
8+ m.SetMaxColumns(3)
9+ m.SetOverflow(OverflowCascade)
10+ for id := WindowID(1); id <= 7; id++ {
11+ m.AddWindow(id)
12+ }
13+ m.ToggleOverview()
14+ buf := m.Layout()
15+ b.ReportAllocs()
16+ b.ResetTimer()
17+ for range b.N {
18+ buf = m.layoutInto(buf[:0])
19+ }
20+}
M internal/core/overview_cascade_test.go +14 -0
@@ -64,6 +64,20 @@ func TestOverviewCascadeReloadRetainsSelection(t *testing.T) {
6464 }
6565 }
6666
67+func TestOverviewMixedWheelDoesNotBounce(t *testing.T) {
68+ for _, source := range []ports.AxisSource{ports.AxisWheel, ports.AxisContinuous} {
69+ m := overviewMonitor()
70+ m.SetOverflow(OverflowFixed)
71+ m.Workspaces[1].Overflow = OverflowCascade
72+ m.ToggleOverview()
73+ from := m.Current()
74+ m.overviewScroll(ports.PointerAxis{Source: source, Vertical: ports.ScrollAxis{Set: true, Value: overviewScrollStep, V120: 120}})
75+ if m.Current() == from {
76+ t.Fatalf("source %v bounced", source)
77+ }
78+ }
79+}
80+
6781 func TestOverviewCascadeStashScale(t *testing.T) {
6882 w := cascadeWorkspace(7)
6983 w.Stash = []Float{{ID: 20}}
@@ -64,6 +64,20 @@ func TestOverviewCascadeReloadRetainsSelection(t *testing.T) {
64 }64 }
65 }65 }
66 66
67+func TestOverviewMixedWheelDoesNotBounce(t *testing.T) {
68+ for _, source := range []ports.AxisSource{ports.AxisWheel, ports.AxisContinuous} {
69+ m := overviewMonitor()
70+ m.SetOverflow(OverflowFixed)
71+ m.Workspaces[1].Overflow = OverflowCascade
72+ m.ToggleOverview()
73+ from := m.Current()
74+ m.overviewScroll(ports.PointerAxis{Source: source, Vertical: ports.ScrollAxis{Set: true, Value: overviewScrollStep, V120: 120}})
75+ if m.Current() == from {
76+ t.Fatalf("source %v bounced", source)
77+ }
78+ }
79+}
80+
67 func TestOverviewCascadeStashScale(t *testing.T) {81 func TestOverviewCascadeStashScale(t *testing.T) {
68 w := cascadeWorkspace(7)82 w := cascadeWorkspace(7)
69 w.Stash = []Float{{ID: 20}}83 w.Stash = []Float{{ID: 20}}

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 23

Tags 10

↑↓ moveenter openesc close