detached 2561e1d

fix(overview): frame the cascade selection without crossing the stash pile

The overview outline framed the first focused preview with four full
lines, so a lane tile's bottom edge ran across the stash pile below it.
Stash cards are now marked (Placement.Card): a selected card is framed
whole and wins over other focused previews, and a lane tile's frame is
cut around the visible cards, without heap allocations per frame.
commit
2561e1da0285fff6d425ed0dc8c06b1c8f7d6e93
author
brice · · 2026-10-15 04:09 UTC
committer
brice · · 2026-10-15 04:09 UTC
parent
4665d1c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +290 -10
M internal/core/layout.go +4 -0
@@ -41,6 +41,10 @@ type Placement struct {
4141 // Peek is a stashed window peeking in beside the selected one, or an
4242 // overview preview of a neighbor workspace: dimmed.
4343 Peek bool
44+ // Card marks a stash card of the overview's pile (as opposed to a tile
45+ // or a covering float): the selection outline goes around a selected
46+ // one and stays off the ones it would cross.
47+ Card bool
4448 // Veil is the share (0..1) of the peek veil (Stash.Dim) the window
4549 // draws: 1 for a settled peek, less while a stash slide brings it to
4650 // the centre. Set wherever Peek is, and on a sliding stash selection.
@@ -41,6 +41,10 @@ type Placement struct {
41 // Peek is a stashed window peeking in beside the selected one, or an41 // Peek is a stashed window peeking in beside the selected one, or an
42 // overview preview of a neighbor workspace: dimmed.42 // overview preview of a neighbor workspace: dimmed.
43 Peek bool43 Peek bool
44+ // Card marks a stash card of the overview's pile (as opposed to a tile
45+ // or a covering float): the selection outline goes around a selected
46+ // one and stays off the ones it would cross.
47+ Card bool
44 // Veil is the share (0..1) of the peek veil (Stash.Dim) the window48 // Veil is the share (0..1) of the peek veil (Stash.Dim) the window
45 // draws: 1 for a settled peek, less while a stash slide brings it to49 // draws: 1 for a settled peek, less while a stash slide brings it to
46 // the centre. Set wherever Peek is, and on a sliding stash selection.50 // the centre. Set wherever Peek is, and on a sliding stash selection.
M internal/core/overview.go +85 -10
@@ -887,7 +887,7 @@ func (w *Workspace) pile(axis layoutAxis, slot Rect, dim bool, front int) []Plac
887887 }
888888 cards := make([][]Placement, n)
889889 for i := range w.Stash {
890- cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: Rect{X: x0, Y: y0, W: cw, H: ch}, Preview: overviewCardZoom, Focused: selected}}
890+ cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: Rect{X: x0, Y: y0, W: cw, H: ch}, Preview: overviewCardZoom, Focused: selected, Card: true}}
891891 }
892892 step := w.cardStep()
893893 return fan(cards, front, min(n, overviewCards)-1, 0, true,
@@ -1133,20 +1133,95 @@ func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes
11331133 }
11341134
11351135 // overviewOutline frames the selected preview with active lines of width
1136-// b, just outside it.
1136+// b, just outside it. The selection is one preview: a selected stash card
1137+// wins over any other focused one, which must then not be framed. A tile's
1138+// frame is cut where the cards of the pile sit over it, so it never runs
1139+// across the pile; a selected card keeps its complete frame.
11371140 func overviewOutline(layout []Placement, b int) []ports.Separator {
1141+ sel := -1
1142+ for i, p := range layout {
1143+ if p.Preview > 0 && p.Focused && !p.Hidden && (sel < 0 || p.Card && !layout[sel].Card) {
1144+ sel = i
1145+ }
1146+ }
1147+ if sel < 0 {
1148+ return nil
1149+ }
1150+ r := layout[sel].Rect
1151+ edges := [4]Rect{
1152+ {X: r.X - b, Y: r.Y - b, W: r.W + 2*b, H: b},
1153+ {X: r.X - b, Y: r.Y + r.H, W: r.W + 2*b, H: b},
1154+ {X: r.X - b, Y: r.Y, W: b, H: r.H},
1155+ {X: r.X + r.W, Y: r.Y, W: b, H: r.H},
1156+ }
1157+ out := make([]ports.Separator, 0, len(edges))
1158+ cut := false
1159+ if !layout[sel].Card {
1160+ for _, p := range layout {
1161+ if p.Card && p.Preview > 0 && !p.Hidden {
1162+ cut = true
1163+ break
1164+ }
1165+ }
1166+ }
1167+ if !cut {
1168+ for _, e := range edges {
1169+ out = append(out, ports.Separator{Rect: e, Active: true})
1170+ }
1171+ return out
1172+ }
1173+ // Stack buffers: the outline is rebuilt every overview frame.
1174+ var cardBuf [overviewCards]Rect
1175+ var pieceBuf [8]Rect
1176+ cards := cardBuf[:0]
11381177 for _, p := range layout {
1139- if p.Preview > 0 && p.Focused && !p.Hidden {
1140- r := p.Rect
1141- return []ports.Separator{
1142- {Rect: Rect{X: r.X - b, Y: r.Y - b, W: r.W + 2*b, H: b}, Active: true},
1143- {Rect: Rect{X: r.X - b, Y: r.Y + r.H, W: r.W + 2*b, H: b}, Active: true},
1144- {Rect: Rect{X: r.X - b, Y: r.Y, W: b, H: r.H}, Active: true},
1145- {Rect: Rect{X: r.X + r.W, Y: r.Y, W: b, H: r.H}, Active: true},
1178+ if p.Card && p.Preview > 0 && !p.Hidden {
1179+ cards = append(cards, p.Rect)
1180+ }
1181+ }
1182+ for _, e := range edges {
1183+ for _, piece := range subtractRects(pieceBuf[:0], e, cards) {
1184+ out = append(out, ports.Separator{Rect: piece, Active: true})
1185+ }
1186+ }
1187+ return out
1188+}
1189+
1190+// subtractRects is what of r no rect of cuts covers, as rects. The edges of
1191+// an outline are thin, so a cut splits one into at most two along its length
1192+// (or drops it, or cuts across its thickness): the pieces are cut one rect
1193+// at a time. The pieces are appended to dst; cuts that miss r allocate
1194+// nothing.
1195+func subtractRects(dst []Rect, r Rect, cuts []Rect) []Rect {
1196+ var bufA, bufB [8]Rect
1197+ pieces, spare := append(bufA[:0], r), bufB[:0]
1198+ for _, c := range cuts {
1199+ next := spare[:0]
1200+ for _, p := range pieces {
1201+ if !p.Overlaps(c) {
1202+ next = append(next, p)
1203+ continue
1204+ }
1205+ // The four bands of p around c: above, below, left, right.
1206+ if c.Y > p.Y {
1207+ next = append(next, Rect{X: p.X, Y: p.Y, W: p.W, H: c.Y - p.Y})
1208+ }
1209+ if c.Y+c.H < p.Y+p.H {
1210+ next = append(next, Rect{X: p.X, Y: c.Y + c.H, W: p.W, H: p.Y + p.H - c.Y - c.H})
1211+ }
1212+ top, bottom := max(p.Y, c.Y), min(p.Y+p.H, c.Y+c.H)
1213+ if c.X > p.X {
1214+ next = append(next, Rect{X: p.X, Y: top, W: c.X - p.X, H: bottom - top})
1215+ }
1216+ if c.X+c.W < p.X+p.W {
1217+ next = append(next, Rect{X: c.X + c.W, Y: top, W: p.X + p.W - c.X - c.W, H: bottom - top})
11461218 }
11471219 }
1220+ // Swap the buffers: next becomes the pieces, the old pieces'
1221+ // storage the next round's.
1222+ pieces, spare = next, pieces
11481223 }
1149- return nil
1224+ return append(dst, pieces...)
11501225 }
11511226
11521227 // overviewClickFade is the fade from which a card takes no click.
@@ -887,7 +887,7 @@ func (w *Workspace) pile(axis layoutAxis, slot Rect, dim bool, front int) []Plac
887 }887 }
888 cards := make([][]Placement, n)888 cards := make([][]Placement, n)
889 for i := range w.Stash {889 for i := range w.Stash {
890- cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: Rect{X: x0, Y: y0, W: cw, H: ch}, Preview: overviewCardZoom, Focused: selected}}890+ cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: Rect{X: x0, Y: y0, W: cw, H: ch}, Preview: overviewCardZoom, Focused: selected, Card: true}}
891 }891 }
892 step := w.cardStep()892 step := w.cardStep()
893 return fan(cards, front, min(n, overviewCards)-1, 0, true,893 return fan(cards, front, min(n, overviewCards)-1, 0, true,
@@ -1133,20 +1133,95 @@ func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes
1133 }1133 }
1134 1134
1135 // overviewOutline frames the selected preview with active lines of width1135 // overviewOutline frames the selected preview with active lines of width
1136-// b, just outside it.1136+// b, just outside it. The selection is one preview: a selected stash card
1137+// wins over any other focused one, which must then not be framed. A tile's
1138+// frame is cut where the cards of the pile sit over it, so it never runs
1139+// across the pile; a selected card keeps its complete frame.
1137 func overviewOutline(layout []Placement, b int) []ports.Separator {1140 func overviewOutline(layout []Placement, b int) []ports.Separator {
1141+ sel := -1
1142+ for i, p := range layout {
1143+ if p.Preview > 0 && p.Focused && !p.Hidden && (sel < 0 || p.Card && !layout[sel].Card) {
1144+ sel = i
1145+ }
1146+ }
1147+ if sel < 0 {
1148+ return nil
1149+ }
1150+ r := layout[sel].Rect
1151+ edges := [4]Rect{
1152+ {X: r.X - b, Y: r.Y - b, W: r.W + 2*b, H: b},
1153+ {X: r.X - b, Y: r.Y + r.H, W: r.W + 2*b, H: b},
1154+ {X: r.X - b, Y: r.Y, W: b, H: r.H},
1155+ {X: r.X + r.W, Y: r.Y, W: b, H: r.H},
1156+ }
1157+ out := make([]ports.Separator, 0, len(edges))
1158+ cut := false
1159+ if !layout[sel].Card {
1160+ for _, p := range layout {
1161+ if p.Card && p.Preview > 0 && !p.Hidden {
1162+ cut = true
1163+ break
1164+ }
1165+ }
1166+ }
1167+ if !cut {
1168+ for _, e := range edges {
1169+ out = append(out, ports.Separator{Rect: e, Active: true})
1170+ }
1171+ return out
1172+ }
1173+ // Stack buffers: the outline is rebuilt every overview frame.
1174+ var cardBuf [overviewCards]Rect
1175+ var pieceBuf [8]Rect
1176+ cards := cardBuf[:0]
1138 for _, p := range layout {1177 for _, p := range layout {
1139- if p.Preview > 0 && p.Focused && !p.Hidden {1178+ if p.Card && p.Preview > 0 && !p.Hidden {
1140- r := p.Rect1179+ cards = append(cards, p.Rect)
1141- return []ports.Separator{1180+ }
1142- {Rect: Rect{X: r.X - b, Y: r.Y - b, W: r.W + 2*b, H: b}, Active: true},1181+ }
1143- {Rect: Rect{X: r.X - b, Y: r.Y + r.H, W: r.W + 2*b, H: b}, Active: true},1182+ for _, e := range edges {
1144- {Rect: Rect{X: r.X - b, Y: r.Y, W: b, H: r.H}, Active: true},1183+ for _, piece := range subtractRects(pieceBuf[:0], e, cards) {
1145- {Rect: Rect{X: r.X + r.W, Y: r.Y, W: b, H: r.H}, Active: true},1184+ out = append(out, ports.Separator{Rect: piece, Active: true})
1185+ }
1186+ }
1187+ return out
1188+}
1189+
1190+// subtractRects is what of r no rect of cuts covers, as rects. The edges of
1191+// an outline are thin, so a cut splits one into at most two along its length
1192+// (or drops it, or cuts across its thickness): the pieces are cut one rect
1193+// at a time. The pieces are appended to dst; cuts that miss r allocate
1194+// nothing.
1195+func subtractRects(dst []Rect, r Rect, cuts []Rect) []Rect {
1196+ var bufA, bufB [8]Rect
1197+ pieces, spare := append(bufA[:0], r), bufB[:0]
1198+ for _, c := range cuts {
1199+ next := spare[:0]
1200+ for _, p := range pieces {
1201+ if !p.Overlaps(c) {
1202+ next = append(next, p)
1203+ continue
1204+ }
1205+ // The four bands of p around c: above, below, left, right.
1206+ if c.Y > p.Y {
1207+ next = append(next, Rect{X: p.X, Y: p.Y, W: p.W, H: c.Y - p.Y})
1208+ }
1209+ if c.Y+c.H < p.Y+p.H {
1210+ next = append(next, Rect{X: p.X, Y: c.Y + c.H, W: p.W, H: p.Y + p.H - c.Y - c.H})
1211+ }
1212+ top, bottom := max(p.Y, c.Y), min(p.Y+p.H, c.Y+c.H)
1213+ if c.X > p.X {
1214+ next = append(next, Rect{X: p.X, Y: top, W: c.X - p.X, H: bottom - top})
1215+ }
1216+ if c.X+c.W < p.X+p.W {
1217+ next = append(next, Rect{X: c.X + c.W, Y: top, W: p.X + p.W - c.X - c.W, H: bottom - top})
1146 }1218 }
1147 }1219 }
1220+ // Swap the buffers: next becomes the pieces, the old pieces'
1221+ // storage the next round's.
1222+ pieces, spare = next, pieces
1148 }1223 }
1149- return nil1224+ return append(dst, pieces...)
1150 }1225 }
1151 1226
1152 // overviewClickFade is the fade from which a card takes no click.1227 // overviewClickFade is the fade from which a card takes no click.
M internal/core/overview_cascade_test.go +201 -0
@@ -1050,3 +1050,204 @@ func TestOverviewCascadeScroll(t *testing.T) {
10501050 })
10511051 }
10521052 }
1053+
1054+// outlineHits reports whether any outline line of ps covers a point of r.
1055+func outlineHits(ps []Placement, r Rect) bool {
1056+ for _, s := range overviewOutline(ps, 2) {
1057+ if s.Rect.Overlaps(r) {
1058+ return true
1059+ }
1060+ }
1061+ return false
1062+}
1063+
1064+// The pile shows whenever the workspace has a stash, whatever is selected
1065+// and however many windows it holds: the front cards are drawn and no
1066+// lane tile is painted over them.
1067+func TestOverviewCascadePileVisible(t *testing.T) {
1068+ for _, n := range []int{1, 2, 4, 6} {
1069+ for _, hidden := range []bool{false, true} {
1070+ for _, down := range []int{0, 1, 2} {
1071+ m := cascadeOverview()
1072+ withStash(m, n)
1073+ w := m.Current()
1074+ w.stashHidden = hidden
1075+ m.ToggleOverview()
1076+ for range down {
1077+ m.OverviewMove(0, 1)
1078+ }
1079+ ps := m.Layout()
1080+ shown := 0
1081+ for i, p := range ps {
1082+ if w.stashIndex(p.ID) < 0 || p.Hidden {
1083+ continue
1084+ }
1085+ shown++
1086+ for _, q := range ps[i+1:] {
1087+ if w.stashIndex(q.ID) < 0 && !q.Hidden && q.Preview > 0 && q.Rect.Overlaps(p.Rect) {
1088+ t.Fatalf("n=%d hidden=%v down=%d: card %d painted under tile %d", n, hidden, down, p.ID, q.ID)
1089+ }
1090+ }
1091+ }
1092+ if want := min(n, overviewCards); shown != want {
1093+ t.Fatalf("n=%d hidden=%v down=%d: %d cards shown, want %d", n, hidden, down, shown, want)
1094+ }
1095+ }
1096+ }
1097+ }
1098+}
1099+
1100+// The selection outline of a lane tile stops at the pile; a selected card
1101+// is framed whole, and the lane keeps no frame then. Exactly one preview is
1102+// framed at any time.
1103+func TestOverviewCascadeOutlineAndPile(t *testing.T) {
1104+ m := cascadeOverview()
1105+ withStash(m, 3)
1106+ w := m.Current()
1107+ w.FocusID(8)
1108+ m.ToggleOverview()
1109+ framed := func(ps []Placement) (n int) {
1110+ for _, p := range ps {
1111+ if p.Preview > 0 && p.Focused && !p.Hidden {
1112+ n++
1113+ }
1114+ }
1115+ return n
1116+ }
1117+
1118+ // A tile selected: the frame goes round the tile but not across any card.
1119+ ps := m.Layout()
1120+ if framed(ps) != 1 || !previewOf(t, ps, 8).Focused {
1121+ t.Fatalf("lane selected: %d framed", framed(ps))
1122+ }
1123+ if len(overviewOutline(ps, 2)) == 0 {
1124+ t.Fatal("lane selected: no frame")
1125+ }
1126+ for _, id := range []WindowID{40, 41, 42} {
1127+ if outlineHits(ps, previewOf(t, ps, id).Rect) {
1128+ t.Fatalf("the lane's frame crosses card %d", id)
1129+ }
1130+ }
1131+ // The cut is the pile's: the tile still has frame lines beside it.
1132+ if !outlineHits(ps, Rect{X: previewOf(t, ps, 8).Rect.X - 2, Y: previewOf(t, ps, 8).Rect.Y, W: 2, H: 10}) {
1133+ t.Fatal("the lane lost its left edge")
1134+ }
1135+
1136+ // The pile selected: the card has all four sides, the lane none.
1137+ m.OverviewMove(0, 1)
1138+ m.OverviewMove(0, 1)
1139+ m.OverviewMove(0, 1)
1140+ at := m.cardAt(w)
1141+ if at < 0 {
1142+ t.Fatal("setup: no card selected")
1143+ }
1144+ ps = m.Layout()
1145+ if framed(ps) != 1 {
1146+ t.Fatalf("card selected: %d framed", framed(ps))
1147+ }
1148+ card := previewOf(t, ps, w.Stash[at].ID)
1149+ if !card.Focused || !card.Card {
1150+ t.Fatalf("selected card not framed: %+v", card)
1151+ }
1152+ out := overviewOutline(ps, 2)
1153+ if len(out) != 4 {
1154+ t.Fatalf("card frame is %d lines, want 4: %+v", len(out), out)
1155+ }
1156+ for _, s := range out {
1157+ if !s.Rect.Overlaps(Rect{X: card.Rect.X - 2, Y: card.Rect.Y - 2, W: card.Rect.W + 4, H: card.Rect.H + 4}) {
1158+ t.Fatalf("line %+v is away from the card %+v", s.Rect, card.Rect)
1159+ }
1160+ }
1161+ for _, p := range ps {
1162+ if w.stashIndex(p.ID) < 0 && p.Focused && !p.Hidden && p.Preview > 0 {
1163+ t.Fatalf("lane tile %d still framed with a card selected", p.ID)
1164+ }
1165+ }
1166+
1167+ // Back to a lane: the frame follows, the card has none.
1168+ m.OverviewMove(0, -1)
1169+ ps = m.Layout()
1170+ if m.cardAt(w) >= 0 || framed(ps) != 1 {
1171+ t.Fatalf("lane reselected: card %d, %d framed", m.cardAt(w), framed(ps))
1172+ }
1173+ for _, p := range ps {
1174+ if w.stashIndex(p.ID) >= 0 && p.Focused {
1175+ t.Fatalf("card %d still framed on the lane", p.ID)
1176+ }
1177+ }
1178+}
1179+
1180+// If two previews claim the selection, the stash card is the one framed.
1181+func TestOverviewOutlinePrefersCard(t *testing.T) {
1182+ ps := []Placement{
1183+ {ID: 1, Rect: Rect{X: 0, Y: 0, W: 100, H: 100}, Preview: 0.5, Focused: true},
1184+ {ID: 2, Rect: Rect{X: 40, Y: 80, W: 30, H: 30}, Preview: 0.2, Focused: true, Card: true},
1185+ }
1186+ out := overviewOutline(ps, 2)
1187+ if len(out) != 4 || out[0].Rect != (Rect{X: 38, Y: 78, W: 34, H: 2}) {
1188+ t.Fatalf("outline: %+v", out)
1189+ }
1190+}
1191+
1192+// The outline is rebuilt every overview frame: cutting it around the pile
1193+// costs only the returned slice.
1194+func TestOverviewOutlinePileAllocations(t *testing.T) {
1195+ m := cascadeOverview()
1196+ withStash(m, 4)
1197+ m.Current().FocusID(8)
1198+ m.ToggleOverview()
1199+ ps := m.Layout()
1200+ card := false
1201+ for _, p := range ps {
1202+ card = card || p.Card && p.Preview > 0 && !p.Hidden
1203+ }
1204+ if !card || len(overviewOutline(ps, 2)) == 0 {
1205+ t.Fatal("setup: no pile under a framed lane tile")
1206+ }
1207+ if n := testing.AllocsPerRun(50, func() { _ = overviewOutline(ps, 2) }); n > 2 {
1208+ t.Fatalf("overviewOutline allocs = %v, want <= 2", n)
1209+ }
1210+}
1211+
1212+func TestSubtractRects(t *testing.T) {
1213+ h := Rect{X: 0, Y: 10, W: 100, H: 2}
1214+ v := Rect{X: 10, Y: 0, W: 2, H: 100}
1215+ for name, tc := range map[string]struct {
1216+ r Rect
1217+ cuts []Rect
1218+ area int
1219+ }{
1220+ "none": {h, nil, 200},
1221+ "apart": {h, []Rect{{X: 0, Y: 50, W: 10, H: 10}}, 200},
1222+ "middle": {h, []Rect{{X: 40, Y: 0, W: 20, H: 30}}, 160},
1223+ "edge": {h, []Rect{{X: 90, Y: 0, W: 50, H: 30}}, 180},
1224+ "covered": {h, []Rect{{X: -5, Y: 0, W: 200, H: 30}}, 0},
1225+ "across": {h, []Rect{{X: 40, Y: 11, W: 20, H: 30}}, 180},
1226+ "two": {h, []Rect{{X: 10, Y: 0, W: 10, H: 30}, {X: 50, Y: 0, W: 10, H: 30}}, 160},
1227+ "overlapped": {h, []Rect{{X: 10, Y: 0, W: 20, H: 30}, {X: 20, Y: 0, W: 20, H: 30}}, 140},
1228+ "vertical": {v, []Rect{{X: 0, Y: 40, W: 30, H: 20}}, 160},
1229+ "stacked": {v, []Rect{{X: 0, Y: 10, W: 30, H: 10}, {X: 0, Y: 15, W: 30, H: 10}, {X: 0, Y: 90, W: 30, H: 30}}, 150},
1230+ } {
1231+ area := 0
1232+ pieces := subtractRects(nil, tc.r, tc.cuts)
1233+ for i, p := range pieces {
1234+ area += p.W * p.H
1235+ if p.W <= 0 || p.H <= 0 || p.X < tc.r.X || p.Y < tc.r.Y || p.X+p.W > tc.r.X+tc.r.W || p.Y+p.H > tc.r.Y+tc.r.H {
1236+ t.Fatalf("%s: piece %v outside %v", name, p, tc.r)
1237+ }
1238+ for _, c := range tc.cuts {
1239+ if p.Overlaps(c) {
1240+ t.Fatalf("%s: piece %v overlaps cut %v", name, p, c)
1241+ }
1242+ }
1243+ for _, q := range pieces[i+1:] {
1244+ if p.Overlaps(q) {
1245+ t.Fatalf("%s: pieces %v and %v overlap", name, p, q)
1246+ }
1247+ }
1248+ }
1249+ if area != tc.area {
1250+ t.Fatalf("%s: area %d, want %d", name, area, tc.area)
1251+ }
1252+ }
1253+}
@@ -1050,3 +1050,204 @@ func TestOverviewCascadeScroll(t *testing.T) {
1050 })1050 })
1051 }1051 }
1052 }1052 }
1053+
1054+// outlineHits reports whether any outline line of ps covers a point of r.
1055+func outlineHits(ps []Placement, r Rect) bool {
1056+ for _, s := range overviewOutline(ps, 2) {
1057+ if s.Rect.Overlaps(r) {
1058+ return true
1059+ }
1060+ }
1061+ return false
1062+}
1063+
1064+// The pile shows whenever the workspace has a stash, whatever is selected
1065+// and however many windows it holds: the front cards are drawn and no
1066+// lane tile is painted over them.
1067+func TestOverviewCascadePileVisible(t *testing.T) {
1068+ for _, n := range []int{1, 2, 4, 6} {
1069+ for _, hidden := range []bool{false, true} {
1070+ for _, down := range []int{0, 1, 2} {
1071+ m := cascadeOverview()
1072+ withStash(m, n)
1073+ w := m.Current()
1074+ w.stashHidden = hidden
1075+ m.ToggleOverview()
1076+ for range down {
1077+ m.OverviewMove(0, 1)
1078+ }
1079+ ps := m.Layout()
1080+ shown := 0
1081+ for i, p := range ps {
1082+ if w.stashIndex(p.ID) < 0 || p.Hidden {
1083+ continue
1084+ }
1085+ shown++
1086+ for _, q := range ps[i+1:] {
1087+ if w.stashIndex(q.ID) < 0 && !q.Hidden && q.Preview > 0 && q.Rect.Overlaps(p.Rect) {
1088+ t.Fatalf("n=%d hidden=%v down=%d: card %d painted under tile %d", n, hidden, down, p.ID, q.ID)
1089+ }
1090+ }
1091+ }
1092+ if want := min(n, overviewCards); shown != want {
1093+ t.Fatalf("n=%d hidden=%v down=%d: %d cards shown, want %d", n, hidden, down, shown, want)
1094+ }
1095+ }
1096+ }
1097+ }
1098+}
1099+
1100+// The selection outline of a lane tile stops at the pile; a selected card
1101+// is framed whole, and the lane keeps no frame then. Exactly one preview is
1102+// framed at any time.
1103+func TestOverviewCascadeOutlineAndPile(t *testing.T) {
1104+ m := cascadeOverview()
1105+ withStash(m, 3)
1106+ w := m.Current()
1107+ w.FocusID(8)
1108+ m.ToggleOverview()
1109+ framed := func(ps []Placement) (n int) {
1110+ for _, p := range ps {
1111+ if p.Preview > 0 && p.Focused && !p.Hidden {
1112+ n++
1113+ }
1114+ }
1115+ return n
1116+ }
1117+
1118+ // A tile selected: the frame goes round the tile but not across any card.
1119+ ps := m.Layout()
1120+ if framed(ps) != 1 || !previewOf(t, ps, 8).Focused {
1121+ t.Fatalf("lane selected: %d framed", framed(ps))
1122+ }
1123+ if len(overviewOutline(ps, 2)) == 0 {
1124+ t.Fatal("lane selected: no frame")
1125+ }
1126+ for _, id := range []WindowID{40, 41, 42} {
1127+ if outlineHits(ps, previewOf(t, ps, id).Rect) {
1128+ t.Fatalf("the lane's frame crosses card %d", id)
1129+ }
1130+ }
1131+ // The cut is the pile's: the tile still has frame lines beside it.
1132+ if !outlineHits(ps, Rect{X: previewOf(t, ps, 8).Rect.X - 2, Y: previewOf(t, ps, 8).Rect.Y, W: 2, H: 10}) {
1133+ t.Fatal("the lane lost its left edge")
1134+ }
1135+
1136+ // The pile selected: the card has all four sides, the lane none.
1137+ m.OverviewMove(0, 1)
1138+ m.OverviewMove(0, 1)
1139+ m.OverviewMove(0, 1)
1140+ at := m.cardAt(w)
1141+ if at < 0 {
1142+ t.Fatal("setup: no card selected")
1143+ }
1144+ ps = m.Layout()
1145+ if framed(ps) != 1 {
1146+ t.Fatalf("card selected: %d framed", framed(ps))
1147+ }
1148+ card := previewOf(t, ps, w.Stash[at].ID)
1149+ if !card.Focused || !card.Card {
1150+ t.Fatalf("selected card not framed: %+v", card)
1151+ }
1152+ out := overviewOutline(ps, 2)
1153+ if len(out) != 4 {
1154+ t.Fatalf("card frame is %d lines, want 4: %+v", len(out), out)
1155+ }
1156+ for _, s := range out {
1157+ if !s.Rect.Overlaps(Rect{X: card.Rect.X - 2, Y: card.Rect.Y - 2, W: card.Rect.W + 4, H: card.Rect.H + 4}) {
1158+ t.Fatalf("line %+v is away from the card %+v", s.Rect, card.Rect)
1159+ }
1160+ }
1161+ for _, p := range ps {
1162+ if w.stashIndex(p.ID) < 0 && p.Focused && !p.Hidden && p.Preview > 0 {
1163+ t.Fatalf("lane tile %d still framed with a card selected", p.ID)
1164+ }
1165+ }
1166+
1167+ // Back to a lane: the frame follows, the card has none.
1168+ m.OverviewMove(0, -1)
1169+ ps = m.Layout()
1170+ if m.cardAt(w) >= 0 || framed(ps) != 1 {
1171+ t.Fatalf("lane reselected: card %d, %d framed", m.cardAt(w), framed(ps))
1172+ }
1173+ for _, p := range ps {
1174+ if w.stashIndex(p.ID) >= 0 && p.Focused {
1175+ t.Fatalf("card %d still framed on the lane", p.ID)
1176+ }
1177+ }
1178+}
1179+
1180+// If two previews claim the selection, the stash card is the one framed.
1181+func TestOverviewOutlinePrefersCard(t *testing.T) {
1182+ ps := []Placement{
1183+ {ID: 1, Rect: Rect{X: 0, Y: 0, W: 100, H: 100}, Preview: 0.5, Focused: true},
1184+ {ID: 2, Rect: Rect{X: 40, Y: 80, W: 30, H: 30}, Preview: 0.2, Focused: true, Card: true},
1185+ }
1186+ out := overviewOutline(ps, 2)
1187+ if len(out) != 4 || out[0].Rect != (Rect{X: 38, Y: 78, W: 34, H: 2}) {
1188+ t.Fatalf("outline: %+v", out)
1189+ }
1190+}
1191+
1192+// The outline is rebuilt every overview frame: cutting it around the pile
1193+// costs only the returned slice.
1194+func TestOverviewOutlinePileAllocations(t *testing.T) {
1195+ m := cascadeOverview()
1196+ withStash(m, 4)
1197+ m.Current().FocusID(8)
1198+ m.ToggleOverview()
1199+ ps := m.Layout()
1200+ card := false
1201+ for _, p := range ps {
1202+ card = card || p.Card && p.Preview > 0 && !p.Hidden
1203+ }
1204+ if !card || len(overviewOutline(ps, 2)) == 0 {
1205+ t.Fatal("setup: no pile under a framed lane tile")
1206+ }
1207+ if n := testing.AllocsPerRun(50, func() { _ = overviewOutline(ps, 2) }); n > 2 {
1208+ t.Fatalf("overviewOutline allocs = %v, want <= 2", n)
1209+ }
1210+}
1211+
1212+func TestSubtractRects(t *testing.T) {
1213+ h := Rect{X: 0, Y: 10, W: 100, H: 2}
1214+ v := Rect{X: 10, Y: 0, W: 2, H: 100}
1215+ for name, tc := range map[string]struct {
1216+ r Rect
1217+ cuts []Rect
1218+ area int
1219+ }{
1220+ "none": {h, nil, 200},
1221+ "apart": {h, []Rect{{X: 0, Y: 50, W: 10, H: 10}}, 200},
1222+ "middle": {h, []Rect{{X: 40, Y: 0, W: 20, H: 30}}, 160},
1223+ "edge": {h, []Rect{{X: 90, Y: 0, W: 50, H: 30}}, 180},
1224+ "covered": {h, []Rect{{X: -5, Y: 0, W: 200, H: 30}}, 0},
1225+ "across": {h, []Rect{{X: 40, Y: 11, W: 20, H: 30}}, 180},
1226+ "two": {h, []Rect{{X: 10, Y: 0, W: 10, H: 30}, {X: 50, Y: 0, W: 10, H: 30}}, 160},
1227+ "overlapped": {h, []Rect{{X: 10, Y: 0, W: 20, H: 30}, {X: 20, Y: 0, W: 20, H: 30}}, 140},
1228+ "vertical": {v, []Rect{{X: 0, Y: 40, W: 30, H: 20}}, 160},
1229+ "stacked": {v, []Rect{{X: 0, Y: 10, W: 30, H: 10}, {X: 0, Y: 15, W: 30, H: 10}, {X: 0, Y: 90, W: 30, H: 30}}, 150},
1230+ } {
1231+ area := 0
1232+ pieces := subtractRects(nil, tc.r, tc.cuts)
1233+ for i, p := range pieces {
1234+ area += p.W * p.H
1235+ if p.W <= 0 || p.H <= 0 || p.X < tc.r.X || p.Y < tc.r.Y || p.X+p.W > tc.r.X+tc.r.W || p.Y+p.H > tc.r.Y+tc.r.H {
1236+ t.Fatalf("%s: piece %v outside %v", name, p, tc.r)
1237+ }
1238+ for _, c := range tc.cuts {
1239+ if p.Overlaps(c) {
1240+ t.Fatalf("%s: piece %v overlaps cut %v", name, p, c)
1241+ }
1242+ }
1243+ for _, q := range pieces[i+1:] {
1244+ if p.Overlaps(q) {
1245+ t.Fatalf("%s: pieces %v and %v overlap", name, p, q)
1246+ }
1247+ }
1248+ }
1249+ if area != tc.area {
1250+ t.Fatalf("%s: area %d, want %d", name, area, tc.area)
1251+ }
1252+ }
1253+}

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