detached 594ac4d

feat(core): centre the cascade overview stash across the lane's bottom edge

In a horizontal overview the stash pile no longer takes room on the left of its row: it is centred on the row, straddling the bottom edge of the selected lane, over the tiles. Down (keys or three fingers) past the last lane enters it; left from the first column goes to the previous workspace. Right from the stash brings the columns to the front.
commit
594ac4d7db083e326ae4653e2b2de37b2812b24b
author
brice · · 2026-10-14 19:19 UTC
parent
f4ebc85
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +221 -128
M internal/core/layout_buffers_test.go +3 -3
@@ -146,7 +146,7 @@ func TestLayoutResultsDoNotAlias(t *testing.T) {
146146 buf = m.layoutInto(buf)
147147 buf = m.Current().layoutInto(buf)
148148 m.Current().previewTiles()
149- m.Current().overviewZoom()
149+ m.Current().overviewZoom(m.overviewAxis())
150150 }
151151 // layoutInto builds in the caller's buffer, never in a scratch of
152152 // the workspace: with the capacity there it returns that storage.
@@ -164,8 +164,8 @@ func TestLayoutResultsDoNotAlias(t *testing.T) {
164164 // unchanged.
165165 mine := w.layoutInto(make([]Placement, 0, len(res)))
166166 mineKept := slices.Clone(mine)
167- w.overviewZoom()
168- w.peekStep()
167+ w.overviewZoom(m.overviewAxis())
168+ w.peekStep(m.overviewAxis())
169169 w.previewTiles()
170170 w.columnRects()
171171 _ = w.Layout()
@@ -146,7 +146,7 @@ func TestLayoutResultsDoNotAlias(t *testing.T) {
146 buf = m.layoutInto(buf)146 buf = m.layoutInto(buf)
147 buf = m.Current().layoutInto(buf)147 buf = m.Current().layoutInto(buf)
148 m.Current().previewTiles()148 m.Current().previewTiles()
149- m.Current().overviewZoom()149+ m.Current().overviewZoom(m.overviewAxis())
150 }150 }
151 // layoutInto builds in the caller's buffer, never in a scratch of151 // layoutInto builds in the caller's buffer, never in a scratch of
152 // the workspace: with the capacity there it returns that storage.152 // the workspace: with the capacity there it returns that storage.
@@ -164,8 +164,8 @@ func TestLayoutResultsDoNotAlias(t *testing.T) {
164 // unchanged.164 // unchanged.
165 mine := w.layoutInto(make([]Placement, 0, len(res)))165 mine := w.layoutInto(make([]Placement, 0, len(res)))
166 mineKept := slices.Clone(mine)166 mineKept := slices.Clone(mine)
167- w.overviewZoom()167+ w.overviewZoom(m.overviewAxis())
168- w.peekStep()168+ w.peekStep(m.overviewAxis())
169 w.previewTiles()169 w.previewTiles()
170 w.columnRects()170 w.columnRects()
171 _ = w.Layout()171 _ = w.Layout()
M internal/core/overview.go +88 -84
@@ -53,10 +53,12 @@ type rowSnapshot struct {
5353 // workspace. A pinned fullscreen window is the front card of its row, the
5454 // windows it hides behind it; choosing one of them leaves fullscreen.
5555
56-// A workspace's stash shows as a pile of cards pinned to the left edge of
57-// its row: the selected stashed window in front at overviewCardZoom, up to
58-// overviewCards-1 others behind it, each overviewCardStep further up and
59-// right, dimmed. The row takes the width the pile leaves.
56+// A workspace's stash shows as a pile of cards: the selected stashed window
57+// in front at overviewCardZoom, up to overviewCards-1 others behind it, each
58+// overviewCardStep further up and right, dimmed. A vertical overview pins
59+// the pile to the left edge of its row, which takes the width the pile
60+// leaves. A horizontal one centres it on its row, straddling the bottom edge
61+// of the selected lane, over the tiles.
6062 const (
6163 overviewCardZoom = 0.2
6264 overviewCards = 4
@@ -423,15 +425,22 @@ func (m *Monitor) cycleCard(w *Workspace, d int) bool {
423425 // overviewLane is the vertical move of an overview whose workspaces sit side
424426 // by side (cascade): a selected stash card cycles the pile; else the
425427 // selection goes to the previous or next lane (band), at the closest column,
426-// stopping at the first and last. Covering floats stack along the horizontal
427-// axis, not this one.
428+// stopping at the first. Down past the last lane, or from a card that is not
429+// the columns, enters the stash, which sits below. Covering floats stack
430+// along the horizontal axis, not this one.
428431 func (m *Monitor) overviewLane(dy int) {
429432 w := m.Current()
430- if m.cycleCard(w, dy) || m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
433+ if m.cycleCard(w, dy) {
431434 return
432435 }
433- if i := w.laneNeighbor(dy); i >= 0 {
434- m.selectLaneColumn(w, i)
436+ if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {
437+ if i := w.laneNeighbor(dy); i >= 0 {
438+ m.selectLaneColumn(w, i)
439+ return
440+ }
441+ }
442+ if dy > 0 && len(w.Stash) > 0 {
443+ m.selectCard(w, w.stashAt)
435444 }
436445 }
437446
@@ -444,10 +453,11 @@ func (m *Monitor) selectLaneColumn(w *Workspace, i int) {
444453
445454 // overviewAcross is the horizontal move of an overview whose workspaces sit
446455 // side by side (cascade): a column inside the selected lane. Past the
447-// lane's first column it goes to the stash, else to the previous workspace;
448-// past the last column, to the next workspace. Covering floats fan along
449-// this axis and are traversed before the stash or a workspace: the card on
450-// the left is the one behind.
456+// lane's first column it goes to the previous workspace; past the last
457+// column, to the next workspace. From a stash card, left goes to the
458+// previous workspace and right back to the columns. Covering floats fan
459+// along this axis and are traversed before a workspace: the card on the
460+// left is the one behind.
451461 func (m *Monitor) overviewAcross(dx int) {
452462 w := m.Current()
453463 if m.cardAt(w) >= 0 {
@@ -459,15 +469,18 @@ func (m *Monitor) overviewAcross(dx int) {
459469 }
460470 m.clearCard()
461471 m.ov.selected = 0
462- if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {
463- // The first column of the focused lane.
472+ if len(w.Columns) > 0 {
473+ // The columns come to the front, even when down entered the
474+ // stash from a covering float: at the first column of the
475+ // focused lane.
476+ m.setFront(w, stackItem{kind: stackColumns})
464477 i, _ := w.laneBounds(w.laneOf(w.Focus))
465478 m.selectLaneColumn(w, i)
466479 }
467480 return
468481 }
469482 if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
470- m.laneEdge(w, dx)
483+ m.laneEdge(dx)
471484 return
472485 }
473486 first, last := w.laneBounds(w.laneOf(w.Focus))
@@ -475,20 +488,15 @@ func (m *Monitor) overviewAcross(dx int) {
475488 m.selectLaneColumn(w, next)
476489 return
477490 }
478- m.laneEdge(w, dx)
491+ m.laneEdge(dx)
479492 }
480493
481494 // laneEdge is a horizontal move off the edge of the selected lane, or off a
482-// card that is not the columns: the stack first, then the stash on the left
483-// or a workspace.
484-func (m *Monitor) laneEdge(w *Workspace, dx int) {
495+// card that is not the columns: the stack first, then a workspace.
496+func (m *Monitor) laneEdge(dx int) {
485497 if m.moveStack(-dx) {
486498 return
487499 }
488- if dx < 0 && len(w.Stash) > 0 {
489- m.selectCard(w, w.stashAt)
490- return
491- }
492500 m.overviewStep(dx)
493501 }
494502
@@ -628,8 +636,8 @@ func (m *Monitor) overviewAxis() layoutAxis { return m.Current().policy().worksp
628636 //
629637 // Vertical axis: full-width slots, the current one centred; the rule is
630638 // horizontal. Horizontal axis: full-height slots as wide as their content,
631-// stash pile and stack peeks included, the current one centred; the rule is
632-// vertical, in the spacing between two slots.
639+// stack peeks included (the stash pile sits over the row), the current one
640+// centred; the rule is vertical, in the spacing between two slots.
633641 func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
634642 cur := m.Current()
635643 u := cur.overviewArea()
@@ -637,13 +645,10 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
637645 axis := m.overviewAxis()
638646 var cs Rect
639647 if axis == horizontalAxis {
640- // The row stays centred on the area; the pile extends the slot to
641- // the left, pushing the previous workspace, unless it would leave
642- // the area.
643- pile, row := cur.pileWidth(), cur.rowWidth()
644- cs = Rect{X: max(u.X+(u.W-row)/2-pile, u.X), Y: u.Y, W: pile + row, H: u.H}
648+ row := cur.rowWidth(axis)
649+ cs = Rect{X: u.X + (u.W-row)/2, Y: u.Y, W: row, H: u.H}
645650 } else {
646- hc := cur.rowHeight()
651+ hc := cur.rowHeight(axis)
647652 cs = Rect{X: u.X, Y: u.Y + (u.H-hc)/2, W: u.W, H: hc}
648653 }
649654 rows := map[*Workspace]Rect{cur: cs}
@@ -673,7 +678,7 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
673678 // slot cs along axis, spacing away; gapAt is where the spacing begins.
674679 func neighborSlot(axis layoutAxis, u, cs Rect, w *Workspace, before bool, spacing int) (slot Rect, gapAt int) {
675680 if axis == horizontalAxis {
676- slot = Rect{Y: u.Y, W: w.slotWidth(), H: u.H}
681+ slot = Rect{Y: u.Y, W: w.rowWidth(axis), H: u.H}
677682 if before {
678683 gapAt = cs.X - spacing
679684 slot.X = gapAt - slot.W
@@ -683,7 +688,7 @@ func neighborSlot(axis layoutAxis, u, cs Rect, w *Workspace, before bool, spacin
683688 }
684689 return slot, gapAt
685690 }
686- h := w.rowHeight()
691+ h := w.rowHeight(axis)
687692 slot = Rect{X: u.X, W: u.W, H: h}
688693 if before {
689694 gapAt = cs.Y - spacing
@@ -718,7 +723,7 @@ func (m *Monitor) overviewLayout(result []Placement) []Placement {
718723 at := m.cardAt(w)
719724 result = append(result, m.stackRow(w, axis, slot, w != cur, at < 0)...)
720725 if len(w.Stash) > 0 {
721- result = append(result, w.pile(w.pileBand(axis, slot), w != cur, at)...)
726+ result = append(result, w.pile(axis, slot, w != cur, at)...)
722727 }
723728 }
724729 for _, f := range w.Floats {
@@ -802,8 +807,8 @@ func fan(cards [][]Placement, front, maxBehind, maxBefore int, circular bool,
802807 return out
803808 }
804809
805-// pileWidth is the room the stash pile of w takes on the left of its row,
806-// its margin included; 0 without a stash.
810+// pileWidth is the room the stash pile of w takes on the left of its row in
811+// a vertical overview, its margin included; 0 without a stash.
807812 func (w *Workspace) pileWidth() int {
808813 if len(w.Stash) == 0 {
809814 return 0
@@ -820,11 +825,11 @@ func (w *Workspace) cardStep() int {
820825 return min(overviewCardStep, a.W/100, a.H/50)
821826 }
822827
823-// pile places the stash of w as cards on the left of band (see pileBand):
824-// entry front (the stash selection when -1) in
828+// pile places the stash of w as cards in its slot along axis (see the
829+// overview's stash comment): entry front (the stash selection when -1) in
825830 // front, the next ones behind it, dimmed, the rest hidden. Only a front card
826831 // of the current row (dim false) with front set is selected.
827-func (w *Workspace) pile(band Rect, dim bool, front int) []Placement {
832+func (w *Workspace) pile(axis layoutAxis, slot Rect, dim bool, front int) []Placement {
828833 n := len(w.Stash)
829834 if n == 0 {
830835 return nil
@@ -838,10 +843,19 @@ func (w *Workspace) pile(band Rect, dim bool, front int) []Placement {
838843 u := w.overviewArea()
839844 cw := int(math.Round(float64(r.W) * overviewCardZoom))
840845 ch := int(math.Round(float64(r.H) * overviewCardZoom))
841- rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))
842- // Stack rows reserve two peek steps above the front card. Align the
843- // stash with that front band, not with the top of its envelope.
844- x0, y0 := band.X+u.W/100, band.Y+(rowH-ch)/2
846+ rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom(axis)))
847+ var x0, y0 int
848+ if axis == horizontalAxis {
849+ // Centred on the row, across the bottom edge of the selected
850+ // lane, which is centred in the slot.
851+ x0 = slot.X + (slot.W-cw)/2
852+ y0 = slot.Y + (slot.H-rowH)/2 + rowH - ch/2
853+ } else {
854+ // Stack rows reserve two peek steps above the front card. Align
855+ // the stash with that front band, not with the top of its envelope.
856+ band := w.frontSlot(axis, slot)
857+ x0, y0 = band.X+u.W/100, band.Y+(rowH-ch)/2
858+ }
845859 cards := make([][]Placement, n)
846860 for i := range w.Stash {
847861 cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: Rect{X: x0, Y: y0, W: cw, H: ch}, Preview: overviewCardZoom, Focused: selected}}
@@ -853,22 +867,26 @@ func (w *Workspace) pile(band Rect, dim bool, front int) []Placement {
853867 }
854868
855869 // overviewZoom is the scale that fits every column of w in the usable
856-// width, within the overview bounds.
857-func (w *Workspace) overviewZoom() float64 {
858- z, _ := w.zoomSpan()
870+// width, within the overview bounds, in an overview along axis: only a
871+// vertical one sets the stash pile beside the row.
872+func (w *Workspace) overviewZoom(axis layoutAxis) float64 {
873+ z, _ := w.zoomSpan(axis)
859874 return z
860875 }
861876
862877 // zoomSpan is overviewZoom with the unscaled span of the tiles it fits. A
863878 // cascade workspace spans one screen whatever its band count.
864-func (w *Workspace) zoomSpan() (float64, int) {
879+func (w *Workspace) zoomSpan(axis layoutAxis) (float64, int) {
865880 // Only the span is read: the tiles go in the zoom scratch, dead on return.
866881 var span int
867882 w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0])
868883 if span <= 0 {
869884 return overviewMaxZoom, span
870885 }
871- room := float64(w.overviewArea().W-w.pileWidth()) * 0.96
886+ room := float64(w.overviewArea().W) * 0.96
887+ if axis == verticalAxis {
888+ room = float64(w.overviewArea().W-w.pileWidth()) * 0.96
889+ }
872890 z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom)
873891 if w.stacked() {
874892 // Reserve two clear peeks on either side of the front row.
@@ -877,31 +895,25 @@ func (w *Workspace) zoomSpan() (float64, int) {
877895 return z, span
878896 }
879897
880-// slotWidth is the width of the slot of w in a horizontal overview: its
881-// stash pile and its row.
882-func (w *Workspace) slotWidth() int {
883- return w.pileWidth() + w.rowWidth()
884-}
885-
886-// rowWidth is the width of the row of w in a horizontal overview: what its
887-// tiles take (at most what the pile leaves of the area), plus the room for
888-// the covering floats peeking on either side of the front card.
889-func (w *Workspace) rowWidth() int {
890- z, span := w.zoomSpan()
898+// rowWidth is the width of the slot of w in a horizontal overview: what its
899+// tiles take (at most the area), plus the room for the covering floats
900+// peeking on either side of the front card.
901+func (w *Workspace) rowWidth(axis layoutAxis) int {
902+ z, span := w.zoomSpan(axis)
891903 if span <= 0 {
892904 // An empty workspace keeps the room of a screen.
893905 span = w.Usable.W
894906 }
895- width := min(int(math.Round(float64(span)*z)), w.overviewArea().W-w.pileWidth())
907+ width := min(int(math.Round(float64(span)*z)), w.overviewArea().W)
896908 if w.stacked() {
897- width += 4 * w.peekStep()
909+ width += 4 * w.peekStep(axis)
898910 }
899911 return width
900912 }
901913
902914 // peekStep is the shared step for a stack row and its front-card inset.
903-func (w *Workspace) peekStep() int {
904- return max(1, int(math.Round(float64(w.Usable.H)*w.overviewZoom()*overviewPeekStep)))
915+func (w *Workspace) peekStep(axis layoutAxis) int {
916+ return max(1, int(math.Round(float64(w.Usable.H)*w.overviewZoom(axis)*overviewPeekStep)))
905917 }
906918
907919 // frontSlot is the part of slot the front card of w takes: slot shifted
@@ -910,7 +922,7 @@ func (w *Workspace) peekStep() int {
910922 // the room of the two after it. A single-card row keeps slot.
911923 func (w *Workspace) frontSlot(axis layoutAxis, slot Rect) Rect {
912924 if w.stacked() {
913- p := w.peekStep()
925+ p := w.peekStep(axis)
914926 axis.offset(&slot, 2*p)
915927 if axis == horizontalAxis {
916928 slot.W -= 4 * p
@@ -919,24 +931,12 @@ func (w *Workspace) frontSlot(axis layoutAxis, slot Rect) Rect {
919931 return slot
920932 }
921933
922-// pileBand is the band the stash pile of w aligns with in its slot. A
923-// vertical overview aligns it with the front card (see frontSlot). A
924-// horizontal one puts it level with the selected lane, left of the whole
925-// stack of cards.
926-func (w *Workspace) pileBand(axis layoutAxis, slot Rect) Rect {
927- if axis == verticalAxis {
928- return w.frontSlot(axis, slot)
929- }
930- slot.Y += (slot.H - int(math.Round(float64(w.Usable.H)*w.overviewZoom()))) / 2
931- return slot
932-}
933-
934934 // rowHeight includes the largest possible stack envelope (two peeks above
935935 // and two below). Neighbor rows use the same measure as the current row.
936-func (w *Workspace) rowHeight() int {
937- h := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))
936+func (w *Workspace) rowHeight(axis layoutAxis) int {
937+ h := int(math.Round(float64(w.Usable.H) * w.overviewZoom(axis)))
938938 if w.stacked() {
939- h += 4 * w.peekStep()
939+ h += 4 * w.peekStep(axis)
940940 }
941941 return h
942942 }
@@ -1043,7 +1043,7 @@ func (w *Workspace) previewRow(axis layoutAxis, slot Rect, dim, lit bool) []Plac
10431043 // the overview axis shows only its selected lane. In a horizontal overview,
10441044 // tiles entirely outside the area are hidden.
10451045 func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes bool, tiles []Placement, span int, sel Rect) []Placement {
1046- z := w.overviewZoom()
1046+ z := w.overviewZoom(axis)
10471047 scale := func(v int) int { return int(math.Round(float64(v) * z)) }
10481048 width := scale(span)
10491049 u := w.overviewArea()
@@ -1051,9 +1051,13 @@ func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes
10511051 if axis == horizontalAxis {
10521052 top = slot.Y + (slot.H-scale(w.Usable.H))/2
10531053 }
1054- // Rows stay centred on the output like rows without a stash; the pile
1055- // only pushes a row right when they would overlap.
1056- left := w.pileWidth()
1054+ // Rows stay centred on the output like rows without a stash; in a
1055+ // vertical overview the pile only pushes a row right when they would
1056+ // overlap. A horizontal one draws the pile over the row.
1057+ left := 0
1058+ if axis == verticalAxis {
1059+ left = w.pileWidth()
1060+ }
10571061 x := max(slot.X+(slot.W-width)/2, slot.X+left)
10581062 ux, uw := slot.X+left, slot.W-left
10591063 if width > uw {
@@ -53,10 +53,12 @@ type rowSnapshot struct {
53 // workspace. A pinned fullscreen window is the front card of its row, the53 // workspace. A pinned fullscreen window is the front card of its row, the
54 // windows it hides behind it; choosing one of them leaves fullscreen.54 // windows it hides behind it; choosing one of them leaves fullscreen.
55 55
56-// A workspace's stash shows as a pile of cards pinned to the left edge of56+// A workspace's stash shows as a pile of cards: the selected stashed window
57-// its row: the selected stashed window in front at overviewCardZoom, up to57+// in front at overviewCardZoom, up to overviewCards-1 others behind it, each
58-// overviewCards-1 others behind it, each overviewCardStep further up and58+// overviewCardStep further up and right, dimmed. A vertical overview pins
59-// right, dimmed. The row takes the width the pile leaves.59+// the pile to the left edge of its row, which takes the width the pile
60+// leaves. A horizontal one centres it on its row, straddling the bottom edge
61+// of the selected lane, over the tiles.
60 const (62 const (
61 overviewCardZoom = 0.263 overviewCardZoom = 0.2
62 overviewCards = 464 overviewCards = 4
@@ -423,15 +425,22 @@ func (m *Monitor) cycleCard(w *Workspace, d int) bool {
423 // overviewLane is the vertical move of an overview whose workspaces sit side425 // overviewLane is the vertical move of an overview whose workspaces sit side
424 // by side (cascade): a selected stash card cycles the pile; else the426 // by side (cascade): a selected stash card cycles the pile; else the
425 // selection goes to the previous or next lane (band), at the closest column,427 // selection goes to the previous or next lane (band), at the closest column,
426-// stopping at the first and last. Covering floats stack along the horizontal428+// stopping at the first. Down past the last lane, or from a card that is not
427-// axis, not this one.429+// the columns, enters the stash, which sits below. Covering floats stack
430+// along the horizontal axis, not this one.
428 func (m *Monitor) overviewLane(dy int) {431 func (m *Monitor) overviewLane(dy int) {
429 w := m.Current()432 w := m.Current()
430- if m.cycleCard(w, dy) || m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {433+ if m.cycleCard(w, dy) {
431 return434 return
432 }435 }
433- if i := w.laneNeighbor(dy); i >= 0 {436+ if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {
434- m.selectLaneColumn(w, i)437+ if i := w.laneNeighbor(dy); i >= 0 {
438+ m.selectLaneColumn(w, i)
439+ return
440+ }
441+ }
442+ if dy > 0 && len(w.Stash) > 0 {
443+ m.selectCard(w, w.stashAt)
435 }444 }
436 }445 }
437 446
@@ -444,10 +453,11 @@ func (m *Monitor) selectLaneColumn(w *Workspace, i int) {
444 453
445 // overviewAcross is the horizontal move of an overview whose workspaces sit454 // overviewAcross is the horizontal move of an overview whose workspaces sit
446 // side by side (cascade): a column inside the selected lane. Past the455 // side by side (cascade): a column inside the selected lane. Past the
447-// lane's first column it goes to the stash, else to the previous workspace;456+// lane's first column it goes to the previous workspace; past the last
448-// past the last column, to the next workspace. Covering floats fan along457+// column, to the next workspace. From a stash card, left goes to the
449-// this axis and are traversed before the stash or a workspace: the card on458+// previous workspace and right back to the columns. Covering floats fan
450-// the left is the one behind.459+// along this axis and are traversed before a workspace: the card on the
460+// left is the one behind.
451 func (m *Monitor) overviewAcross(dx int) {461 func (m *Monitor) overviewAcross(dx int) {
452 w := m.Current()462 w := m.Current()
453 if m.cardAt(w) >= 0 {463 if m.cardAt(w) >= 0 {
@@ -459,15 +469,18 @@ func (m *Monitor) overviewAcross(dx int) {
459 }469 }
460 m.clearCard()470 m.clearCard()
461 m.ov.selected = 0471 m.ov.selected = 0
462- if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {472+ if len(w.Columns) > 0 {
463- // The first column of the focused lane.473+ // The columns come to the front, even when down entered the
474+ // stash from a covering float: at the first column of the
475+ // focused lane.
476+ m.setFront(w, stackItem{kind: stackColumns})
464 i, _ := w.laneBounds(w.laneOf(w.Focus))477 i, _ := w.laneBounds(w.laneOf(w.Focus))
465 m.selectLaneColumn(w, i)478 m.selectLaneColumn(w, i)
466 }479 }
467 return480 return
468 }481 }
469 if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {482 if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
470- m.laneEdge(w, dx)483+ m.laneEdge(dx)
471 return484 return
472 }485 }
473 first, last := w.laneBounds(w.laneOf(w.Focus))486 first, last := w.laneBounds(w.laneOf(w.Focus))
@@ -475,20 +488,15 @@ func (m *Monitor) overviewAcross(dx int) {
475 m.selectLaneColumn(w, next)488 m.selectLaneColumn(w, next)
476 return489 return
477 }490 }
478- m.laneEdge(w, dx)491+ m.laneEdge(dx)
479 }492 }
480 493
481 // laneEdge is a horizontal move off the edge of the selected lane, or off a494 // laneEdge is a horizontal move off the edge of the selected lane, or off a
482-// card that is not the columns: the stack first, then the stash on the left495+// card that is not the columns: the stack first, then a workspace.
483-// or a workspace.496+func (m *Monitor) laneEdge(dx int) {
484-func (m *Monitor) laneEdge(w *Workspace, dx int) {
485 if m.moveStack(-dx) {497 if m.moveStack(-dx) {
486 return498 return
487 }499 }
488- if dx < 0 && len(w.Stash) > 0 {
489- m.selectCard(w, w.stashAt)
490- return
491- }
492 m.overviewStep(dx)500 m.overviewStep(dx)
493 }501 }
494 502
@@ -628,8 +636,8 @@ func (m *Monitor) overviewAxis() layoutAxis { return m.Current().policy().worksp
628 //636 //
629 // Vertical axis: full-width slots, the current one centred; the rule is637 // Vertical axis: full-width slots, the current one centred; the rule is
630 // horizontal. Horizontal axis: full-height slots as wide as their content,638 // horizontal. Horizontal axis: full-height slots as wide as their content,
631-// stash pile and stack peeks included, the current one centred; the rule is639+// stack peeks included (the stash pile sits over the row), the current one
632-// vertical, in the spacing between two slots.640+// centred; the rule is vertical, in the spacing between two slots.
633 func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {641 func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
634 cur := m.Current()642 cur := m.Current()
635 u := cur.overviewArea()643 u := cur.overviewArea()
@@ -637,13 +645,10 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
637 axis := m.overviewAxis()645 axis := m.overviewAxis()
638 var cs Rect646 var cs Rect
639 if axis == horizontalAxis {647 if axis == horizontalAxis {
640- // The row stays centred on the area; the pile extends the slot to648+ row := cur.rowWidth(axis)
641- // the left, pushing the previous workspace, unless it would leave649+ cs = Rect{X: u.X + (u.W-row)/2, Y: u.Y, W: row, H: u.H}
642- // the area.
643- pile, row := cur.pileWidth(), cur.rowWidth()
644- cs = Rect{X: max(u.X+(u.W-row)/2-pile, u.X), Y: u.Y, W: pile + row, H: u.H}
645 } else {650 } else {
646- hc := cur.rowHeight()651+ hc := cur.rowHeight(axis)
647 cs = Rect{X: u.X, Y: u.Y + (u.H-hc)/2, W: u.W, H: hc}652 cs = Rect{X: u.X, Y: u.Y + (u.H-hc)/2, W: u.W, H: hc}
648 }653 }
649 rows := map[*Workspace]Rect{cur: cs}654 rows := map[*Workspace]Rect{cur: cs}
@@ -673,7 +678,7 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
673 // slot cs along axis, spacing away; gapAt is where the spacing begins.678 // slot cs along axis, spacing away; gapAt is where the spacing begins.
674 func neighborSlot(axis layoutAxis, u, cs Rect, w *Workspace, before bool, spacing int) (slot Rect, gapAt int) {679 func neighborSlot(axis layoutAxis, u, cs Rect, w *Workspace, before bool, spacing int) (slot Rect, gapAt int) {
675 if axis == horizontalAxis {680 if axis == horizontalAxis {
676- slot = Rect{Y: u.Y, W: w.slotWidth(), H: u.H}681+ slot = Rect{Y: u.Y, W: w.rowWidth(axis), H: u.H}
677 if before {682 if before {
678 gapAt = cs.X - spacing683 gapAt = cs.X - spacing
679 slot.X = gapAt - slot.W684 slot.X = gapAt - slot.W
@@ -683,7 +688,7 @@ func neighborSlot(axis layoutAxis, u, cs Rect, w *Workspace, before bool, spacin
683 }688 }
684 return slot, gapAt689 return slot, gapAt
685 }690 }
686- h := w.rowHeight()691+ h := w.rowHeight(axis)
687 slot = Rect{X: u.X, W: u.W, H: h}692 slot = Rect{X: u.X, W: u.W, H: h}
688 if before {693 if before {
689 gapAt = cs.Y - spacing694 gapAt = cs.Y - spacing
@@ -718,7 +723,7 @@ func (m *Monitor) overviewLayout(result []Placement) []Placement {
718 at := m.cardAt(w)723 at := m.cardAt(w)
719 result = append(result, m.stackRow(w, axis, slot, w != cur, at < 0)...)724 result = append(result, m.stackRow(w, axis, slot, w != cur, at < 0)...)
720 if len(w.Stash) > 0 {725 if len(w.Stash) > 0 {
721- result = append(result, w.pile(w.pileBand(axis, slot), w != cur, at)...)726+ result = append(result, w.pile(axis, slot, w != cur, at)...)
722 }727 }
723 }728 }
724 for _, f := range w.Floats {729 for _, f := range w.Floats {
@@ -802,8 +807,8 @@ func fan(cards [][]Placement, front, maxBehind, maxBefore int, circular bool,
802 return out807 return out
803 }808 }
804 809
805-// pileWidth is the room the stash pile of w takes on the left of its row,810+// pileWidth is the room the stash pile of w takes on the left of its row in
806-// its margin included; 0 without a stash.811+// a vertical overview, its margin included; 0 without a stash.
807 func (w *Workspace) pileWidth() int {812 func (w *Workspace) pileWidth() int {
808 if len(w.Stash) == 0 {813 if len(w.Stash) == 0 {
809 return 0814 return 0
@@ -820,11 +825,11 @@ func (w *Workspace) cardStep() int {
820 return min(overviewCardStep, a.W/100, a.H/50)825 return min(overviewCardStep, a.W/100, a.H/50)
821 }826 }
822 827
823-// pile places the stash of w as cards on the left of band (see pileBand):828+// pile places the stash of w as cards in its slot along axis (see the
824-// entry front (the stash selection when -1) in829+// overview's stash comment): entry front (the stash selection when -1) in
825 // front, the next ones behind it, dimmed, the rest hidden. Only a front card830 // front, the next ones behind it, dimmed, the rest hidden. Only a front card
826 // of the current row (dim false) with front set is selected.831 // of the current row (dim false) with front set is selected.
827-func (w *Workspace) pile(band Rect, dim bool, front int) []Placement {832+func (w *Workspace) pile(axis layoutAxis, slot Rect, dim bool, front int) []Placement {
828 n := len(w.Stash)833 n := len(w.Stash)
829 if n == 0 {834 if n == 0 {
830 return nil835 return nil
@@ -838,10 +843,19 @@ func (w *Workspace) pile(band Rect, dim bool, front int) []Placement {
838 u := w.overviewArea()843 u := w.overviewArea()
839 cw := int(math.Round(float64(r.W) * overviewCardZoom))844 cw := int(math.Round(float64(r.W) * overviewCardZoom))
840 ch := int(math.Round(float64(r.H) * overviewCardZoom))845 ch := int(math.Round(float64(r.H) * overviewCardZoom))
841- rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))846+ rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom(axis)))
842- // Stack rows reserve two peek steps above the front card. Align the847+ var x0, y0 int
843- // stash with that front band, not with the top of its envelope.848+ if axis == horizontalAxis {
844- x0, y0 := band.X+u.W/100, band.Y+(rowH-ch)/2849+ // Centred on the row, across the bottom edge of the selected
850+ // lane, which is centred in the slot.
851+ x0 = slot.X + (slot.W-cw)/2
852+ y0 = slot.Y + (slot.H-rowH)/2 + rowH - ch/2
853+ } else {
854+ // Stack rows reserve two peek steps above the front card. Align
855+ // the stash with that front band, not with the top of its envelope.
856+ band := w.frontSlot(axis, slot)
857+ x0, y0 = band.X+u.W/100, band.Y+(rowH-ch)/2
858+ }
845 cards := make([][]Placement, n)859 cards := make([][]Placement, n)
846 for i := range w.Stash {860 for i := range w.Stash {
847 cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: Rect{X: x0, Y: y0, W: cw, H: ch}, Preview: overviewCardZoom, Focused: selected}}861 cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: Rect{X: x0, Y: y0, W: cw, H: ch}, Preview: overviewCardZoom, Focused: selected}}
@@ -853,22 +867,26 @@ func (w *Workspace) pile(band Rect, dim bool, front int) []Placement {
853 }867 }
854 868
855 // overviewZoom is the scale that fits every column of w in the usable869 // overviewZoom is the scale that fits every column of w in the usable
856-// width, within the overview bounds.870+// width, within the overview bounds, in an overview along axis: only a
857-func (w *Workspace) overviewZoom() float64 {871+// vertical one sets the stash pile beside the row.
858- z, _ := w.zoomSpan()872+func (w *Workspace) overviewZoom(axis layoutAxis) float64 {
873+ z, _ := w.zoomSpan(axis)
859 return z874 return z
860 }875 }
861 876
862 // zoomSpan is overviewZoom with the unscaled span of the tiles it fits. A877 // zoomSpan is overviewZoom with the unscaled span of the tiles it fits. A
863 // cascade workspace spans one screen whatever its band count.878 // cascade workspace spans one screen whatever its band count.
864-func (w *Workspace) zoomSpan() (float64, int) {879+func (w *Workspace) zoomSpan(axis layoutAxis) (float64, int) {
865 // Only the span is read: the tiles go in the zoom scratch, dead on return.880 // Only the span is read: the tiles go in the zoom scratch, dead on return.
866 var span int881 var span int
867 w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0])882 w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0])
868 if span <= 0 {883 if span <= 0 {
869 return overviewMaxZoom, span884 return overviewMaxZoom, span
870 }885 }
871- room := float64(w.overviewArea().W-w.pileWidth()) * 0.96886+ room := float64(w.overviewArea().W) * 0.96
887+ if axis == verticalAxis {
888+ room = float64(w.overviewArea().W-w.pileWidth()) * 0.96
889+ }
872 z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom)890 z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom)
873 if w.stacked() {891 if w.stacked() {
874 // Reserve two clear peeks on either side of the front row.892 // Reserve two clear peeks on either side of the front row.
@@ -877,31 +895,25 @@ func (w *Workspace) zoomSpan() (float64, int) {
877 return z, span895 return z, span
878 }896 }
879 897
880-// slotWidth is the width of the slot of w in a horizontal overview: its898+// rowWidth is the width of the slot of w in a horizontal overview: what its
881-// stash pile and its row.899+// tiles take (at most the area), plus the room for the covering floats
882-func (w *Workspace) slotWidth() int {900+// peeking on either side of the front card.
883- return w.pileWidth() + w.rowWidth()901+func (w *Workspace) rowWidth(axis layoutAxis) int {
884-}902+ z, span := w.zoomSpan(axis)
885-
886-// rowWidth is the width of the row of w in a horizontal overview: what its
887-// tiles take (at most what the pile leaves of the area), plus the room for
888-// the covering floats peeking on either side of the front card.
889-func (w *Workspace) rowWidth() int {
890- z, span := w.zoomSpan()
891 if span <= 0 {903 if span <= 0 {
892 // An empty workspace keeps the room of a screen.904 // An empty workspace keeps the room of a screen.
893 span = w.Usable.W905 span = w.Usable.W
894 }906 }
895- width := min(int(math.Round(float64(span)*z)), w.overviewArea().W-w.pileWidth())907+ width := min(int(math.Round(float64(span)*z)), w.overviewArea().W)
896 if w.stacked() {908 if w.stacked() {
897- width += 4 * w.peekStep()909+ width += 4 * w.peekStep(axis)
898 }910 }
899 return width911 return width
900 }912 }
901 913
902 // peekStep is the shared step for a stack row and its front-card inset.914 // peekStep is the shared step for a stack row and its front-card inset.
903-func (w *Workspace) peekStep() int {915+func (w *Workspace) peekStep(axis layoutAxis) int {
904- return max(1, int(math.Round(float64(w.Usable.H)*w.overviewZoom()*overviewPeekStep)))916+ return max(1, int(math.Round(float64(w.Usable.H)*w.overviewZoom(axis)*overviewPeekStep)))
905 }917 }
906 918
907 // frontSlot is the part of slot the front card of w takes: slot shifted919 // frontSlot is the part of slot the front card of w takes: slot shifted
@@ -910,7 +922,7 @@ func (w *Workspace) peekStep() int {
910 // the room of the two after it. A single-card row keeps slot.922 // the room of the two after it. A single-card row keeps slot.
911 func (w *Workspace) frontSlot(axis layoutAxis, slot Rect) Rect {923 func (w *Workspace) frontSlot(axis layoutAxis, slot Rect) Rect {
912 if w.stacked() {924 if w.stacked() {
913- p := w.peekStep()925+ p := w.peekStep(axis)
914 axis.offset(&slot, 2*p)926 axis.offset(&slot, 2*p)
915 if axis == horizontalAxis {927 if axis == horizontalAxis {
916 slot.W -= 4 * p928 slot.W -= 4 * p
@@ -919,24 +931,12 @@ func (w *Workspace) frontSlot(axis layoutAxis, slot Rect) Rect {
919 return slot931 return slot
920 }932 }
921 933
922-// pileBand is the band the stash pile of w aligns with in its slot. A
923-// vertical overview aligns it with the front card (see frontSlot). A
924-// horizontal one puts it level with the selected lane, left of the whole
925-// stack of cards.
926-func (w *Workspace) pileBand(axis layoutAxis, slot Rect) Rect {
927- if axis == verticalAxis {
928- return w.frontSlot(axis, slot)
929- }
930- slot.Y += (slot.H - int(math.Round(float64(w.Usable.H)*w.overviewZoom()))) / 2
931- return slot
932-}
933-
934 // rowHeight includes the largest possible stack envelope (two peeks above934 // rowHeight includes the largest possible stack envelope (two peeks above
935 // and two below). Neighbor rows use the same measure as the current row.935 // and two below). Neighbor rows use the same measure as the current row.
936-func (w *Workspace) rowHeight() int {936+func (w *Workspace) rowHeight(axis layoutAxis) int {
937- h := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))937+ h := int(math.Round(float64(w.Usable.H) * w.overviewZoom(axis)))
938 if w.stacked() {938 if w.stacked() {
939- h += 4 * w.peekStep()939+ h += 4 * w.peekStep(axis)
940 }940 }
941 return h941 return h
942 }942 }
@@ -1043,7 +1043,7 @@ func (w *Workspace) previewRow(axis layoutAxis, slot Rect, dim, lit bool) []Plac
1043 // the overview axis shows only its selected lane. In a horizontal overview,1043 // the overview axis shows only its selected lane. In a horizontal overview,
1044 // tiles entirely outside the area are hidden.1044 // tiles entirely outside the area are hidden.
1045 func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes bool, tiles []Placement, span int, sel Rect) []Placement {1045 func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes bool, tiles []Placement, span int, sel Rect) []Placement {
1046- z := w.overviewZoom()1046+ z := w.overviewZoom(axis)
1047 scale := func(v int) int { return int(math.Round(float64(v) * z)) }1047 scale := func(v int) int { return int(math.Round(float64(v) * z)) }
1048 width := scale(span)1048 width := scale(span)
1049 u := w.overviewArea()1049 u := w.overviewArea()
@@ -1051,9 +1051,13 @@ func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes
1051 if axis == horizontalAxis {1051 if axis == horizontalAxis {
1052 top = slot.Y + (slot.H-scale(w.Usable.H))/21052 top = slot.Y + (slot.H-scale(w.Usable.H))/2
1053 }1053 }
1054- // Rows stay centred on the output like rows without a stash; the pile1054+ // Rows stay centred on the output like rows without a stash; in a
1055- // only pushes a row right when they would overlap.1055+ // vertical overview the pile only pushes a row right when they would
1056- left := w.pileWidth()1056+ // overlap. A horizontal one draws the pile over the row.
1057+ left := 0
1058+ if axis == verticalAxis {
1059+ left = w.pileWidth()
1060+ }
1057 x := max(slot.X+(slot.W-width)/2, slot.X+left)1061 x := max(slot.X+(slot.W-width)/2, slot.X+left)
1058 ux, uw := slot.X+left, slot.W-left1062 ux, uw := slot.X+left, slot.W-left
1059 if width > uw {1063 if width > uw {
M internal/core/overview_behavior_test.go +5 -4
@@ -50,12 +50,13 @@ func TestOverviewPeekEnvelopeAndSingleCardHorizontal(t *testing.T) {
5050 front := previewOf(t, m.Layout(), 7)
5151 back := previewOf(t, m.Layout(), 8)
5252 step := float64(front.Rect.Y - back.Rect.Y)
53- if step < float64(w.Usable.H)*w.overviewZoom()*0.06 {
53+ if step < float64(w.Usable.H)*w.overviewZoom(verticalAxis)*0.06 {
5454 t.Fatalf("peek step %.1f", step)
5555 }
56- y := w.Usable.Y + (w.Usable.H-w.rowHeight())/2
57- if back.Rect.Y < y || front.Rect.Y+front.Rect.H > y+w.rowHeight() {
58- t.Fatalf("outside row front %+v back %+v row %d..%d", front, back, y, y+w.rowHeight())
56+ h := w.rowHeight(verticalAxis)
57+ y := w.Usable.Y + (w.Usable.H-h)/2
58+ if back.Rect.Y < y || front.Rect.Y+front.Rect.H > y+h {
59+ t.Fatalf("outside row front %+v back %+v row %d..%d", front, back, y, y+h)
5960 }
6061 m.OverviewMove(1, 0)
6162 if m.stackFront(w) != (stackItem{stackFloat, 7}) {
@@ -50,12 +50,13 @@ func TestOverviewPeekEnvelopeAndSingleCardHorizontal(t *testing.T) {
50 front := previewOf(t, m.Layout(), 7)50 front := previewOf(t, m.Layout(), 7)
51 back := previewOf(t, m.Layout(), 8)51 back := previewOf(t, m.Layout(), 8)
52 step := float64(front.Rect.Y - back.Rect.Y)52 step := float64(front.Rect.Y - back.Rect.Y)
53- if step < float64(w.Usable.H)*w.overviewZoom()*0.06 {53+ if step < float64(w.Usable.H)*w.overviewZoom(verticalAxis)*0.06 {
54 t.Fatalf("peek step %.1f", step)54 t.Fatalf("peek step %.1f", step)
55 }55 }
56- y := w.Usable.Y + (w.Usable.H-w.rowHeight())/256+ h := w.rowHeight(verticalAxis)
57- if back.Rect.Y < y || front.Rect.Y+front.Rect.H > y+w.rowHeight() {57+ y := w.Usable.Y + (w.Usable.H-h)/2
58- t.Fatalf("outside row front %+v back %+v row %d..%d", front, back, y, y+w.rowHeight())58+ if back.Rect.Y < y || front.Rect.Y+front.Rect.H > y+h {
59+ t.Fatalf("outside row front %+v back %+v row %d..%d", front, back, y, y+h)
59 }60 }
60 m.OverviewMove(1, 0)61 m.OverviewMove(1, 0)
61 if m.stackFront(w) != (stackItem{stackFloat, 7}) {62 if m.stackFront(w) != (stackItem{stackFloat, 7}) {
M internal/core/overview_cascade_test.go +124 -36
@@ -2,6 +2,7 @@ package core
22
33 import (
44 "fmt"
5+ "math"
56 "slices"
67 "testing"
78
@@ -70,7 +71,7 @@ func hiddenOff(p Placement) bool { return p.Hidden && p.Preview == 0 && !p.Focus
7071
7172 // The zoom does not depend on the number of bands.
7273 func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) {
73- if one, three := cascadeWorkspace(3).overviewZoom(), cascadeWorkspace(7).overviewZoom(); one != three || one != overviewMaxZoom {
74+ if one, three := cascadeWorkspace(3).overviewZoom(horizontalAxis), cascadeWorkspace(7).overviewZoom(horizontalAxis); one != three || one != overviewMaxZoom {
7475 t.Fatalf("zoom with one band %v, with three %v", one, three)
7576 }
7677 }
@@ -216,31 +217,40 @@ func TestOverviewCascadeNeighborBelowScrollShowsOneBand(t *testing.T) {
216217 }
217218 }
218219
219-// The stash pile is inside the current slot, left of the band; the
220-// previous workspace stays clear of it.
220+// The stash pile is centred on the current row, straddling the bottom edge
221+// of the selected lane, over the tiles; it takes no room in the slot.
221222 func TestOverviewCascadeStashInSlot(t *testing.T) {
222223 m := cascadeOverview()
224+ m.ToggleOverview()
225+ bare, _ := m.overviewRows()
226+ m.CancelOverview()
223227 withStash(m, 2)
224228 m.ToggleOverview()
225229 rows, _ := m.overviewRows()
226- cur, prev := rows[m.Current()], rows[m.Workspaces[0]]
230+ w := m.Current()
231+ cur, prev := rows[w], rows[m.Workspaces[0]]
232+ if cur != bare[w] {
233+ t.Fatalf("the pile changed the slot: %v, without it %v", cur, bare[w])
234+ }
227235 ps := m.Layout()
228- left := cur.X + cur.W
229- for _, id := range []WindowID{7, 8, 9} {
230- left = min(left, previewOf(t, ps, id).Rect.X)
236+ lane := previewOf(t, ps, 8).Rect
237+ // 41 is the front card of the pile, 40 behind it.
238+ front := previewOf(t, ps, 41)
239+ if front.Hidden || front.Peek || front.Preview != overviewCardZoom {
240+ t.Fatalf("front card: %+v", front)
231241 }
232- for _, id := range []WindowID{40, 41} {
233- p := previewOf(t, ps, id)
234- if p.Hidden || p.Preview != overviewCardZoom {
235- t.Fatalf("pile card %d: %+v", id, p)
236- }
237- if p.Rect.X < cur.X || p.Rect.X+p.Rect.W > left {
238- t.Fatalf("pile card %d not inside the slot, left of the band (%d..%d): %+v", id, cur.X, left, p.Rect)
239- }
240- // 41 is the front card of the pile.
241- if id == 41 && !within1(centreY(p.Rect), centreY(previewOf(t, ps, 8).Rect)) {
242- t.Fatalf("pile not level with the selected lane: %+v", p.Rect)
243- }
242+ if back := previewOf(t, ps, 40); back.Hidden || !back.Peek {
243+ t.Fatalf("card behind: %+v", back)
244+ }
245+ if !within1(front.Rect.X+front.Rect.W/2, cur.X+cur.W/2) {
246+ t.Fatalf("pile not centred on the row %v: %+v", cur, front.Rect)
247+ }
248+ // The lane is centred in the slot; its tiles stop a gap short of its
249+ // bottom edge.
250+ rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom(horizontalAxis)))
251+ bottom := cur.Y + (cur.H-rowH)/2 + rowH
252+ if !within1(centreY(front.Rect), bottom) || front.Rect.Y >= lane.Y+lane.H {
253+ t.Fatalf("pile not across the lane's bottom edge %d (lane %v): %+v", bottom, lane, front.Rect)
244254 }
245255 // The pile does not shift the band: its row stays centred on the area.
246256 u := m.Current().overviewArea()
@@ -248,11 +258,6 @@ func TestOverviewCascadeStashInSlot(t *testing.T) {
248258 if mid := (lo.X + hi.X + hi.W) / 2; !within1(mid, u.X+u.W/2) {
249259 t.Fatalf("band centre %d with a stash, area centre %d", mid, u.X+u.W/2)
250260 }
251- for _, id := range []WindowID{1, 2, 3} {
252- if p := previewOf(t, ps, id); !p.Hidden && p.Rect.X+p.Rect.W > cur.X {
253- t.Fatalf("previous workspace overlaps the slot with the pile: %+v", p.Rect)
254- }
255- }
256261 if prev.X+prev.W > cur.X {
257262 t.Fatalf("slots overlap: %v %v", prev, cur)
258263 }
@@ -334,21 +339,32 @@ func TestOverviewCascadeKeys(t *testing.T) {
334339 }
335340 m.CancelOverview()
336341
337- // With a stash: left from the first column enters it, right
338- // comes back to the band's first column, left twice goes to
339- // the previous workspace.
342+ // With a stash: left from the first column still goes to the
343+ // previous workspace. Down past the last band enters the
344+ // stash, right comes back to the band's first column, and
345+ // left from the stash goes to the previous workspace.
340346 withStash(m, 2)
341347 w.FocusID(7)
342348 m.ToggleOverview()
343349 d.move(m, -1, 0)
350+ if m.Current() != prev {
351+ t.Fatal("left from the first column with a stash did not reach the previous workspace")
352+ }
353+ m.CancelOverview()
354+ m.ToggleOverview()
355+ d.move(m, 0, 1)
356+ if m.cardAt(w) >= 0 || sel() != 10 {
357+ t.Fatalf("down to the last band: card %d, column %d", m.cardAt(w), sel())
358+ }
359+ d.move(m, 0, 1)
344360 if m.cardAt(w) < 0 {
345- t.Fatal("left from the first column did not select the stash")
361+ t.Fatal("down past the last band did not select the stash")
346362 }
347363 d.move(m, 1, 0)
348- if m.cardAt(w) >= 0 || sel() != 7 {
364+ if m.cardAt(w) >= 0 || sel() != 10 {
349365 t.Fatalf("right from the stash: card %d, column %d", m.cardAt(w), sel())
350366 }
351- d.move(m, -1, 0)
367+ d.move(m, 0, 1)
352368 d.move(m, -1, 0)
353369 if m.Current() != prev {
354370 t.Fatal("left from the stash did not reach the previous workspace")
@@ -369,9 +385,9 @@ func TestOverviewCascadeStashPile(t *testing.T) {
369385 m := cascadeOverview()
370386 withStash(m, 3)
371387 w := m.Current()
372- w.FocusID(7)
388+ w.FocusID(10)
373389 m.ToggleOverview()
374- d.move(m, -1, 0)
390+ d.move(m, 0, 1)
375391 if m.cardAt(w) != w.stashAt {
376392 t.Fatalf("entered at %d", m.cardAt(w))
377393 }
@@ -467,6 +483,23 @@ func TestOverviewCascadeThreeFinger(t *testing.T) {
467483 }
468484 }
469485
486+// Three fingers down past the last band enter the stash below it.
487+func TestOverviewCascadeThreeFingerDownEntersStash(t *testing.T) {
488+ c, m := cascadeCore(t)
489+ withStash(m, 2)
490+ w := m.Current()
491+ w.FocusID(6)
492+ m.ToggleOverview()
493+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
494+ for range 4 {
495+ c.swipeUpdate(ports.SwipeUpdate{DY: 40})
496+ }
497+ c.swipeEnd(ports.SwipeEnd{})
498+ if m.Current() != w || m.cardAt(w) != w.stashAt {
499+ t.Fatalf("swipe down from the last band: card %d, want %d", m.cardAt(w), w.stashAt)
500+ }
501+}
502+
470503 // Right from a stash card lands on the maximized column, which keeps its
471504 // maximization: it is alone in its band.
472505 func TestOverviewCascadeStashToMaximized(t *testing.T) {
@@ -505,6 +538,61 @@ func TestOverviewCascadeNamedDivider(t *testing.T) {
505538 }
506539 }
507540
541+// A stash takes no room in a cascade overview, on a neighbor either: a
542+// wide scroll workspace beside it keeps its zoom and slot.
543+func TestOverviewCascadeNeighborStashKeepsSlot(t *testing.T) {
544+ m := cascadeOverview()
545+ prev := m.Workspaces[0]
546+ m.Focus(0)
547+ for id := WindowID(60); id < 64; id++ {
548+ m.AddWindow(id)
549+ }
550+ m.Focus(1)
551+ m.ToggleOverview()
552+ bare, _ := m.overviewRows()
553+ zoom := prev.overviewZoom(horizontalAxis)
554+ m.CancelOverview()
555+ if zoom <= overviewMinZoom || zoom >= overviewMaxZoom {
556+ t.Fatalf("setup: neighbor zoom %v is at a bound", zoom)
557+ }
558+ // The same columns, with one more window stashed.
559+ m.Focus(0)
560+ m.AddWindow(64)
561+ prev.FocusID(64)
562+ prev.ToggleWindowStash()
563+ m.Focus(1)
564+ m.ToggleOverview()
565+ rows, _ := m.overviewRows()
566+ if rows[prev] != bare[prev] || prev.overviewZoom(horizontalAxis) != zoom {
567+ t.Fatalf("neighbor slot %v zoom %v with a stash, %v %v without", rows[prev], prev.overviewZoom(horizontalAxis), bare[prev], zoom)
568+ }
569+ if p := previewOf(t, m.Layout(), 64); p.Hidden || !p.Peek {
570+ t.Fatalf("neighbor pile card: %+v", p)
571+ }
572+}
573+
574+// Down from a covering float enters the stash; right brings the columns
575+// back to the front.
576+func TestOverviewCascadeFloatStashRight(t *testing.T) {
577+ m := cascadeOverview()
578+ withStash(m, 1)
579+ w := m.Current()
580+ w.AddFloating(90, 300, 200)
581+ w.FocusID(90)
582+ m.ToggleOverview()
583+ if f := m.stackFront(w); f.kind != stackFloat {
584+ t.Fatalf("setup: front %+v", f)
585+ }
586+ m.OverviewMove(0, 1)
587+ if m.cardAt(w) < 0 {
588+ t.Fatal("down from the float did not select the stash")
589+ }
590+ m.OverviewMove(1, 0)
591+ if m.cardAt(w) >= 0 || m.stackFront(w).kind != stackColumns || m.overviewTarget() == 0 {
592+ t.Fatalf("right from the stash: card %d, front %+v, target %d", m.cardAt(w), m.stackFront(w), m.overviewTarget())
593+ }
594+}
595+
508596 // Covering floats fan along the horizontal axis: left goes deeper in the
509597 // stack (the cards behind show on the left), right comes back to the front.
510598 func TestOverviewCascadeCoveringFloat(t *testing.T) {
@@ -553,8 +641,8 @@ func TestOverviewCascadeCoveringFloat(t *testing.T) {
553641 // The cards behind fan to the left: coming to the front, the
554642 // columns move right by one peek step.
555643 front := previewOf(t, m.Layout(), 10)
556- if front.Peek || front.Rect.X-behind.Rect.X != w.peekStep() {
557- t.Fatalf("columns behind %v, in front %v, peek step %d", behind.Rect, front.Rect, w.peekStep())
644+ if front.Peek || front.Rect.X-behind.Rect.X != w.peekStep(horizontalAxis) {
645+ t.Fatalf("columns behind %v, in front %v, peek step %d", behind.Rect, front.Rect, w.peekStep(horizontalAxis))
558646 }
559647 d.move(m, 1, 0)
560648 if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 {
@@ -617,9 +705,9 @@ func TestOverviewCascadeFocusBinds(t *testing.T) {
617705
618706 // A selected stash card keeps the pile cycle.
619707 withStash(m, 3)
620- w.FocusID(7)
708+ w.FocusID(10)
621709 m.ToggleOverview()
622- m.OverviewMove(-1, 0)
710+ m.OverviewMove(0, 1)
623711 at := m.cardAt(w)
624712 m.overviewFocus(ActionFocusWorkspacePrev)
625713 if m.Current() != w || m.cardAt(w) != at-1 {
@@ -2,6 +2,7 @@ package core
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "math"
5 "slices"6 "slices"
6 "testing"7 "testing"
7 8
@@ -70,7 +71,7 @@ func hiddenOff(p Placement) bool { return p.Hidden && p.Preview == 0 && !p.Focus
70 71
71 // The zoom does not depend on the number of bands.72 // The zoom does not depend on the number of bands.
72 func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) {73 func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) {
73- if one, three := cascadeWorkspace(3).overviewZoom(), cascadeWorkspace(7).overviewZoom(); one != three || one != overviewMaxZoom {74+ if one, three := cascadeWorkspace(3).overviewZoom(horizontalAxis), cascadeWorkspace(7).overviewZoom(horizontalAxis); one != three || one != overviewMaxZoom {
74 t.Fatalf("zoom with one band %v, with three %v", one, three)75 t.Fatalf("zoom with one band %v, with three %v", one, three)
75 }76 }
76 }77 }
@@ -216,31 +217,40 @@ func TestOverviewCascadeNeighborBelowScrollShowsOneBand(t *testing.T) {
216 }217 }
217 }218 }
218 219
219-// The stash pile is inside the current slot, left of the band; the220+// The stash pile is centred on the current row, straddling the bottom edge
220-// previous workspace stays clear of it.221+// of the selected lane, over the tiles; it takes no room in the slot.
221 func TestOverviewCascadeStashInSlot(t *testing.T) {222 func TestOverviewCascadeStashInSlot(t *testing.T) {
222 m := cascadeOverview()223 m := cascadeOverview()
224+ m.ToggleOverview()
225+ bare, _ := m.overviewRows()
226+ m.CancelOverview()
223 withStash(m, 2)227 withStash(m, 2)
224 m.ToggleOverview()228 m.ToggleOverview()
225 rows, _ := m.overviewRows()229 rows, _ := m.overviewRows()
226- cur, prev := rows[m.Current()], rows[m.Workspaces[0]]230+ w := m.Current()
231+ cur, prev := rows[w], rows[m.Workspaces[0]]
232+ if cur != bare[w] {
233+ t.Fatalf("the pile changed the slot: %v, without it %v", cur, bare[w])
234+ }
227 ps := m.Layout()235 ps := m.Layout()
228- left := cur.X + cur.W236+ lane := previewOf(t, ps, 8).Rect
229- for _, id := range []WindowID{7, 8, 9} {237+ // 41 is the front card of the pile, 40 behind it.
230- left = min(left, previewOf(t, ps, id).Rect.X)238+ front := previewOf(t, ps, 41)
239+ if front.Hidden || front.Peek || front.Preview != overviewCardZoom {
240+ t.Fatalf("front card: %+v", front)
231 }241 }
232- for _, id := range []WindowID{40, 41} {242+ if back := previewOf(t, ps, 40); back.Hidden || !back.Peek {
233- p := previewOf(t, ps, id)243+ t.Fatalf("card behind: %+v", back)
234- if p.Hidden || p.Preview != overviewCardZoom {244+ }
235- t.Fatalf("pile card %d: %+v", id, p)245+ if !within1(front.Rect.X+front.Rect.W/2, cur.X+cur.W/2) {
236- }246+ t.Fatalf("pile not centred on the row %v: %+v", cur, front.Rect)
237- if p.Rect.X < cur.X || p.Rect.X+p.Rect.W > left {247+ }
238- t.Fatalf("pile card %d not inside the slot, left of the band (%d..%d): %+v", id, cur.X, left, p.Rect)248+ // The lane is centred in the slot; its tiles stop a gap short of its
239- }249+ // bottom edge.
240- // 41 is the front card of the pile.250+ rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom(horizontalAxis)))
241- if id == 41 && !within1(centreY(p.Rect), centreY(previewOf(t, ps, 8).Rect)) {251+ bottom := cur.Y + (cur.H-rowH)/2 + rowH
242- t.Fatalf("pile not level with the selected lane: %+v", p.Rect)252+ if !within1(centreY(front.Rect), bottom) || front.Rect.Y >= lane.Y+lane.H {
243- }253+ t.Fatalf("pile not across the lane's bottom edge %d (lane %v): %+v", bottom, lane, front.Rect)
244 }254 }
245 // The pile does not shift the band: its row stays centred on the area.255 // The pile does not shift the band: its row stays centred on the area.
246 u := m.Current().overviewArea()256 u := m.Current().overviewArea()
@@ -248,11 +258,6 @@ func TestOverviewCascadeStashInSlot(t *testing.T) {
248 if mid := (lo.X + hi.X + hi.W) / 2; !within1(mid, u.X+u.W/2) {258 if mid := (lo.X + hi.X + hi.W) / 2; !within1(mid, u.X+u.W/2) {
249 t.Fatalf("band centre %d with a stash, area centre %d", mid, u.X+u.W/2)259 t.Fatalf("band centre %d with a stash, area centre %d", mid, u.X+u.W/2)
250 }260 }
251- for _, id := range []WindowID{1, 2, 3} {
252- if p := previewOf(t, ps, id); !p.Hidden && p.Rect.X+p.Rect.W > cur.X {
253- t.Fatalf("previous workspace overlaps the slot with the pile: %+v", p.Rect)
254- }
255- }
256 if prev.X+prev.W > cur.X {261 if prev.X+prev.W > cur.X {
257 t.Fatalf("slots overlap: %v %v", prev, cur)262 t.Fatalf("slots overlap: %v %v", prev, cur)
258 }263 }
@@ -334,21 +339,32 @@ func TestOverviewCascadeKeys(t *testing.T) {
334 }339 }
335 m.CancelOverview()340 m.CancelOverview()
336 341
337- // With a stash: left from the first column enters it, right342+ // With a stash: left from the first column still goes to the
338- // comes back to the band's first column, left twice goes to343+ // previous workspace. Down past the last band enters the
339- // the previous workspace.344+ // stash, right comes back to the band's first column, and
345+ // left from the stash goes to the previous workspace.
340 withStash(m, 2)346 withStash(m, 2)
341 w.FocusID(7)347 w.FocusID(7)
342 m.ToggleOverview()348 m.ToggleOverview()
343 d.move(m, -1, 0)349 d.move(m, -1, 0)
350+ if m.Current() != prev {
351+ t.Fatal("left from the first column with a stash did not reach the previous workspace")
352+ }
353+ m.CancelOverview()
354+ m.ToggleOverview()
355+ d.move(m, 0, 1)
356+ if m.cardAt(w) >= 0 || sel() != 10 {
357+ t.Fatalf("down to the last band: card %d, column %d", m.cardAt(w), sel())
358+ }
359+ d.move(m, 0, 1)
344 if m.cardAt(w) < 0 {360 if m.cardAt(w) < 0 {
345- t.Fatal("left from the first column did not select the stash")361+ t.Fatal("down past the last band did not select the stash")
346 }362 }
347 d.move(m, 1, 0)363 d.move(m, 1, 0)
348- if m.cardAt(w) >= 0 || sel() != 7 {364+ if m.cardAt(w) >= 0 || sel() != 10 {
349 t.Fatalf("right from the stash: card %d, column %d", m.cardAt(w), sel())365 t.Fatalf("right from the stash: card %d, column %d", m.cardAt(w), sel())
350 }366 }
351- d.move(m, -1, 0)367+ d.move(m, 0, 1)
352 d.move(m, -1, 0)368 d.move(m, -1, 0)
353 if m.Current() != prev {369 if m.Current() != prev {
354 t.Fatal("left from the stash did not reach the previous workspace")370 t.Fatal("left from the stash did not reach the previous workspace")
@@ -369,9 +385,9 @@ func TestOverviewCascadeStashPile(t *testing.T) {
369 m := cascadeOverview()385 m := cascadeOverview()
370 withStash(m, 3)386 withStash(m, 3)
371 w := m.Current()387 w := m.Current()
372- w.FocusID(7)388+ w.FocusID(10)
373 m.ToggleOverview()389 m.ToggleOverview()
374- d.move(m, -1, 0)390+ d.move(m, 0, 1)
375 if m.cardAt(w) != w.stashAt {391 if m.cardAt(w) != w.stashAt {
376 t.Fatalf("entered at %d", m.cardAt(w))392 t.Fatalf("entered at %d", m.cardAt(w))
377 }393 }
@@ -467,6 +483,23 @@ func TestOverviewCascadeThreeFinger(t *testing.T) {
467 }483 }
468 }484 }
469 485
486+// Three fingers down past the last band enter the stash below it.
487+func TestOverviewCascadeThreeFingerDownEntersStash(t *testing.T) {
488+ c, m := cascadeCore(t)
489+ withStash(m, 2)
490+ w := m.Current()
491+ w.FocusID(6)
492+ m.ToggleOverview()
493+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
494+ for range 4 {
495+ c.swipeUpdate(ports.SwipeUpdate{DY: 40})
496+ }
497+ c.swipeEnd(ports.SwipeEnd{})
498+ if m.Current() != w || m.cardAt(w) != w.stashAt {
499+ t.Fatalf("swipe down from the last band: card %d, want %d", m.cardAt(w), w.stashAt)
500+ }
501+}
502+
470 // Right from a stash card lands on the maximized column, which keeps its503 // Right from a stash card lands on the maximized column, which keeps its
471 // maximization: it is alone in its band.504 // maximization: it is alone in its band.
472 func TestOverviewCascadeStashToMaximized(t *testing.T) {505 func TestOverviewCascadeStashToMaximized(t *testing.T) {
@@ -505,6 +538,61 @@ func TestOverviewCascadeNamedDivider(t *testing.T) {
505 }538 }
506 }539 }
507 540
541+// A stash takes no room in a cascade overview, on a neighbor either: a
542+// wide scroll workspace beside it keeps its zoom and slot.
543+func TestOverviewCascadeNeighborStashKeepsSlot(t *testing.T) {
544+ m := cascadeOverview()
545+ prev := m.Workspaces[0]
546+ m.Focus(0)
547+ for id := WindowID(60); id < 64; id++ {
548+ m.AddWindow(id)
549+ }
550+ m.Focus(1)
551+ m.ToggleOverview()
552+ bare, _ := m.overviewRows()
553+ zoom := prev.overviewZoom(horizontalAxis)
554+ m.CancelOverview()
555+ if zoom <= overviewMinZoom || zoom >= overviewMaxZoom {
556+ t.Fatalf("setup: neighbor zoom %v is at a bound", zoom)
557+ }
558+ // The same columns, with one more window stashed.
559+ m.Focus(0)
560+ m.AddWindow(64)
561+ prev.FocusID(64)
562+ prev.ToggleWindowStash()
563+ m.Focus(1)
564+ m.ToggleOverview()
565+ rows, _ := m.overviewRows()
566+ if rows[prev] != bare[prev] || prev.overviewZoom(horizontalAxis) != zoom {
567+ t.Fatalf("neighbor slot %v zoom %v with a stash, %v %v without", rows[prev], prev.overviewZoom(horizontalAxis), bare[prev], zoom)
568+ }
569+ if p := previewOf(t, m.Layout(), 64); p.Hidden || !p.Peek {
570+ t.Fatalf("neighbor pile card: %+v", p)
571+ }
572+}
573+
574+// Down from a covering float enters the stash; right brings the columns
575+// back to the front.
576+func TestOverviewCascadeFloatStashRight(t *testing.T) {
577+ m := cascadeOverview()
578+ withStash(m, 1)
579+ w := m.Current()
580+ w.AddFloating(90, 300, 200)
581+ w.FocusID(90)
582+ m.ToggleOverview()
583+ if f := m.stackFront(w); f.kind != stackFloat {
584+ t.Fatalf("setup: front %+v", f)
585+ }
586+ m.OverviewMove(0, 1)
587+ if m.cardAt(w) < 0 {
588+ t.Fatal("down from the float did not select the stash")
589+ }
590+ m.OverviewMove(1, 0)
591+ if m.cardAt(w) >= 0 || m.stackFront(w).kind != stackColumns || m.overviewTarget() == 0 {
592+ t.Fatalf("right from the stash: card %d, front %+v, target %d", m.cardAt(w), m.stackFront(w), m.overviewTarget())
593+ }
594+}
595+
508 // Covering floats fan along the horizontal axis: left goes deeper in the596 // Covering floats fan along the horizontal axis: left goes deeper in the
509 // stack (the cards behind show on the left), right comes back to the front.597 // stack (the cards behind show on the left), right comes back to the front.
510 func TestOverviewCascadeCoveringFloat(t *testing.T) {598 func TestOverviewCascadeCoveringFloat(t *testing.T) {
@@ -553,8 +641,8 @@ func TestOverviewCascadeCoveringFloat(t *testing.T) {
553 // The cards behind fan to the left: coming to the front, the641 // The cards behind fan to the left: coming to the front, the
554 // columns move right by one peek step.642 // columns move right by one peek step.
555 front := previewOf(t, m.Layout(), 10)643 front := previewOf(t, m.Layout(), 10)
556- if front.Peek || front.Rect.X-behind.Rect.X != w.peekStep() {644+ if front.Peek || front.Rect.X-behind.Rect.X != w.peekStep(horizontalAxis) {
557- t.Fatalf("columns behind %v, in front %v, peek step %d", behind.Rect, front.Rect, w.peekStep())645+ t.Fatalf("columns behind %v, in front %v, peek step %d", behind.Rect, front.Rect, w.peekStep(horizontalAxis))
558 }646 }
559 d.move(m, 1, 0)647 d.move(m, 1, 0)
560 if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 {648 if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 {
@@ -617,9 +705,9 @@ func TestOverviewCascadeFocusBinds(t *testing.T) {
617 705
618 // A selected stash card keeps the pile cycle.706 // A selected stash card keeps the pile cycle.
619 withStash(m, 3)707 withStash(m, 3)
620- w.FocusID(7)708+ w.FocusID(10)
621 m.ToggleOverview()709 m.ToggleOverview()
622- m.OverviewMove(-1, 0)710+ m.OverviewMove(0, 1)
623 at := m.cardAt(w)711 at := m.cardAt(w)
624 m.overviewFocus(ActionFocusWorkspacePrev)712 m.overviewFocus(ActionFocusWorkspacePrev)
625 if m.Current() != w || m.cardAt(w) != at-1 {713 if m.Current() != w || m.cardAt(w) != at-1 {
M internal/core/overview_stack.go +1 -1
@@ -354,7 +354,7 @@ func (m *Monitor) stackRow(w *Workspace, axis layoutAxis, slot Rect, dim, lit bo
354354 at = slices.Index(items, m.stackFront(w))
355355 }
356356 cards := make([][]Placement, len(items))
357- step := w.peekStep()
357+ step := w.peekStep(axis)
358358 // along is the offset of d steps on the overview axis.
359359 along := func(d int) (int, int) {
360360 var r Rect
@@ -354,7 +354,7 @@ func (m *Monitor) stackRow(w *Workspace, axis layoutAxis, slot Rect, dim, lit bo
354 at = slices.Index(items, m.stackFront(w))354 at = slices.Index(items, m.stackFront(w))
355 }355 }
356 cards := make([][]Placement, len(items))356 cards := make([][]Placement, len(items))
357- step := w.peekStep()357+ step := w.peekStep(axis)
358 // along is the offset of d steps on the overview axis.358 // along is the offset of d steps on the overview axis.
359 along := func(d int) (int, int) {359 along := func(d int) (int, int) {
360 var r Rect360 var r Rect

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