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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 41 | 41 | // Peek is a stashed window peeking in beside the selected one, or an | |
| 42 | 42 | // overview preview of a neighbor workspace: dimmed. | |
| 43 | 43 | 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 | 48 | // Veil is the share (0..1) of the peek veil (Stash.Dim) the window | |
| 45 | 49 | // draws: 1 for a settled peek, less while a stash slide brings it to | |
| 46 | 50 | // 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 an | 41 | // 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 bool | 43 | 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 window | 48 | // 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 to | 49 | // 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 | |||
| 887 | 887 | } | |
| 888 | 888 | cards := make([][]Placement, n) | |
| 889 | 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 | 892 | step := w.cardStep() | |
| 893 | 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 | 1135 | // 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 | 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 | 1177 | 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}) | |
| 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 nil | |
| 1224 | + | return append(dst, pieces...) | |
| 1150 | 1225 | } | |
| 1151 | 1226 | ||
| 1152 | 1227 | // 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 width | 1135 | // 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.Rect | 1179 | + | 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 nil | 1224 | + | 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) { | |||
| 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 | + | } | |
| @@ -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 | + | } | |||