detached 079b695

feat(overview): show columns hidden by a maximized cascade column as cards

Columns a maximized cascade column hides are dimmed cards behind it in
their band. Selecting one moves the maximization to it; Escape restores it.
A shared hiddenByMaximized predicate replaces the inline checks.
commit
079b695058d28118cb84c8e56577c34c1af9ee6a
author
brice · · 2026-10-07 13:55 UTC
parent
92e4f5a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +173 -30
M docs/config.md +1 -1
@@ -196,7 +196,7 @@ Left/right focus follows column order across band boundaries. Up/down visits sta
196196
197197 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-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour.
198198
199-In the [overview](#overview), the selected band is centred at the usual overview zoom, whatever the band count. The other bands of the workspace sit above and below it, dimmed, and may run off screen. Neighbor workspaces sit left and right, fully dimmed, with their selected row level with the centred band. The stash pile stays left of the band, inside the workspace's slot.
199+In the [overview](#overview), the selected band is centred at the usual overview zoom, whatever the band count. The other bands of the workspace sit above and below it, dimmed, and may run off screen. Neighbor workspaces sit left and right, fully dimmed, with their selected row level with the centred band. The stash pile stays left of the band, inside the workspace's slot. A maximized column shows the columns it hides as dimmed cards behind it, peeking out on their side of the band; selecting one (left/right, Return or a click) moves the maximization to it, and Escape gives it back.
200200
201201 In the overview, up/down move to the previous or next band and stop at the first and last; with a stash card selected they browse the pile. Left/right move along the band; past its first column they enter the stash, or go to the previous workspace when there is none; past its last column they go to the next workspace (with the overview closed, focus crosses into the next band instead). Covering floats fan left and right of the band: left/right traverse them before entering the stash or changing workspace, and up/down do nothing on a float. `h`/`j`/`k`/`l` and the focus binds follow the arrows; `focus-workspace-prev/next` always change workspace, except over a selected stash card, where they browse the pile. Three-finger swipes follow the fingers: vertical moves one band (or one stash card) and horizontal one workspace per gesture, even over a stash card. A vertical wheel or continuous scroll changes workspace, except over a selected stash card, where it browses the pile; a horizontal one moves along the band.
202202
@@ -196,7 +196,7 @@ Left/right focus follows column order across band boundaries. Up/down visits sta
196 196
197 Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour.197 Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour.
198 198
199-In the [overview](#overview), the selected band is centred at the usual overview zoom, whatever the band count. The other bands of the workspace sit above and below it, dimmed, and may run off screen. Neighbor workspaces sit left and right, fully dimmed, with their selected row level with the centred band. The stash pile stays left of the band, inside the workspace's slot.199+In the [overview](#overview), the selected band is centred at the usual overview zoom, whatever the band count. The other bands of the workspace sit above and below it, dimmed, and may run off screen. Neighbor workspaces sit left and right, fully dimmed, with their selected row level with the centred band. The stash pile stays left of the band, inside the workspace's slot. A maximized column shows the columns it hides as dimmed cards behind it, peeking out on their side of the band; selecting one (left/right, Return or a click) moves the maximization to it, and Escape gives it back.
200 200
201 In the overview, up/down move to the previous or next band and stop at the first and last; with a stash card selected they browse the pile. Left/right move along the band; past its first column they enter the stash, or go to the previous workspace when there is none; past its last column they go to the next workspace (with the overview closed, focus crosses into the next band instead). Covering floats fan left and right of the band: left/right traverse them before entering the stash or changing workspace, and up/down do nothing on a float. `h`/`j`/`k`/`l` and the focus binds follow the arrows; `focus-workspace-prev/next` always change workspace, except over a selected stash card, where they browse the pile. Three-finger swipes follow the fingers: vertical moves one band (or one stash card) and horizontal one workspace per gesture, even over a stash card. A vertical wheel or continuous scroll changes workspace, except over a selected stash card, where it browses the pile; a horizontal one moves along the band.201 In the overview, up/down move to the previous or next band and stop at the first and last; with a stash card selected they browse the pile. Left/right move along the band; past its first column they enter the stash, or go to the previous workspace when there is none; past its last column they go to the next workspace (with the overview closed, focus crosses into the next band instead). Covering floats fan left and right of the band: left/right traverse them before entering the stash or changing workspace, and up/down do nothing on a float. `h`/`j`/`k`/`l` and the focus binds follow the arrows; `focus-workspace-prev/next` always change workspace, except over a selected stash card, where they browse the pile. Three-finger swipes follow the fingers: vertical moves one band (or one stash card) and horizontal one workspace per gesture, even over a stash card. A vertical wheel or continuous scroll changes workspace, except over a selected stash card, where it browses the pile; a horizontal one moves along the band.
202 202
M internal/core/layout.go +8 -2
@@ -1052,6 +1052,13 @@ func (w *Workspace) maximize(i int) {
10521052 }
10531053 }
10541054
1055+// hiddenByMaximized reports whether the maximized focused column hides
1056+// column i on screen: in equal-cell layouts it hides the other columns of
1057+// its band (the whole row in fixed overflow).
1058+func (w *Workspace) hiddenByMaximized(i int) bool {
1059+ return w.policy().equalCells && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth && i != w.Focus && w.sameBand(i, w.Focus)
1060+}
1061+
10551062 // unmaximize clears the focused column in scroll mode, or all maximized
10561063 // columns in fixed mode (including after Escape changed focus).
10571064 func (w *Workspace) unmaximize() {
@@ -1637,8 +1644,7 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
16371644 full := w.fullscreen == id && id != 0
16381645 // Fixed overflow cannot scroll to other columns while one fills
16391646 // the view. Keep them in place, but out of the scene.
1640- maximized := w.policy().equalCells && w.Columns[w.Focus].FullWidth && i != w.Focus && w.sameBand(i, w.Focus)
1641- hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || maximized
1647+ hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || w.hiddenByMaximized(i)
16421648 if full {
16431649 // Scroll mode aligns the view on the column; fixed never scrolls.
16441650 r = w.Output
@@ -1052,6 +1052,13 @@ func (w *Workspace) maximize(i int) {
1052 }1052 }
1053 }1053 }
1054 1054
1055+// hiddenByMaximized reports whether the maximized focused column hides
1056+// column i on screen: in equal-cell layouts it hides the other columns of
1057+// its band (the whole row in fixed overflow).
1058+func (w *Workspace) hiddenByMaximized(i int) bool {
1059+ return w.policy().equalCells && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth && i != w.Focus && w.sameBand(i, w.Focus)
1060+}
1061+
1055 // unmaximize clears the focused column in scroll mode, or all maximized1062 // unmaximize clears the focused column in scroll mode, or all maximized
1056 // columns in fixed mode (including after Escape changed focus).1063 // columns in fixed mode (including after Escape changed focus).
1057 func (w *Workspace) unmaximize() {1064 func (w *Workspace) unmaximize() {
@@ -1637,8 +1644,7 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
1637 full := w.fullscreen == id && id != 01644 full := w.fullscreen == id && id != 0
1638 // Fixed overflow cannot scroll to other columns while one fills1645 // Fixed overflow cannot scroll to other columns while one fills
1639 // the view. Keep them in place, but out of the scene.1646 // the view. Keep them in place, but out of the scene.
1640- maximized := w.policy().equalCells && w.Columns[w.Focus].FullWidth && i != w.Focus && w.sameBand(i, w.Focus)1647+ hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || w.hiddenByMaximized(i)
1641- hidden := (cover != 0 && id != cover) || ((fullColumn || w.policy().equalCells && w.fullscreen != 0) && !full) || maximized
1642 if full {1648 if full {
1643 // Scroll mode aligns the view on the column; fixed never scrolls.1649 // Scroll mode aligns the view on the column; fixed never scrolls.
1644 r = w.Output1650 r = w.Output
M internal/core/overview.go +63 -7
@@ -970,17 +970,34 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
970970 rects = w.colBuf
971971 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.
972972 }
973- maximized := w.policy().equalCells && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth
973+ if w.policy().wraps && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth {
974+ // The columns the maximized one hides are cards behind it,
975+ // farthest first so the nearest paint over them.
976+ first, last := w.laneBounds(w.laneOf(w.Focus))
977+ for d := last - first; d > 0; d-- {
978+ if i := w.Focus - d; i >= first {
979+ tiles = w.appendBandCard(tiles, i)
980+ }
981+ if i := w.Focus + d; i <= last {
982+ tiles = w.appendBandCard(tiles, i)
983+ }
984+ }
985+ }
974986 for i, c := range w.Columns {
975987 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}
976988 if w.policy().wraps {
989+ if w.hiddenByMaximized(i) {
990+ continue // A card, above.
991+ }
977992 // The band is the lane: it is encoded in Y, one screen apart.
978- // A maximized column fills its band as on screen.
993+ // A maximized column fills its band as on screen, inset for
994+ // the cards it hides.
979995 r.X = w.cellX(i)
980996 r.Y = g + w.band(i)*w.Usable.H
981997 r.W = w.cellWidth()
982998 if c.FullWidth {
983- r.X, r.W = g, max(w.Usable.W-2*g, 0)
999+ in := w.bandCardsInset(i)
1000+ r.X, r.W = g+in, max(w.Usable.W-2*g-2*in, 0)
9841001 }
9851002 } else if w.Overflow == OverflowFixed {
9861003 r = rects[i]
@@ -989,10 +1006,10 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
9891006 // A fullscreen column shows at the width it had in the row.
9901007 r.W = max(w.Usable.W-2*g, 0)
9911008 }
992- if maximized && i != w.Focus && w.sameBand(i, w.Focus) {
993- // FullWidth hides the other columns of its band on screen.
994- // Their old buffers cannot fit their current rects; show only
995- // the maximized column until navigation restores the layout.
1009+ if w.hiddenByMaximized(i) {
1010+ // Fixed overflow shows the hidden columns on their own card
1011+ // (previewItem). Their old buffers cannot fit their current
1012+ // rects; show only the maximized column here.
9961013 for _, id := range c.Windows {
9971014 tiles = append(tiles, Placement{ID: id, Hidden: true})
9981015 }
@@ -1015,6 +1032,45 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
10151032 return tiles, span, sel
10161033 }
10171034
1035+// bandCardStep is the unscaled step between the cards of a cascade band
1036+// whose maximized column hides the others: the stack peek step.
1037+func (w *Workspace) bandCardStep() int {
1038+ return max(1, int(math.Round(float64(w.Usable.H)*overviewPeekStep)))
1039+}
1040+
1041+// bandCardsInset is how far the maximized column i is inset on each side
1042+// of its band in the overview, leaving room for up to two cards peeking
1043+// out on that side; 0 when it hides nothing.
1044+func (w *Workspace) bandCardsInset(i int) int {
1045+ first, last := w.laneBounds(w.laneOf(i))
1046+ if first >= last {
1047+ return 0
1048+ }
1049+ return 2 * w.bandCardStep()
1050+}
1051+
1052+// appendBandCard appends hidden column i of a cascade band as a dimmed card
1053+// at its cell size behind the maximized column, on its side: the nearest
1054+// column one step out from the maximized one, the next ones two steps out
1055+// (on the band's edge), like the cards of a stack.
1056+func (w *Workspace) appendBandCard(tiles []Placement, i int) []Placement {
1057+ g, s, c := w.gap(), w.bandCardStep(), w.Columns[i]
1058+ r := Rect{Y: g + w.band(i)*w.Usable.H, W: w.cellWidth(), H: max(w.Usable.H-2*g, 0)}
1059+ k := min(max(i-w.Focus, w.Focus-i), 2)
1060+ if i < w.Focus {
1061+ r.X = g + (2-k)*s
1062+ } else {
1063+ r.X = w.Usable.W - g - (2-k)*s - r.W
1064+ }
1065+ w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g)
1066+ for j, t := range w.rowBuf {
1067+ p := Placement{ID: c.Windows[j], Rect: t}
1068+ p.peeking(1)
1069+ tiles = append(tiles, p)
1070+ }
1071+ return tiles
1072+}
1073+
10181074 // previewRow scales the tiles of w into a row placed in slot, centred
10191075 // in the slot's width, or scrolled so the focused column shows when the
10201076 // row is wider, right of the stash pile. Dimmed rows (neighbor
@@ -970,17 +970,34 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
970 rects = w.colBuf970 rects = w.colBuf
971 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.971 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.
972 }972 }
973- maximized := w.policy().equalCells && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth973+ if w.policy().wraps && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth {
974+ // The columns the maximized one hides are cards behind it,
975+ // farthest first so the nearest paint over them.
976+ first, last := w.laneBounds(w.laneOf(w.Focus))
977+ for d := last - first; d > 0; d-- {
978+ if i := w.Focus - d; i >= first {
979+ tiles = w.appendBandCard(tiles, i)
980+ }
981+ if i := w.Focus + d; i <= last {
982+ tiles = w.appendBandCard(tiles, i)
983+ }
984+ }
985+ }
974 for i, c := range w.Columns {986 for i, c := range w.Columns {
975 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}987 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}
976 if w.policy().wraps {988 if w.policy().wraps {
989+ if w.hiddenByMaximized(i) {
990+ continue // A card, above.
991+ }
977 // The band is the lane: it is encoded in Y, one screen apart.992 // The band is the lane: it is encoded in Y, one screen apart.
978- // A maximized column fills its band as on screen.993+ // A maximized column fills its band as on screen, inset for
994+ // the cards it hides.
979 r.X = w.cellX(i)995 r.X = w.cellX(i)
980 r.Y = g + w.band(i)*w.Usable.H996 r.Y = g + w.band(i)*w.Usable.H
981 r.W = w.cellWidth()997 r.W = w.cellWidth()
982 if c.FullWidth {998 if c.FullWidth {
983- r.X, r.W = g, max(w.Usable.W-2*g, 0)999+ in := w.bandCardsInset(i)
1000+ r.X, r.W = g+in, max(w.Usable.W-2*g-2*in, 0)
984 }1001 }
985 } else if w.Overflow == OverflowFixed {1002 } else if w.Overflow == OverflowFixed {
986 r = rects[i]1003 r = rects[i]
@@ -989,10 +1006,10 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
989 // A fullscreen column shows at the width it had in the row.1006 // A fullscreen column shows at the width it had in the row.
990 r.W = max(w.Usable.W-2*g, 0)1007 r.W = max(w.Usable.W-2*g, 0)
991 }1008 }
992- if maximized && i != w.Focus && w.sameBand(i, w.Focus) {1009+ if w.hiddenByMaximized(i) {
993- // FullWidth hides the other columns of its band on screen.1010+ // Fixed overflow shows the hidden columns on their own card
994- // Their old buffers cannot fit their current rects; show only1011+ // (previewItem). Their old buffers cannot fit their current
995- // the maximized column until navigation restores the layout.1012+ // rects; show only the maximized column here.
996 for _, id := range c.Windows {1013 for _, id := range c.Windows {
997 tiles = append(tiles, Placement{ID: id, Hidden: true})1014 tiles = append(tiles, Placement{ID: id, Hidden: true})
998 }1015 }
@@ -1015,6 +1032,45 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
1015 return tiles, span, sel1032 return tiles, span, sel
1016 }1033 }
1017 1034
1035+// bandCardStep is the unscaled step between the cards of a cascade band
1036+// whose maximized column hides the others: the stack peek step.
1037+func (w *Workspace) bandCardStep() int {
1038+ return max(1, int(math.Round(float64(w.Usable.H)*overviewPeekStep)))
1039+}
1040+
1041+// bandCardsInset is how far the maximized column i is inset on each side
1042+// of its band in the overview, leaving room for up to two cards peeking
1043+// out on that side; 0 when it hides nothing.
1044+func (w *Workspace) bandCardsInset(i int) int {
1045+ first, last := w.laneBounds(w.laneOf(i))
1046+ if first >= last {
1047+ return 0
1048+ }
1049+ return 2 * w.bandCardStep()
1050+}
1051+
1052+// appendBandCard appends hidden column i of a cascade band as a dimmed card
1053+// at its cell size behind the maximized column, on its side: the nearest
1054+// column one step out from the maximized one, the next ones two steps out
1055+// (on the band's edge), like the cards of a stack.
1056+func (w *Workspace) appendBandCard(tiles []Placement, i int) []Placement {
1057+ g, s, c := w.gap(), w.bandCardStep(), w.Columns[i]
1058+ r := Rect{Y: g + w.band(i)*w.Usable.H, W: w.cellWidth(), H: max(w.Usable.H-2*g, 0)}
1059+ k := min(max(i-w.Focus, w.Focus-i), 2)
1060+ if i < w.Focus {
1061+ r.X = g + (2-k)*s
1062+ } else {
1063+ r.X = w.Usable.W - g - (2-k)*s - r.W
1064+ }
1065+ w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g)
1066+ for j, t := range w.rowBuf {
1067+ p := Placement{ID: c.Windows[j], Rect: t}
1068+ p.peeking(1)
1069+ tiles = append(tiles, p)
1070+ }
1071+ return tiles
1072+}
1073+
1018 // previewRow scales the tiles of w into a row placed in slot, centred1074 // previewRow scales the tiles of w into a row placed in slot, centred
1019 // in the slot's width, or scrolled so the focused column shows when the1075 // in the slot's width, or scrolled so the focused column shows when the
1020 // row is wider, right of the stash pile. Dimmed rows (neighbor1076 // row is wider, right of the stash pile. Dimmed rows (neighbor
M internal/core/overview_cascade_test.go +96 -19
@@ -75,39 +75,116 @@ func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) {
7575 }
7676 }
7777
78-// A maximized column fills its band in the preview as on screen, and hides
79-// only the other columns of its band.
80-func TestOverviewCascadeMaximizedColumnFillsBand(t *testing.T) {
78+// A maximized column fills its band in the preview, inset for the columns
79+// it hides: they are dimmed cards at their cell size behind it, on their
80+// side, the nearest one step out, the farther ones two (the band's edge).
81+// Other bands keep their cells.
82+func TestOverviewCascadeMaximizedColumnCards(t *testing.T) {
8183 // Four columns, three per band: band 0 holds 0..2, band 1 holds 3.
8284 for _, tc := range []struct {
8385 n, max int
84- hidden []bool
86+ cards []int // per column: 0 not a card, -k/+k k steps left/right
87+ order []int // paint order of band 0's columns
8588 }{
86- {n: 1, max: 0, hidden: []bool{false}},
87- {n: 4, max: 0, hidden: []bool{false, true, true, false}},
88- {n: 4, max: 3, hidden: []bool{false, false, false, false}},
89+ {n: 1, max: 0, cards: []int{0}, order: []int{0}},
90+ {n: 4, max: 0, cards: []int{0, 1, 2, 0}, order: []int{2, 1, 0}},
91+ {n: 4, max: 1, cards: []int{-1, 0, 1, 0}, order: []int{0, 2, 1}},
92+ {n: 4, max: 3, cards: []int{0, 0, 0, 0}},
8993 } {
9094 w := cascadeWorkspace(tc.n)
9195 w.Focus = tc.max
9296 w.maximize(tc.max)
93- tiles, _, _ := w.previewTiles()
94- g, h := w.gap(), w.Usable.H-2*w.gap()
95- for i, p := range tiles {
96- if p.Hidden != tc.hidden[i] {
97- t.Fatalf("n=%d max=%d column %d hidden %v, want %v", tc.n, tc.max, i, p.Hidden, tc.hidden[i])
98- }
99- if p.Hidden {
100- continue
97+ tiles, _, sel := w.previewTiles()
98+ g, h, s := w.gap(), w.Usable.H-2*w.gap(), w.bandCardStep()
99+ in := 0
100+ if slices.ContainsFunc(tc.cards, func(k int) bool { return k != 0 }) {
101+ in = 2 * s
102+ }
103+ var order []int
104+ for _, p := range tiles {
105+ i := int(p.ID) - 1
106+ if i/3 == 0 && tc.order != nil {
107+ order = append(order, i)
101108 }
102109 band := i / 3
103110 want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(), H: h}
104- if i == tc.max {
105- want.X, want.W = g, w.Usable.W-2*g
111+ switch k := tc.cards[i]; {
112+ case i == tc.max:
113+ want.X, want.W = g+in, w.Usable.W-2*g-2*in
114+ case k < 0:
115+ want.X = g + (2+k)*s
116+ case k > 0:
117+ want.X = w.Usable.W - g - (2-k)*s - want.W
118+ }
119+ if p.Hidden || p.Peek != (tc.cards[i] != 0) || p.Focused != (i == tc.max) || p.Rect != want {
120+ t.Fatalf("n=%d max=%d column %d: %+v, want rect %+v card %v", tc.n, tc.max, i, p, want, tc.cards[i] != 0)
106121 }
107- if p.Rect != want {
108- t.Fatalf("n=%d max=%d column %d rect %+v, want %+v", tc.n, tc.max, i, p.Rect, want)
122+ if i == tc.max && sel != want {
123+ t.Fatalf("n=%d max=%d sel %+v, want %+v", tc.n, tc.max, sel, want)
109124 }
110125 }
126+ if tc.order != nil && !slices.Equal(order, tc.order) {
127+ t.Fatalf("n=%d max=%d paint order %v, want %v", tc.n, tc.max, order, tc.order)
128+ }
129+ }
130+}
131+
132+// Moving onto a card behind a maximized column hands it the maximization;
133+// Return keeps it there, Escape gives it back. Moving to another band
134+// unmaximizes as before.
135+func TestOverviewCascadeMaximizedCardSelection(t *testing.T) {
136+ for _, d := range cascadeDrivers {
137+ t.Run(d.name, func(t *testing.T) {
138+ m := cascadeOverview()
139+ w := m.Current()
140+ w.FocusID(9) // band 2 holds 7, 8, 9
141+ w.ToggleFullWidth()
142+ m.ToggleOverview()
143+ for _, id := range []WindowID{7, 8} {
144+ if p := previewOf(t, m.Layout(), id); p.Hidden || !p.Peek {
145+ t.Fatalf("card %d: %+v", id, p)
146+ }
147+ }
148+ d.move(m, -1, 0)
149+ if focusedID(m) != 8 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(9)].FullWidth {
150+ t.Fatalf("left: focus %d, maximization not moved", focusedID(m))
151+ }
152+ if p := previewOf(t, m.Layout(), 9); !p.Peek {
153+ t.Fatalf("former maximized column not a card: %+v", p)
154+ }
155+ m.CancelOverview()
156+ if focusedID(m) != 9 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(8)].FullWidth {
157+ t.Fatalf("escape: focus %d, maximization not restored", focusedID(m))
158+ }
159+
160+ m.ToggleOverview()
161+ d.move(m, -1, 0)
162+ d.move(m, -1, 0)
163+ m.ToggleOverview() // return
164+ if focusedID(m) != 7 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(9)].FullWidth {
165+ t.Fatalf("return: focus %d, maximization not on 7", focusedID(m))
166+ }
167+
168+ m.ToggleOverview()
169+ d.move(m, 0, -1)
170+ if focusedID(m) != 4 || slices.ContainsFunc(w.Columns, func(c Column) bool { return c.FullWidth }) {
171+ t.Fatalf("up: focus %d, a column still maximized", focusedID(m))
172+ }
173+ })
174+ }
175+}
176+
177+// A click on a card behind a maximized cascade column hands it the
178+// maximization.
179+func TestOverviewCascadeMaximizedCardPick(t *testing.T) {
180+ m := cascadeOverview()
181+ w := m.Current()
182+ w.FocusID(7)
183+ w.ToggleFullWidth()
184+ m.ToggleOverview()
185+ m.OverviewPick(9)
186+ if m.ov.open || focusedID(m) != 9 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(7)].FullWidth {
187+ t.Fatalf("pick: open %v, focus %d, maximization not moved", m.ov.open, focusedID(m))
111188 }
112189 }
113190
@@ -75,39 +75,116 @@ func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) {
75 }75 }
76 }76 }
77 77
78-// A maximized column fills its band in the preview as on screen, and hides78+// A maximized column fills its band in the preview, inset for the columns
79-// only the other columns of its band.79+// it hides: they are dimmed cards at their cell size behind it, on their
80-func TestOverviewCascadeMaximizedColumnFillsBand(t *testing.T) {80+// side, the nearest one step out, the farther ones two (the band's edge).
81+// Other bands keep their cells.
82+func TestOverviewCascadeMaximizedColumnCards(t *testing.T) {
81 // Four columns, three per band: band 0 holds 0..2, band 1 holds 3.83 // Four columns, three per band: band 0 holds 0..2, band 1 holds 3.
82 for _, tc := range []struct {84 for _, tc := range []struct {
83 n, max int85 n, max int
84- hidden []bool86+ cards []int // per column: 0 not a card, -k/+k k steps left/right
87+ order []int // paint order of band 0's columns
85 }{88 }{
86- {n: 1, max: 0, hidden: []bool{false}},89+ {n: 1, max: 0, cards: []int{0}, order: []int{0}},
87- {n: 4, max: 0, hidden: []bool{false, true, true, false}},90+ {n: 4, max: 0, cards: []int{0, 1, 2, 0}, order: []int{2, 1, 0}},
88- {n: 4, max: 3, hidden: []bool{false, false, false, false}},91+ {n: 4, max: 1, cards: []int{-1, 0, 1, 0}, order: []int{0, 2, 1}},
92+ {n: 4, max: 3, cards: []int{0, 0, 0, 0}},
89 } {93 } {
90 w := cascadeWorkspace(tc.n)94 w := cascadeWorkspace(tc.n)
91 w.Focus = tc.max95 w.Focus = tc.max
92 w.maximize(tc.max)96 w.maximize(tc.max)
93- tiles, _, _ := w.previewTiles()97+ tiles, _, sel := w.previewTiles()
94- g, h := w.gap(), w.Usable.H-2*w.gap()98+ g, h, s := w.gap(), w.Usable.H-2*w.gap(), w.bandCardStep()
95- for i, p := range tiles {99+ in := 0
96- if p.Hidden != tc.hidden[i] {100+ if slices.ContainsFunc(tc.cards, func(k int) bool { return k != 0 }) {
97- t.Fatalf("n=%d max=%d column %d hidden %v, want %v", tc.n, tc.max, i, p.Hidden, tc.hidden[i])101+ in = 2 * s
98- }102+ }
99- if p.Hidden {103+ var order []int
100- continue104+ for _, p := range tiles {
105+ i := int(p.ID) - 1
106+ if i/3 == 0 && tc.order != nil {
107+ order = append(order, i)
101 }108 }
102 band := i / 3109 band := i / 3
103 want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(), H: h}110 want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(), H: h}
104- if i == tc.max {111+ switch k := tc.cards[i]; {
105- want.X, want.W = g, w.Usable.W-2*g112+ case i == tc.max:
113+ want.X, want.W = g+in, w.Usable.W-2*g-2*in
114+ case k < 0:
115+ want.X = g + (2+k)*s
116+ case k > 0:
117+ want.X = w.Usable.W - g - (2-k)*s - want.W
118+ }
119+ if p.Hidden || p.Peek != (tc.cards[i] != 0) || p.Focused != (i == tc.max) || p.Rect != want {
120+ t.Fatalf("n=%d max=%d column %d: %+v, want rect %+v card %v", tc.n, tc.max, i, p, want, tc.cards[i] != 0)
106 }121 }
107- if p.Rect != want {122+ if i == tc.max && sel != want {
108- t.Fatalf("n=%d max=%d column %d rect %+v, want %+v", tc.n, tc.max, i, p.Rect, want)123+ t.Fatalf("n=%d max=%d sel %+v, want %+v", tc.n, tc.max, sel, want)
109 }124 }
110 }125 }
126+ if tc.order != nil && !slices.Equal(order, tc.order) {
127+ t.Fatalf("n=%d max=%d paint order %v, want %v", tc.n, tc.max, order, tc.order)
128+ }
129+ }
130+}
131+
132+// Moving onto a card behind a maximized column hands it the maximization;
133+// Return keeps it there, Escape gives it back. Moving to another band
134+// unmaximizes as before.
135+func TestOverviewCascadeMaximizedCardSelection(t *testing.T) {
136+ for _, d := range cascadeDrivers {
137+ t.Run(d.name, func(t *testing.T) {
138+ m := cascadeOverview()
139+ w := m.Current()
140+ w.FocusID(9) // band 2 holds 7, 8, 9
141+ w.ToggleFullWidth()
142+ m.ToggleOverview()
143+ for _, id := range []WindowID{7, 8} {
144+ if p := previewOf(t, m.Layout(), id); p.Hidden || !p.Peek {
145+ t.Fatalf("card %d: %+v", id, p)
146+ }
147+ }
148+ d.move(m, -1, 0)
149+ if focusedID(m) != 8 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(9)].FullWidth {
150+ t.Fatalf("left: focus %d, maximization not moved", focusedID(m))
151+ }
152+ if p := previewOf(t, m.Layout(), 9); !p.Peek {
153+ t.Fatalf("former maximized column not a card: %+v", p)
154+ }
155+ m.CancelOverview()
156+ if focusedID(m) != 9 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(8)].FullWidth {
157+ t.Fatalf("escape: focus %d, maximization not restored", focusedID(m))
158+ }
159+
160+ m.ToggleOverview()
161+ d.move(m, -1, 0)
162+ d.move(m, -1, 0)
163+ m.ToggleOverview() // return
164+ if focusedID(m) != 7 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(9)].FullWidth {
165+ t.Fatalf("return: focus %d, maximization not on 7", focusedID(m))
166+ }
167+
168+ m.ToggleOverview()
169+ d.move(m, 0, -1)
170+ if focusedID(m) != 4 || slices.ContainsFunc(w.Columns, func(c Column) bool { return c.FullWidth }) {
171+ t.Fatalf("up: focus %d, a column still maximized", focusedID(m))
172+ }
173+ })
174+ }
175+}
176+
177+// A click on a card behind a maximized cascade column hands it the
178+// maximization.
179+func TestOverviewCascadeMaximizedCardPick(t *testing.T) {
180+ m := cascadeOverview()
181+ w := m.Current()
182+ w.FocusID(7)
183+ w.ToggleFullWidth()
184+ m.ToggleOverview()
185+ m.OverviewPick(9)
186+ if m.ov.open || focusedID(m) != 9 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(7)].FullWidth {
187+ t.Fatalf("pick: open %v, focus %d, maximization not moved", m.ov.open, focusedID(m))
111 }188 }
112 }189 }
113 190
M internal/core/overview_stack.go +5 -1
@@ -214,7 +214,11 @@ func (m *Monitor) hiddenColumn(w *Workspace) int {
214214 // selectOverviewColumn retains the existing ordinary-row live focus semantics.
215215 func (w *Workspace) selectOverviewColumn(i int) {
216216 w.floatFocus, w.stashFocus = false, false
217- if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) {
217+ if w.policy().wraps && w.hiddenByMaximized(i) {
218+ // A card behind a maximized cascade column takes the
219+ // maximization; Escape gives it back (restoreRow).
220+ w.transferMaximization(i)
221+ } else if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) {
218222 w.unmaximize()
219223 }
220224 w.Focus = i
@@ -214,7 +214,11 @@ func (m *Monitor) hiddenColumn(w *Workspace) int {
214 // selectOverviewColumn retains the existing ordinary-row live focus semantics.214 // selectOverviewColumn retains the existing ordinary-row live focus semantics.
215 func (w *Workspace) selectOverviewColumn(i int) {215 func (w *Workspace) selectOverviewColumn(i int) {
216 w.floatFocus, w.stashFocus = false, false216 w.floatFocus, w.stashFocus = false, false
217- if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) {217+ if w.policy().wraps && w.hiddenByMaximized(i) {
218+ // A card behind a maximized cascade column takes the
219+ // maximization; Escape gives it back (restoreRow).
220+ w.transferMaximization(i)
221+ } else if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) {
218 w.unmaximize()222 w.unmaximize()
219 }223 }
220 w.Focus = i224 w.Focus = i

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