detached 8193e80

Merge pull request 'fix(overview): cascade stash pile navigation and outline' (#55) from fix/cascade-overview-stash into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/55
commit
8193e80ad3917a7e7f05170cc711651e65a44f06
author
bnema · · 2026-10-15 04:52 UTC
merge of
6aca404 + 2561e1d · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
4 files · +376 -34
M docs/config.md +2 -0
@@ -274,3 +274,5 @@ The window must appear within 5 seconds, otherwise it opens normally. Apps that
274274 ### Overview
275275
276276 `toggle-overview` (or a four-finger swipe up) shows previews of your workspaces. Arrows, `h/j/k/l` or three-finger swipes move the selection; `Return` or a click opens it; `Escape` goes back. `close-window` and the move-to-workspace binds work on the selected preview.
277+
278+With `layout.overflow = cascade`, workspaces sit side by side and each band of columns is a lane. Down (or fingers down on the touchpad, with natural scroll off) moves to the lane below; past the last lane it selects the stash pile under it, and down again brings the next stash card to the front. Up from a stash card leaves the pile for the last lane, at the column you came from. `focus-workspace-prev/next` also walk the pile in both directions, and left/right leave it.
@@ -274,3 +274,5 @@ The window must appear within 5 seconds, otherwise it opens normally. Apps that
274 ### Overview274 ### Overview
275 275
276 `toggle-overview` (or a four-finger swipe up) shows previews of your workspaces. Arrows, `h/j/k/l` or three-finger swipes move the selection; `Return` or a click opens it; `Escape` goes back. `close-window` and the move-to-workspace binds work on the selected preview.276 `toggle-overview` (or a four-finger swipe up) shows previews of your workspaces. Arrows, `h/j/k/l` or three-finger swipes move the selection; `Return` or a click opens it; `Escape` goes back. `close-window` and the move-to-workspace binds work on the selected preview.
277+
278+With `layout.overflow = cascade`, workspaces sit side by side and each band of columns is a lane. Down (or fingers down on the touchpad, with natural scroll off) moves to the lane below; past the last lane it selects the stash pile under it, and down again brings the next stash card to the front. Up from a stash card leaves the pile for the last lane, at the column you came from. `focus-workspace-prev/next` also walk the pile in both directions, and left/right leave it.
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 +118 -16
@@ -423,14 +423,21 @@ func (m *Monitor) cycleCard(w *Workspace, d int) bool {
423423 }
424424
425425 // overviewLane is the vertical move of an overview whose workspaces sit side
426-// by side (cascade): a selected stash card cycles the pile; else the
427-// selection goes to the previous or next lane (band), at the closest column,
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.
426+// by side (cascade). From a selected stash card, down brings the next card
427+// of the pile to the front (stopping at the last) and up leaves the pile for
428+// the last lane (leavePile); focus-workspace-prev/next cycle it both ways.
429+// Else the selection goes to the previous or next lane (band), at the
430+// closest column, stopping at the first. Down past the last lane, or from a
431+// card that is not the columns, enters the stash, which sits below. Covering
432+// floats stack along the horizontal axis, not this one.
431433 func (m *Monitor) overviewLane(dy int) {
432434 w := m.Current()
433- if m.cycleCard(w, dy) {
435+ if m.cardAt(w) >= 0 {
436+ if dy > 0 {
437+ m.cycleCard(w, dy)
438+ } else {
439+ m.leavePile(w)
440+ }
434441 return
435442 }
436443 if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {
@@ -444,6 +451,26 @@ func (m *Monitor) overviewLane(dy int) {
444451 }
445452 }
446453
454+// leavePile moves the selection from a stash card back up to where the pile
455+// sits below: the last lane, at the column the pile was entered from (the
456+// focused column's offset in its lane, or the lane's last column when it is
457+// shorter). With a covering float in front it only deselects the card; a
458+// stash-only workspace has nothing above the pile, so the card stays.
459+func (m *Monitor) leavePile(w *Workspace) {
460+ if len(w.stack()) == 0 {
461+ return
462+ }
463+ front := m.stackFront(w)
464+ m.clearCard()
465+ m.ov.selected = 0
466+ if front.kind != stackColumns || len(w.Columns) == 0 {
467+ return
468+ }
469+ first, _ := w.laneBounds(w.laneOf(w.Focus))
470+ nfirst, nlast := w.laneBounds(w.laneOf(len(w.Columns) - 1))
471+ m.selectLaneColumn(w, min(nfirst+w.Focus-first, nlast))
472+}
473+
447474 // selectLaneColumn selects column i of w.
448475 func (m *Monitor) selectLaneColumn(w *Workspace, i int) {
449476 w.selectOverviewColumn(i)
@@ -860,7 +887,7 @@ func (w *Workspace) pile(axis layoutAxis, slot Rect, dim bool, front int) []Plac
860887 }
861888 cards := make([][]Placement, n)
862889 for i := range w.Stash {
863- 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}}
864891 }
865892 step := w.cardStep()
866893 return fan(cards, front, min(n, overviewCards)-1, 0, true,
@@ -1106,20 +1133,95 @@ func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes
11061133 }
11071134
11081135 // overviewOutline frames the selected preview with active lines of width
1109-// 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.
11101140 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]
11111177 for _, p := range layout {
1112- if p.Preview > 0 && p.Focused && !p.Hidden {
1113- r := p.Rect
1114- return []ports.Separator{
1115- {Rect: Rect{X: r.X - b, Y: r.Y - b, W: r.W + 2*b, H: b}, Active: true},
1116- {Rect: Rect{X: r.X - b, Y: r.Y + r.H, W: r.W + 2*b, H: b}, Active: true},
1117- {Rect: Rect{X: r.X - b, Y: r.Y, W: b, H: r.H}, Active: true},
1118- {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})
11191218 }
11201219 }
1220+ // Swap the buffers: next becomes the pieces, the old pieces'
1221+ // storage the next round's.
1222+ pieces, spare = next, pieces
11211223 }
1122- return nil
1224+ return append(dst, pieces...)
11231225 }
11241226
11251227 // overviewClickFade is the fade from which a card takes no click.
@@ -423,14 +423,21 @@ func (m *Monitor) cycleCard(w *Workspace, d int) bool {
423 }423 }
424 424
425 // overviewLane is the vertical move of an overview whose workspaces sit side425 // overviewLane is the vertical move of an overview whose workspaces sit side
426-// by side (cascade): a selected stash card cycles the pile; else the426+// by side (cascade). From a selected stash card, down brings the next card
427-// selection goes to the previous or next lane (band), at the closest column,427+// of the pile to the front (stopping at the last) and up leaves the pile for
428-// stopping at the first. Down past the last lane, or from a card that is not428+// the last lane (leavePile); focus-workspace-prev/next cycle it both ways.
429-// the columns, enters the stash, which sits below. Covering floats stack429+// Else the selection goes to the previous or next lane (band), at the
430-// along the horizontal axis, not this one.430+// closest column, stopping at the first. Down past the last lane, or from a
431+// card that is not the columns, enters the stash, which sits below. Covering
432+// floats stack along the horizontal axis, not this one.
431 func (m *Monitor) overviewLane(dy int) {433 func (m *Monitor) overviewLane(dy int) {
432 w := m.Current()434 w := m.Current()
433- if m.cycleCard(w, dy) {435+ if m.cardAt(w) >= 0 {
436+ if dy > 0 {
437+ m.cycleCard(w, dy)
438+ } else {
439+ m.leavePile(w)
440+ }
434 return441 return
435 }442 }
436 if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {443 if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {
@@ -444,6 +451,26 @@ func (m *Monitor) overviewLane(dy int) {
444 }451 }
445 }452 }
446 453
454+// leavePile moves the selection from a stash card back up to where the pile
455+// sits below: the last lane, at the column the pile was entered from (the
456+// focused column's offset in its lane, or the lane's last column when it is
457+// shorter). With a covering float in front it only deselects the card; a
458+// stash-only workspace has nothing above the pile, so the card stays.
459+func (m *Monitor) leavePile(w *Workspace) {
460+ if len(w.stack()) == 0 {
461+ return
462+ }
463+ front := m.stackFront(w)
464+ m.clearCard()
465+ m.ov.selected = 0
466+ if front.kind != stackColumns || len(w.Columns) == 0 {
467+ return
468+ }
469+ first, _ := w.laneBounds(w.laneOf(w.Focus))
470+ nfirst, nlast := w.laneBounds(w.laneOf(len(w.Columns) - 1))
471+ m.selectLaneColumn(w, min(nfirst+w.Focus-first, nlast))
472+}
473+
447 // selectLaneColumn selects column i of w.474 // selectLaneColumn selects column i of w.
448 func (m *Monitor) selectLaneColumn(w *Workspace, i int) {475 func (m *Monitor) selectLaneColumn(w *Workspace, i int) {
449 w.selectOverviewColumn(i)476 w.selectOverviewColumn(i)
@@ -860,7 +887,7 @@ func (w *Workspace) pile(axis layoutAxis, slot Rect, dim bool, front int) []Plac
860 }887 }
861 cards := make([][]Placement, n)888 cards := make([][]Placement, n)
862 for i := range w.Stash {889 for i := range w.Stash {
863- 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}}
864 }891 }
865 step := w.cardStep()892 step := w.cardStep()
866 return fan(cards, front, min(n, overviewCards)-1, 0, true,893 return fan(cards, front, min(n, overviewCards)-1, 0, true,
@@ -1106,20 +1133,95 @@ func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes
1106 }1133 }
1107 1134
1108 // overviewOutline frames the selected preview with active lines of width1135 // overviewOutline frames the selected preview with active lines of width
1109-// 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.
1110 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]
1111 for _, p := range layout {1177 for _, p := range layout {
1112- if p.Preview > 0 && p.Focused && !p.Hidden {1178+ if p.Card && p.Preview > 0 && !p.Hidden {
1113- r := p.Rect1179+ cards = append(cards, p.Rect)
1114- return []ports.Separator{1180+ }
1115- {Rect: Rect{X: r.X - b, Y: r.Y - b, W: r.W + 2*b, H: b}, Active: true},1181+ }
1116- {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 {
1117- {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) {
1118- {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})
1119 }1218 }
1120 }1219 }
1220+ // Swap the buffers: next becomes the pieces, the old pieces'
1221+ // storage the next round's.
1222+ pieces, spare = next, pieces
1121 }1223 }
1122- return nil1224+ return append(dst, pieces...)
1123 }1225 }
1124 1226
1125 // 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 +252 -18
@@ -377,8 +377,9 @@ func TestOverviewCascadeKeys(t *testing.T) {
377377 }
378378 }
379379
380-// T7b: with a stash card selected, up/down and the three-finger vertical
381-// swipe cycle the pile as in a vertical overview; confirming shows the picked window.
380+// T7b: with a stash card selected in a cascade overview, down walks the pile
381+// (stopping at its last card) and up leaves it for the last band, at the
382+// column it was entered from; confirming shows the picked window.
382383 func TestOverviewCascadeStashPile(t *testing.T) {
383384 for _, d := range cascadeDrivers {
384385 t.Run(d.name, func(t *testing.T) {
@@ -392,17 +393,12 @@ func TestOverviewCascadeStashPile(t *testing.T) {
392393 t.Fatalf("entered at %d", m.cardAt(w))
393394 }
394395 at := m.cardAt(w)
395- d.move(m, 0, -1)
396- if m.Current() != w || m.cardAt(w) != at-1 {
397- t.Fatalf("up: card %d, was %d", m.cardAt(w), at)
396+ for i := range len(w.Stash) {
397+ d.move(m, 0, 1)
398+ if want := min(at+i+1, len(w.Stash)-1); m.Current() != w || m.cardAt(w) != want {
399+ t.Fatalf("down %d: card %d, want %d", i, m.cardAt(w), want)
400+ }
398401 }
399- d.move(m, 0, 1)
400- d.move(m, 0, 1)
401- d.move(m, 0, 1)
402- if m.Current() != w || m.cardAt(w) != len(w.Stash)-1 {
403- t.Fatalf("down: card %d", m.cardAt(w))
404- }
405- d.move(m, 0, -1)
406402 picked := w.Stash[m.cardAt(w)].ID
407403 m.ToggleOverview()
408404 if focusedID(m) != picked || w.stashHidden {
@@ -411,6 +407,31 @@ func TestOverviewCascadeStashPile(t *testing.T) {
411407 })
412408 }
413409
410+ // Up from any card, whichever it was cycled to, leaves the pile at once.
411+ for _, d := range cascadeDrivers {
412+ t.Run(d.name+"/up", func(t *testing.T) {
413+ m := cascadeOverview()
414+ withStash(m, 3)
415+ w := m.Current()
416+ w.FocusID(10)
417+ m.ToggleOverview()
418+ d.move(m, 0, 1)
419+ d.move(m, 0, 1)
420+ if m.cardAt(w) < 0 {
421+ t.Fatal("setup: no card selected")
422+ }
423+ d.move(m, 0, -1)
424+ if m.Current() != w || m.cardAt(w) >= 0 || focusedID(m) != 10 || m.ov.selected != 10 {
425+ t.Fatalf("up: card %d, focus %d, selected %d", m.cardAt(w), focusedID(m), m.ov.selected)
426+ }
427+ // The pile is entered again from there.
428+ d.move(m, 0, 1)
429+ if m.cardAt(w) < 0 {
430+ t.Fatal("down after leaving did not enter the stash")
431+ }
432+ })
433+ }
434+
414435 c, _, sc, _ := stashRig(t, true)
415436 m := sc.mon
416437 w := m.Current()
@@ -423,13 +444,25 @@ func TestOverviewCascadeStashPile(t *testing.T) {
423444 t.Fatal("setup: no card selected")
424445 }
425446 at, before := m.cardAt(w), stashIDs(w)
426- c.swipeBegin(ports.SwipeBegin{Fingers: 3})
427- for range 3 {
428- c.swipeUpdate(ports.SwipeUpdate{DY: -40})
447+ swipeY := func(dy float64) {
448+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
449+ for range 3 {
450+ c.swipeUpdate(ports.SwipeUpdate{DY: dy})
451+ }
452+ c.swipeEnd(ports.SwipeEnd{})
429453 }
430- c.swipeEnd(ports.SwipeEnd{})
431- if m.Current() != w || m.cardAt(w) != at-1 {
432- t.Fatalf("swipe up: card %d, was %d", m.cardAt(w), at)
454+ // Fingers down walk the pile; fingers up leave it for the columns.
455+ swipeY(40)
456+ if want := min(at+1, len(w.Stash)-1); m.Current() != w || m.cardAt(w) != want {
457+ t.Fatalf("swipe down: card %d, want %d", m.cardAt(w), want)
458+ }
459+ swipeY(-40)
460+ if m.Current() != w || m.cardAt(w) >= 0 || m.ov.selected == 0 {
461+ t.Fatalf("swipe up: card %d, selected %d", m.cardAt(w), m.ov.selected)
462+ }
463+ swipeY(40)
464+ if m.cardAt(w) < 0 {
465+ t.Fatal("swipe down after leaving did not enter the stash")
433466 }
434467 c.swipeBegin(ports.SwipeBegin{Fingers: 3})
435468 for range 3 {
@@ -1017,3 +1050,204 @@ func TestOverviewCascadeScroll(t *testing.T) {
10171050 })
10181051 }
10191052 }
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+}
@@ -377,8 +377,9 @@ func TestOverviewCascadeKeys(t *testing.T) {
377 }377 }
378 }378 }
379 379
380-// T7b: with a stash card selected, up/down and the three-finger vertical380+// T7b: with a stash card selected in a cascade overview, down walks the pile
381-// swipe cycle the pile as in a vertical overview; confirming shows the picked window.381+// (stopping at its last card) and up leaves it for the last band, at the
382+// column it was entered from; confirming shows the picked window.
382 func TestOverviewCascadeStashPile(t *testing.T) {383 func TestOverviewCascadeStashPile(t *testing.T) {
383 for _, d := range cascadeDrivers {384 for _, d := range cascadeDrivers {
384 t.Run(d.name, func(t *testing.T) {385 t.Run(d.name, func(t *testing.T) {
@@ -392,17 +393,12 @@ func TestOverviewCascadeStashPile(t *testing.T) {
392 t.Fatalf("entered at %d", m.cardAt(w))393 t.Fatalf("entered at %d", m.cardAt(w))
393 }394 }
394 at := m.cardAt(w)395 at := m.cardAt(w)
395- d.move(m, 0, -1)396+ for i := range len(w.Stash) {
396- if m.Current() != w || m.cardAt(w) != at-1 {397+ d.move(m, 0, 1)
397- t.Fatalf("up: card %d, was %d", m.cardAt(w), at)398+ if want := min(at+i+1, len(w.Stash)-1); m.Current() != w || m.cardAt(w) != want {
399+ t.Fatalf("down %d: card %d, want %d", i, m.cardAt(w), want)
400+ }
398 }401 }
399- d.move(m, 0, 1)
400- d.move(m, 0, 1)
401- d.move(m, 0, 1)
402- if m.Current() != w || m.cardAt(w) != len(w.Stash)-1 {
403- t.Fatalf("down: card %d", m.cardAt(w))
404- }
405- d.move(m, 0, -1)
406 picked := w.Stash[m.cardAt(w)].ID402 picked := w.Stash[m.cardAt(w)].ID
407 m.ToggleOverview()403 m.ToggleOverview()
408 if focusedID(m) != picked || w.stashHidden {404 if focusedID(m) != picked || w.stashHidden {
@@ -411,6 +407,31 @@ func TestOverviewCascadeStashPile(t *testing.T) {
411 })407 })
412 }408 }
413 409
410+ // Up from any card, whichever it was cycled to, leaves the pile at once.
411+ for _, d := range cascadeDrivers {
412+ t.Run(d.name+"/up", func(t *testing.T) {
413+ m := cascadeOverview()
414+ withStash(m, 3)
415+ w := m.Current()
416+ w.FocusID(10)
417+ m.ToggleOverview()
418+ d.move(m, 0, 1)
419+ d.move(m, 0, 1)
420+ if m.cardAt(w) < 0 {
421+ t.Fatal("setup: no card selected")
422+ }
423+ d.move(m, 0, -1)
424+ if m.Current() != w || m.cardAt(w) >= 0 || focusedID(m) != 10 || m.ov.selected != 10 {
425+ t.Fatalf("up: card %d, focus %d, selected %d", m.cardAt(w), focusedID(m), m.ov.selected)
426+ }
427+ // The pile is entered again from there.
428+ d.move(m, 0, 1)
429+ if m.cardAt(w) < 0 {
430+ t.Fatal("down after leaving did not enter the stash")
431+ }
432+ })
433+ }
434+
414 c, _, sc, _ := stashRig(t, true)435 c, _, sc, _ := stashRig(t, true)
415 m := sc.mon436 m := sc.mon
416 w := m.Current()437 w := m.Current()
@@ -423,13 +444,25 @@ func TestOverviewCascadeStashPile(t *testing.T) {
423 t.Fatal("setup: no card selected")444 t.Fatal("setup: no card selected")
424 }445 }
425 at, before := m.cardAt(w), stashIDs(w)446 at, before := m.cardAt(w), stashIDs(w)
426- c.swipeBegin(ports.SwipeBegin{Fingers: 3})447+ swipeY := func(dy float64) {
427- for range 3 {448+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
428- c.swipeUpdate(ports.SwipeUpdate{DY: -40})449+ for range 3 {
450+ c.swipeUpdate(ports.SwipeUpdate{DY: dy})
451+ }
452+ c.swipeEnd(ports.SwipeEnd{})
429 }453 }
430- c.swipeEnd(ports.SwipeEnd{})454+ // Fingers down walk the pile; fingers up leave it for the columns.
431- if m.Current() != w || m.cardAt(w) != at-1 {455+ swipeY(40)
432- t.Fatalf("swipe up: card %d, was %d", m.cardAt(w), at)456+ if want := min(at+1, len(w.Stash)-1); m.Current() != w || m.cardAt(w) != want {
457+ t.Fatalf("swipe down: card %d, want %d", m.cardAt(w), want)
458+ }
459+ swipeY(-40)
460+ if m.Current() != w || m.cardAt(w) >= 0 || m.ov.selected == 0 {
461+ t.Fatalf("swipe up: card %d, selected %d", m.cardAt(w), m.ov.selected)
462+ }
463+ swipeY(40)
464+ if m.cardAt(w) < 0 {
465+ t.Fatal("swipe down after leaving did not enter the stash")
433 }466 }
434 c.swipeBegin(ports.SwipeBegin{Fingers: 3})467 c.swipeBegin(ports.SwipeBegin{Fingers: 3})
435 for range 3 {468 for range 3 {
@@ -1017,3 +1050,204 @@ func TestOverviewCascadeScroll(t *testing.T) {
1017 })1050 })
1018 }1051 }
1019 }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 23

Tags 10

↑↓ moveenter openesc close