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
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 | |||
| 274 | 274 | ### Overview | |
| 275 | 275 | ||
| 276 | 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. | |
| @@ -274,3 +274,5 @@ The window must appear within 5 seconds, otherwise it opens normally. Apps that | |||||
| 274 | ### Overview | 274 | ### 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 { | |||
| 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 +118 -16
| @@ -423,14 +423,21 @@ func (m *Monitor) cycleCard(w *Workspace, d int) bool { | |||
| 423 | 423 | } | |
| 424 | 424 | ||
| 425 | 425 | // 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. | |
| 431 | 433 | func (m *Monitor) overviewLane(dy int) { | |
| 432 | 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 | 441 | return | |
| 435 | 442 | } | |
| 436 | 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 | 474 | // selectLaneColumn selects column i of w. | |
| 448 | 475 | func (m *Monitor) selectLaneColumn(w *Workspace, i int) { | |
| 449 | 476 | w.selectOverviewColumn(i) | |
| @@ -860,7 +887,7 @@ func (w *Workspace) pile(axis layoutAxis, slot Rect, dim bool, front int) []Plac | |||
| 860 | 887 | } | |
| 861 | 888 | cards := make([][]Placement, n) | |
| 862 | 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 | 892 | step := w.cardStep() | |
| 866 | 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 | 1135 | // 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 | 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 | 1177 | 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}) | |
| 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 nil | |
| 1224 | + | return append(dst, pieces...) | |
| 1123 | 1225 | } | |
| 1124 | 1226 | ||
| 1125 | 1227 | // 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 side | 425 | // 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 | 426 | + | // 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 not | 428 | + | // the last lane (leavePile); focus-workspace-prev/next cycle it both ways. |
| 429 | - | // the columns, enters the stash, which sits below. Covering floats stack | 429 | + | // 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 | return | 441 | 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 width | 1135 | // 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.Rect | 1179 | + | 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 nil | 1224 | + | 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) { | |||
| 377 | 377 | } | |
| 378 | 378 | } | |
| 379 | 379 | ||
| 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. | |
| 382 | 383 | func TestOverviewCascadeStashPile(t *testing.T) { | |
| 383 | 384 | for _, d := range cascadeDrivers { | |
| 384 | 385 | t.Run(d.name, func(t *testing.T) { | |
| @@ -392,17 +393,12 @@ func TestOverviewCascadeStashPile(t *testing.T) { | |||
| 392 | 393 | t.Fatalf("entered at %d", m.cardAt(w)) | |
| 393 | 394 | } | |
| 394 | 395 | 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 | + | } | |
| 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 | 402 | picked := w.Stash[m.cardAt(w)].ID | |
| 407 | 403 | m.ToggleOverview() | |
| 408 | 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 | 435 | c, _, sc, _ := stashRig(t, true) | |
| 415 | 436 | m := sc.mon | |
| 416 | 437 | w := m.Current() | |
| @@ -423,13 +444,25 @@ func TestOverviewCascadeStashPile(t *testing.T) { | |||
| 423 | 444 | t.Fatal("setup: no card selected") | |
| 424 | 445 | } | |
| 425 | 446 | 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{}) | |
| 429 | 453 | } | |
| 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") | |
| 433 | 466 | } | |
| 434 | 467 | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |
| 435 | 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 | + | } | |
| @@ -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 vertical | 380 | + | // 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)].ID | 402 | 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.mon | 436 | 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 | + | } | |||