fix(overview): contain cascade previews and preserve mixed navigation
- commit
- aee8623d8c81a99389553a301c82d6062e7bb8ff
- author
- brice · · 2026-10-06 14:28 UTC
- parent
- d53d2a4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 287 | 287 | if err != nil { | |
| 288 | 288 | return err | |
| 289 | 289 | } | |
| 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)) | |
| 292 | 292 | 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() | |
| 295 | 294 | } | |
| 296 | 295 | c.cfg = cfg | |
| 297 | 296 | c.binds = binds | |
| @@ -303,6 +302,14 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 303 | 302 | c.settings(s.mon) | |
| 304 | 303 | } | |
| 305 | 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 | 313 | return nil | |
| 307 | 314 | } | |
| 308 | 315 | ||
| @@ -287,11 +287,10 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 287 | if err != nil { | 287 | if err != nil { | ||
| 288 | return err | 288 | 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, 0 | 293 | + | axes[s.mon] = s.mon.Current().policy() |
| 294 | - | s.mon.ov.scrolled, s.mon.ov.sideways = false, false | |||
| 295 | } | 294 | } | ||
| 296 | c.cfg = cfg | 295 | c.cfg = cfg | ||
| 297 | c.binds = binds | 296 | 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 nil | 313 | 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) { | |||
| 521 | 521 | rows[w] = Rect{Y: y + hc + spacing} | |
| 522 | 522 | } | |
| 523 | 523 | 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 | |
| 525 | 527 | if next < i { | |
| 526 | 528 | dx = -dx | |
| 527 | 529 | } | |
| @@ -531,9 +533,10 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) { | |||
| 531 | 533 | margin := u.W * 4 / 100 | |
| 532 | 534 | r := Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1} | |
| 533 | 535 | 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 | |
| 535 | 538 | if next < i { | |
| 536 | - | x = u.X - spacing/2 | |
| 539 | + | x = u.X + u.W/2 - width/2 - spacing/2 | |
| 537 | 540 | } | |
| 538 | 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 | 868 | x = u.X + u.W/2 - scale(sel.X+sel.W/2) | |
| 866 | 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 | 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 | 874 | offset := room/2 - scale(sel.Y+sel.H/2) | |
| 871 | 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 | 881 | r := tiles[i].Rect | |
| 878 | 882 | x0, y0 := scale(r.X), scale(r.Y) | |
| 879 | 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 | 888 | tiles[i].Preview = z | |
| 881 | 889 | if dim { | |
| 882 | 890 | tiles[i].peeking(1) | |
| @@ -1020,7 +1028,7 @@ const ( | |||
| 1020 | 1028 | func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |
| 1021 | 1029 | // The accumulator uses logical content/workspace axes, independent of | |
| 1022 | 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 | 1032 | a.Vertical, a.Horizontal = a.Horizontal, a.Vertical | |
| 1025 | 1033 | } | |
| 1026 | 1034 | if a.Vertical.Stop || a.Horizontal.Stop { | |
| @@ -1056,8 +1064,13 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |||
| 1056 | 1064 | for { | |
| 1057 | 1065 | switch { | |
| 1058 | 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)) | |
| 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 | |
| 1061 | 1074 | m.ov.scrollX = 0 | |
| 1062 | 1075 | if finger { | |
| 1063 | 1076 | 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 + 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 | |||
| 525 | if next < i { | 527 | if next < i { | ||
| 526 | dx = -dx | 528 | dx = -dx | ||
| 527 | } | 529 | } | ||
| @@ -531,9 +533,10 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) { | |||||
| 531 | margin := u.W * 4 / 100 | 533 | 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/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 | |||
| 535 | if next < i { | 538 | if next < i { | ||
| 536 | - | x = u.X - spacing/2 | 539 | + | 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].Rect | 881 | 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 = z | 888 | 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 of | 1029 | // 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.Vertical | 1032 | 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)) * overviewScrollStep | 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 | |||
| 1061 | m.ov.scrollX = 0 | 1074 | m.ov.scrollX = 0 | ||
| 1062 | if finger { | 1075 | if finger { | ||
| 1063 | m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true | 1076 | 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) { | |||
| 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 | 81 | func TestOverviewCascadeStashScale(t *testing.T) { | |
| 68 | 82 | w := cascadeWorkspace(7) | |
| 69 | 83 | 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}} | ||