feat(overview): unfold the cascade stash as a carousel centred on the selection
- commit
- 1c7d6c14d42b53abc1b45269bb742f050b19fbab
- author
- brice · · 2026-10-16 12:02 UTC
- parent
- af5dc26
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +333 -118
M docs/config.md +2 -2
| @@ -276,8 +276,8 @@ The window must appear within 5 seconds, otherwise it opens normally. Apps that | |||
| 276 | 276 | ||
| 277 | 277 | `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. | |
| 278 | 278 | ||
| 279 | - | A workspace's stash shows as a pile of cards. Selecting the pile unfolds it, so you see every stashed window: a row under the workspace with `layout.overflow = cascade`, a column on its left otherwise. Moves along the unfolded pile pick a card; a pile longer than the screen scrolls with the selection. Leaving it folds it again. | |
| 279 | + | A workspace's stash shows as a pile of cards. Selecting the pile unfolds it, so you see every stashed window: a carousel under the workspace with `layout.overflow = cascade`, a column on its left otherwise. The carousel keeps the selected card in the middle, the earlier cards on its left and the later ones on its right, wrapping round; a wider gap separates the newest card from the oldest. Moves along the unfolded pile pick a card; a pile longer than the screen scrolls with the selection. Leaving it folds it again. | |
| 280 | 280 | ||
| 281 | - | With `layout.overflow = cascade`, workspaces sit side by side, each band of columns is a lane, and the stash sits under the lanes. Down (or fingers down on the touchpad, with natural scroll off) moves to the lane below; past the last lane it unfolds the stash. Left/right walk the cards, then go to the neighbor workspace past either end. Up leaves the stash for the last lane, at the column you came from. | |
| 281 | + | With `layout.overflow = cascade`, workspaces sit side by side, each band of columns is a lane, and the stash sits under the lanes. Down (or fingers down on the touchpad, with natural scroll off) moves to the lane below; past the last lane it unfolds the stash. Left/right walk the cards round the carousel; with fewer than three cards, or on a workspace holding only stashed windows, they go to the neighbor workspace past either end. Up leaves the stash for the last lane, at the column you came from. | |
| 282 | 282 | ||
| 283 | 283 | Otherwise, left from the first column unfolds the stash. Up/down walk the cards and right goes back to the columns. | |
| @@ -276,8 +276,8 @@ The window must appear within 5 seconds, otherwise it opens normally. Apps that | |||||
| 276 | 276 | ||||
| 277 | `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 | `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. | ||
| 278 | 278 | ||||
| 279 | - | A workspace's stash shows as a pile of cards. Selecting the pile unfolds it, so you see every stashed window: a row under the workspace with `layout.overflow = cascade`, a column on its left otherwise. Moves along the unfolded pile pick a card; a pile longer than the screen scrolls with the selection. Leaving it folds it again. | 279 | + | A workspace's stash shows as a pile of cards. Selecting the pile unfolds it, so you see every stashed window: a carousel under the workspace with `layout.overflow = cascade`, a column on its left otherwise. The carousel keeps the selected card in the middle, the earlier cards on its left and the later ones on its right, wrapping round; a wider gap separates the newest card from the oldest. Moves along the unfolded pile pick a card; a pile longer than the screen scrolls with the selection. Leaving it folds it again. |
| 280 | 280 | ||||
| 281 | - | With `layout.overflow = cascade`, workspaces sit side by side, each band of columns is a lane, and the stash sits under the lanes. Down (or fingers down on the touchpad, with natural scroll off) moves to the lane below; past the last lane it unfolds the stash. Left/right walk the cards, then go to the neighbor workspace past either end. Up leaves the stash for the last lane, at the column you came from. | 281 | + | With `layout.overflow = cascade`, workspaces sit side by side, each band of columns is a lane, and the stash sits under the lanes. Down (or fingers down on the touchpad, with natural scroll off) moves to the lane below; past the last lane it unfolds the stash. Left/right walk the cards round the carousel; with fewer than three cards, or on a workspace holding only stashed windows, they go to the neighbor workspace past either end. Up leaves the stash for the last lane, at the column you came from. |
| 282 | 282 | ||||
| 283 | Otherwise, left from the first column unfolds the stash. Up/down walk the cards and right goes back to the columns. | 283 | Otherwise, left from the first column unfolds the stash. Up/down walk the cards and right goes back to the columns. | ||
M internal/core/overview.go +112 -31
| @@ -57,8 +57,9 @@ type rowSnapshot struct { | |||
| 57 | 57 | // (not selected), the stash's own selection is in front at | |
| 58 | 58 | // overviewCardZoom, up to overviewCards-1 others behind it, each | |
| 59 | 59 | // overviewCardStep further up and right, dimmed. Selected, the pile | |
| 60 | - | // unfolds along the overview axis into a strip of every card (unfold), the | |
| 61 | - | // selection framed, and the moves along that axis walk it. A vertical | |
| 60 | + | // unfolds along the overview axis (unfold), the selection framed, and the | |
| 61 | + | // moves along that axis walk it: a column of every card in a vertical | |
| 62 | + | // overview, a carousel centred on the selection in a horizontal one. A vertical | |
| 62 | 63 | // overview pins the pile to the left edge of its row, which takes the width | |
| 63 | 64 | // the pile leaves. A horizontal one centres it on its row, in a band | |
| 64 | 65 | // reserved under the selected lane (pileBand): the lane and the band are | |
| @@ -158,6 +159,15 @@ func (m *Monitor) cardAt(w *Workspace) int { | |||
| 158 | 159 | return -1 | |
| 159 | 160 | } | |
| 160 | 161 | ||
| 162 | + | // carouselCard is the selected card of w's stash when it unfolds as a | |
| 163 | + | // carousel (a horizontal overview), -1 otherwise. | |
| 164 | + | func (m *Monitor) carouselCard(w *Workspace) int { | |
| 165 | + | if !m.previewing() || m.overviewAxis() != horizontalAxis { | |
| 166 | + | return -1 | |
| 167 | + | } | |
| 168 | + | return m.cardAt(w) | |
| 169 | + | } | |
| 170 | + | ||
| 161 | 171 | // overviewTarget resolves the active preview to one window for validation | |
| 162 | 172 | // and window actions. Presentation state never falls back to a covering float. | |
| 163 | 173 | func (m *Monitor) overviewTarget() WindowID { | |
| @@ -480,18 +490,29 @@ func (m *Monitor) selectLaneColumn(w *Workspace, i int) { | |||
| 480 | 490 | ||
| 481 | 491 | // overviewAcross is the horizontal move of an overview whose workspaces sit | |
| 482 | 492 | // side by side (cascade): a column inside the selected lane, or a card of | |
| 483 | - | // the unfolded stash. Past the first column or card it goes to the previous | |
| 484 | - | // workspace; past the last, to the next workspace. Covering floats fan | |
| 493 | + | // the unfolded stash, which wraps round (carousel). Past the first column | |
| 494 | + | // it goes to the previous workspace; past the last, to the next workspace. | |
| 495 | + | // A carousel of fewer than three cards does not wrap, nor does the one of a | |
| 496 | + | // stash-only workspace, which has nothing above its pile: there the ends | |
| 497 | + | // change workspace like a lane's. | |
| 498 | + | // Covering floats fan | |
| 485 | 499 | // along this axis and are traversed before a workspace: the card on the | |
| 486 | 500 | // left is the one behind. | |
| 487 | 501 | func (m *Monitor) overviewAcross(dx int) { | |
| 488 | 502 | w := m.Current() | |
| 489 | 503 | if at := m.cardAt(w); at >= 0 { | |
| 490 | - | if next := at + dx; next >= 0 && next < len(w.Stash) { | |
| 504 | + | n, next := len(w.Stash), at+dx | |
| 505 | + | if next >= 0 && next < n { | |
| 491 | 506 | m.selectCard(w, next) | |
| 492 | 507 | return | |
| 493 | 508 | } | |
| 494 | - | m.overviewStep(dx) | |
| 509 | + | if _, k := w.stackHead(); k == 0 || n < 3 { | |
| 510 | + | // Nothing above the pile to leave it for, or too few | |
| 511 | + | // cards to wrap round: the ends are the way out. | |
| 512 | + | m.overviewStep(dx) | |
| 513 | + | return | |
| 514 | + | } | |
| 515 | + | m.selectCard(w, (next+n)%n) | |
| 495 | 516 | return | |
| 496 | 517 | } | |
| 497 | 518 | if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 { | |
| @@ -916,48 +937,108 @@ func (w *Workspace) pile(dst []Placement, axis layoutAxis, slot Rect, dim bool, | |||
| 916 | 937 | return dst | |
| 917 | 938 | } | |
| 918 | 939 | ||
| 919 | - | // unfold lays the stash of w out as a strip of every card along axis, from | |
| 920 | - | // the folded pile's card rect: a row in a horizontal overview, a column in | |
| 921 | - | // a vertical one. The strip is centred on the pile and kept in the overview | |
| 922 | - | // area. Longer than the area, it scrolls like a wide row: the selected card | |
| 923 | - | // at is centred, the strip stopping at the area's edges. The other cards | |
| 924 | - | // are dimmed; those out of the area are hidden. The cards are appended to | |
| 925 | - | // dst. | |
| 940 | + | // unfold lays the stash of w out from the folded pile's card rect. A | |
| 941 | + | // vertical overview lines every card up in a column (strip). A horizontal | |
| 942 | + | // one spreads them as a carousel (carousel). Cards other than the selected | |
| 943 | + | // one at are dimmed; those out of the overview area are hidden. The cards | |
| 944 | + | // are appended to dst. | |
| 926 | 945 | func (w *Workspace) unfold(dst []Placement, axis layoutAxis, folded Rect, at int) []Placement { | |
| 946 | + | if axis == horizontalAxis { | |
| 947 | + | return w.carousel(dst, folded, at) | |
| 948 | + | } | |
| 949 | + | return w.strip(dst, folded, at) | |
| 950 | + | } | |
| 951 | + | ||
| 952 | + | // strip lays the stash out as a column of every card, in stash order, | |
| 953 | + | // centred on the pile and kept in the overview area. Longer than the area, | |
| 954 | + | // it scrolls like a wide row: the selected card at is centred, the strip | |
| 955 | + | // stopping at the area's edges. | |
| 956 | + | func (w *Workspace) strip(dst []Placement, folded Rect, at int) []Placement { | |
| 927 | 957 | n := len(w.Stash) | |
| 928 | 958 | u := w.overviewArea() | |
| 929 | - | size, origin, lo, hi := folded.W, folded.X, u.X, u.X+u.W | |
| 930 | - | if axis == verticalAxis { | |
| 931 | - | size, origin, lo, hi = folded.H, folded.Y, u.Y, u.Y+u.H | |
| 932 | - | } | |
| 959 | + | size, lo, hi := folded.H, u.Y, u.Y+u.H | |
| 933 | 960 | stride := size + 2*w.cardStep() | |
| 934 | 961 | total := n*stride - (stride - size) | |
| 935 | - | start := origin + size/2 - total/2 | |
| 962 | + | start := folded.Y + size/2 - total/2 | |
| 936 | 963 | if total > hi-lo { | |
| 937 | - | start = origin + size/2 - at*stride - size/2 | |
| 964 | + | start = folded.Y - at*stride | |
| 938 | 965 | start = min(max(start, hi-total), lo) | |
| 939 | 966 | } else { | |
| 940 | 967 | start = min(max(start, lo), hi-total) | |
| 941 | 968 | } | |
| 942 | 969 | for i := range w.Stash { | |
| 943 | 970 | r := folded | |
| 944 | - | if axis == verticalAxis { | |
| 945 | - | r.Y = start + i*stride | |
| 946 | - | } else { | |
| 947 | - | r.X = start + i*stride | |
| 948 | - | } | |
| 949 | - | p := Placement{ID: w.Stash[i].ID, Rect: r, Preview: overviewCardZoom, Card: true, Focused: i == at} | |
| 950 | - | if i != at { | |
| 951 | - | p.peeking(1) | |
| 952 | - | } | |
| 953 | - | if !r.Overlaps(u) { | |
| 954 | - | p.Hidden, p.Preview, p.Focused = true, 0, false | |
| 971 | + | r.Y = start + i*stride | |
| 972 | + | dst = w.unfoldedCard(dst, i, r, at, u) | |
| 973 | + | } | |
| 974 | + | return dst | |
| 975 | + | } | |
| 976 | + | ||
| 977 | + | // carousel lays the stash out as a row centred on the selected card at, | |
| 978 | + | // under the pile, in stash order wrapping round: the cards before it on | |
| 979 | + | // the left, the ones after it on the right, the oldest following the | |
| 980 | + | // newest (carouselSlot). A wider gap marks that seam. When the row fits | |
| 981 | + | // the overview area it is shifted into it; else the cards past the area | |
| 982 | + | // are hidden. | |
| 983 | + | func (w *Workspace) carousel(dst []Placement, folded Rect, at int) []Placement { | |
| 984 | + | n := len(w.Stash) | |
| 985 | + | u := w.overviewArea() | |
| 986 | + | stride := folded.W + 2*w.cardStep() | |
| 987 | + | // The extra room at the seam, on top of the stride. | |
| 988 | + | gap := folded.W / 4 | |
| 989 | + | x := func(i int) int { | |
| 990 | + | s := carouselSlot(i, at, n) | |
| 991 | + | x := folded.X + s*stride | |
| 992 | + | switch { | |
| 993 | + | case s > 0 && at+s >= n: | |
| 994 | + | x += gap | |
| 995 | + | case s < 0 && at+s < 0: | |
| 996 | + | x -= gap | |
| 955 | 997 | } | |
| 956 | - | dst = append(dst, p) | |
| 998 | + | return x | |
| 999 | + | } | |
| 1000 | + | first, last := x((at+n-n/2)%n), x((at+(n-1)/2)%n) | |
| 1001 | + | shift := 0 | |
| 1002 | + | if total := last + folded.W - first; total <= u.W { | |
| 1003 | + | shift = min(max(first, u.X), u.X+u.W-total) - first | |
| 1004 | + | } | |
| 1005 | + | for i := range w.Stash { | |
| 1006 | + | r := folded | |
| 1007 | + | r.X = x(i) + shift | |
| 1008 | + | dst = w.unfoldedCard(dst, i, r, at, u) | |
| 957 | 1009 | } | |
| 958 | 1010 | return dst | |
| 959 | 1011 | } | |
| 960 | 1012 | ||
| 1013 | + | // carouselSlot is the place of stash card i in a carousel of n cards | |
| 1014 | + | // centred on card at: 0 for at, negative on the left, positive on the | |
| 1015 | + | // right. The left side takes the extra card of an even count. Fewer than | |
| 1016 | + | // three cards keep the stash order without wrapping round, so that each | |
| 1017 | + | // side has the card its move selects. | |
| 1018 | + | func carouselSlot(i, at, n int) int { | |
| 1019 | + | if n < 3 { | |
| 1020 | + | return i - at | |
| 1021 | + | } | |
| 1022 | + | s := (i - at + n) % n | |
| 1023 | + | if s > (n-1)/2 { | |
| 1024 | + | s -= n | |
| 1025 | + | } | |
| 1026 | + | return s | |
| 1027 | + | } | |
| 1028 | + | ||
| 1029 | + | // unfoldedCard appends stash card i of an unfolded pile at r to dst: framed | |
| 1030 | + | // when it is the selected one at, dimmed otherwise, hidden out of the area u. | |
| 1031 | + | func (w *Workspace) unfoldedCard(dst []Placement, i int, r Rect, at int, u Rect) []Placement { | |
| 1032 | + | p := Placement{ID: w.Stash[i].ID, Rect: r, Preview: overviewCardZoom, Card: true, Focused: i == at} | |
| 1033 | + | if i != at { | |
| 1034 | + | p.peeking(1) | |
| 1035 | + | } | |
| 1036 | + | if !r.Overlaps(u) { | |
| 1037 | + | p.Hidden, p.Preview, p.Focused = true, 0, false | |
| 1038 | + | } | |
| 1039 | + | return append(dst, p) | |
| 1040 | + | } | |
| 1041 | + | ||
| 961 | 1042 | // overviewZoom is the scale that fits every column of w in the usable | |
| 962 | 1043 | // width, within the overview bounds, in an overview along axis: only a | |
| 963 | 1044 | // vertical one sets the stash pile beside the row. | |
| @@ -57,8 +57,9 @@ type rowSnapshot struct { | |||||
| 57 | // (not selected), the stash's own selection is in front at | 57 | // (not selected), the stash's own selection is in front at | ||
| 58 | // overviewCardZoom, up to overviewCards-1 others behind it, each | 58 | // overviewCardZoom, up to overviewCards-1 others behind it, each | ||
| 59 | // overviewCardStep further up and right, dimmed. Selected, the pile | 59 | // overviewCardStep further up and right, dimmed. Selected, the pile | ||
| 60 | - | // unfolds along the overview axis into a strip of every card (unfold), the | 60 | + | // unfolds along the overview axis (unfold), the selection framed, and the |
| 61 | - | // selection framed, and the moves along that axis walk it. A vertical | 61 | + | // moves along that axis walk it: a column of every card in a vertical |
| 62 | + | // overview, a carousel centred on the selection in a horizontal one. A vertical | |||
| 62 | // overview pins the pile to the left edge of its row, which takes the width | 63 | // overview pins the pile to the left edge of its row, which takes the width | ||
| 63 | // the pile leaves. A horizontal one centres it on its row, in a band | 64 | // the pile leaves. A horizontal one centres it on its row, in a band | ||
| 64 | // reserved under the selected lane (pileBand): the lane and the band are | 65 | // reserved under the selected lane (pileBand): the lane and the band are | ||
| @@ -158,6 +159,15 @@ func (m *Monitor) cardAt(w *Workspace) int { | |||||
| 158 | return -1 | 159 | return -1 | ||
| 159 | } | 160 | } | ||
| 160 | 161 | ||||
| 162 | + | // carouselCard is the selected card of w's stash when it unfolds as a | |||
| 163 | + | // carousel (a horizontal overview), -1 otherwise. | |||
| 164 | + | func (m *Monitor) carouselCard(w *Workspace) int { | |||
| 165 | + | if !m.previewing() || m.overviewAxis() != horizontalAxis { | |||
| 166 | + | return -1 | |||
| 167 | + | } | |||
| 168 | + | return m.cardAt(w) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 161 | // overviewTarget resolves the active preview to one window for validation | 171 | // overviewTarget resolves the active preview to one window for validation | ||
| 162 | // and window actions. Presentation state never falls back to a covering float. | 172 | // and window actions. Presentation state never falls back to a covering float. | ||
| 163 | func (m *Monitor) overviewTarget() WindowID { | 173 | func (m *Monitor) overviewTarget() WindowID { | ||
| @@ -480,18 +490,29 @@ func (m *Monitor) selectLaneColumn(w *Workspace, i int) { | |||||
| 480 | 490 | ||||
| 481 | // overviewAcross is the horizontal move of an overview whose workspaces sit | 491 | // overviewAcross is the horizontal move of an overview whose workspaces sit | ||
| 482 | // side by side (cascade): a column inside the selected lane, or a card of | 492 | // side by side (cascade): a column inside the selected lane, or a card of | ||
| 483 | - | // the unfolded stash. Past the first column or card it goes to the previous | 493 | + | // the unfolded stash, which wraps round (carousel). Past the first column |
| 484 | - | // workspace; past the last, to the next workspace. Covering floats fan | 494 | + | // it goes to the previous workspace; past the last, to the next workspace. |
| 495 | + | // A carousel of fewer than three cards does not wrap, nor does the one of a | |||
| 496 | + | // stash-only workspace, which has nothing above its pile: there the ends | |||
| 497 | + | // change workspace like a lane's. | |||
| 498 | + | // Covering floats fan | |||
| 485 | // along this axis and are traversed before a workspace: the card on the | 499 | // along this axis and are traversed before a workspace: the card on the | ||
| 486 | // left is the one behind. | 500 | // left is the one behind. | ||
| 487 | func (m *Monitor) overviewAcross(dx int) { | 501 | func (m *Monitor) overviewAcross(dx int) { | ||
| 488 | w := m.Current() | 502 | w := m.Current() | ||
| 489 | if at := m.cardAt(w); at >= 0 { | 503 | if at := m.cardAt(w); at >= 0 { | ||
| 490 | - | if next := at + dx; next >= 0 && next < len(w.Stash) { | 504 | + | n, next := len(w.Stash), at+dx |
| 505 | + | if next >= 0 && next < n { | |||
| 491 | m.selectCard(w, next) | 506 | m.selectCard(w, next) | ||
| 492 | return | 507 | return | ||
| 493 | } | 508 | } | ||
| 494 | - | m.overviewStep(dx) | 509 | + | if _, k := w.stackHead(); k == 0 || n < 3 { |
| 510 | + | // Nothing above the pile to leave it for, or too few | |||
| 511 | + | // cards to wrap round: the ends are the way out. | |||
| 512 | + | m.overviewStep(dx) | |||
| 513 | + | return | |||
| 514 | + | } | |||
| 515 | + | m.selectCard(w, (next+n)%n) | |||
| 495 | return | 516 | return | ||
| 496 | } | 517 | } | ||
| 497 | if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 { | 518 | if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 { | ||
| @@ -916,48 +937,108 @@ func (w *Workspace) pile(dst []Placement, axis layoutAxis, slot Rect, dim bool, | |||||
| 916 | return dst | 937 | return dst | ||
| 917 | } | 938 | } | ||
| 918 | 939 | ||||
| 919 | - | // unfold lays the stash of w out as a strip of every card along axis, from | 940 | + | // unfold lays the stash of w out from the folded pile's card rect. A |
| 920 | - | // the folded pile's card rect: a row in a horizontal overview, a column in | 941 | + | // vertical overview lines every card up in a column (strip). A horizontal |
| 921 | - | // a vertical one. The strip is centred on the pile and kept in the overview | 942 | + | // one spreads them as a carousel (carousel). Cards other than the selected |
| 922 | - | // area. Longer than the area, it scrolls like a wide row: the selected card | 943 | + | // one at are dimmed; those out of the overview area are hidden. The cards |
| 923 | - | // at is centred, the strip stopping at the area's edges. The other cards | 944 | + | // are appended to dst. |
| 924 | - | // are dimmed; those out of the area are hidden. The cards are appended to | |||
| 925 | - | // dst. | |||
| 926 | func (w *Workspace) unfold(dst []Placement, axis layoutAxis, folded Rect, at int) []Placement { | 945 | func (w *Workspace) unfold(dst []Placement, axis layoutAxis, folded Rect, at int) []Placement { | ||
| 946 | + | if axis == horizontalAxis { | |||
| 947 | + | return w.carousel(dst, folded, at) | |||
| 948 | + | } | |||
| 949 | + | return w.strip(dst, folded, at) | |||
| 950 | + | } | |||
| 951 | + | ||||
| 952 | + | // strip lays the stash out as a column of every card, in stash order, | |||
| 953 | + | // centred on the pile and kept in the overview area. Longer than the area, | |||
| 954 | + | // it scrolls like a wide row: the selected card at is centred, the strip | |||
| 955 | + | // stopping at the area's edges. | |||
| 956 | + | func (w *Workspace) strip(dst []Placement, folded Rect, at int) []Placement { | |||
| 927 | n := len(w.Stash) | 957 | n := len(w.Stash) | ||
| 928 | u := w.overviewArea() | 958 | u := w.overviewArea() | ||
| 929 | - | size, origin, lo, hi := folded.W, folded.X, u.X, u.X+u.W | 959 | + | size, lo, hi := folded.H, u.Y, u.Y+u.H |
| 930 | - | if axis == verticalAxis { | |||
| 931 | - | size, origin, lo, hi = folded.H, folded.Y, u.Y, u.Y+u.H | |||
| 932 | - | } | |||
| 933 | stride := size + 2*w.cardStep() | 960 | stride := size + 2*w.cardStep() | ||
| 934 | total := n*stride - (stride - size) | 961 | total := n*stride - (stride - size) | ||
| 935 | - | start := origin + size/2 - total/2 | 962 | + | start := folded.Y + size/2 - total/2 |
| 936 | if total > hi-lo { | 963 | if total > hi-lo { | ||
| 937 | - | start = origin + size/2 - at*stride - size/2 | 964 | + | start = folded.Y - at*stride |
| 938 | start = min(max(start, hi-total), lo) | 965 | start = min(max(start, hi-total), lo) | ||
| 939 | } else { | 966 | } else { | ||
| 940 | start = min(max(start, lo), hi-total) | 967 | start = min(max(start, lo), hi-total) | ||
| 941 | } | 968 | } | ||
| 942 | for i := range w.Stash { | 969 | for i := range w.Stash { | ||
| 943 | r := folded | 970 | r := folded | ||
| 944 | - | if axis == verticalAxis { | 971 | + | r.Y = start + i*stride |
| 945 | - | r.Y = start + i*stride | 972 | + | dst = w.unfoldedCard(dst, i, r, at, u) |
| 946 | - | } else { | 973 | + | } |
| 947 | - | r.X = start + i*stride | 974 | + | return dst |
| 948 | - | } | 975 | + | } |
| 949 | - | p := Placement{ID: w.Stash[i].ID, Rect: r, Preview: overviewCardZoom, Card: true, Focused: i == at} | 976 | + | |
| 950 | - | if i != at { | 977 | + | // carousel lays the stash out as a row centred on the selected card at, |
| 951 | - | p.peeking(1) | 978 | + | // under the pile, in stash order wrapping round: the cards before it on |
| 952 | - | } | 979 | + | // the left, the ones after it on the right, the oldest following the |
| 953 | - | if !r.Overlaps(u) { | 980 | + | // newest (carouselSlot). A wider gap marks that seam. When the row fits |
| 954 | - | p.Hidden, p.Preview, p.Focused = true, 0, false | 981 | + | // the overview area it is shifted into it; else the cards past the area |
| 982 | + | // are hidden. | |||
| 983 | + | func (w *Workspace) carousel(dst []Placement, folded Rect, at int) []Placement { | |||
| 984 | + | n := len(w.Stash) | |||
| 985 | + | u := w.overviewArea() | |||
| 986 | + | stride := folded.W + 2*w.cardStep() | |||
| 987 | + | // The extra room at the seam, on top of the stride. | |||
| 988 | + | gap := folded.W / 4 | |||
| 989 | + | x := func(i int) int { | |||
| 990 | + | s := carouselSlot(i, at, n) | |||
| 991 | + | x := folded.X + s*stride | |||
| 992 | + | switch { | |||
| 993 | + | case s > 0 && at+s >= n: | |||
| 994 | + | x += gap | |||
| 995 | + | case s < 0 && at+s < 0: | |||
| 996 | + | x -= gap | |||
| 955 | } | 997 | } | ||
| 956 | - | dst = append(dst, p) | 998 | + | return x |
| 999 | + | } | |||
| 1000 | + | first, last := x((at+n-n/2)%n), x((at+(n-1)/2)%n) | |||
| 1001 | + | shift := 0 | |||
| 1002 | + | if total := last + folded.W - first; total <= u.W { | |||
| 1003 | + | shift = min(max(first, u.X), u.X+u.W-total) - first | |||
| 1004 | + | } | |||
| 1005 | + | for i := range w.Stash { | |||
| 1006 | + | r := folded | |||
| 1007 | + | r.X = x(i) + shift | |||
| 1008 | + | dst = w.unfoldedCard(dst, i, r, at, u) | |||
| 957 | } | 1009 | } | ||
| 958 | return dst | 1010 | return dst | ||
| 959 | } | 1011 | } | ||
| 960 | 1012 | ||||
| 1013 | + | // carouselSlot is the place of stash card i in a carousel of n cards | |||
| 1014 | + | // centred on card at: 0 for at, negative on the left, positive on the | |||
| 1015 | + | // right. The left side takes the extra card of an even count. Fewer than | |||
| 1016 | + | // three cards keep the stash order without wrapping round, so that each | |||
| 1017 | + | // side has the card its move selects. | |||
| 1018 | + | func carouselSlot(i, at, n int) int { | |||
| 1019 | + | if n < 3 { | |||
| 1020 | + | return i - at | |||
| 1021 | + | } | |||
| 1022 | + | s := (i - at + n) % n | |||
| 1023 | + | if s > (n-1)/2 { | |||
| 1024 | + | s -= n | |||
| 1025 | + | } | |||
| 1026 | + | return s | |||
| 1027 | + | } | |||
| 1028 | + | ||||
| 1029 | + | // unfoldedCard appends stash card i of an unfolded pile at r to dst: framed | |||
| 1030 | + | // when it is the selected one at, dimmed otherwise, hidden out of the area u. | |||
| 1031 | + | func (w *Workspace) unfoldedCard(dst []Placement, i int, r Rect, at int, u Rect) []Placement { | |||
| 1032 | + | p := Placement{ID: w.Stash[i].ID, Rect: r, Preview: overviewCardZoom, Card: true, Focused: i == at} | |||
| 1033 | + | if i != at { | |||
| 1034 | + | p.peeking(1) | |||
| 1035 | + | } | |||
| 1036 | + | if !r.Overlaps(u) { | |||
| 1037 | + | p.Hidden, p.Preview, p.Focused = true, 0, false | |||
| 1038 | + | } | |||
| 1039 | + | return append(dst, p) | |||
| 1040 | + | } | |||
| 1041 | + | ||||
| 961 | // overviewZoom is the scale that fits every column of w in the usable | 1042 | // overviewZoom is the scale that fits every column of w in the usable | ||
| 962 | // width, within the overview bounds, in an overview along axis: only a | 1043 | // width, within the overview bounds, in an overview along axis: only a | ||
| 963 | // vertical one sets the stash pile beside the row. | 1044 | // vertical one sets the stash pile beside the row. | ||
M internal/core/overview_cascade_test.go +7 -7
| @@ -344,7 +344,8 @@ func TestOverviewCascadeKeys(t *testing.T) { | |||
| 344 | 344 | // With a stash: left from the first column still goes to the | |
| 345 | 345 | // previous workspace. Down past the last band enters the | |
| 346 | 346 | // stash, up comes back to the band, and left past the first | |
| 347 | - | // card goes to the previous workspace. | |
| 347 | + | // card goes to the previous workspace: two cards do not | |
| 348 | + | // wrap round. | |
| 348 | 349 | withStash(m, 2) | |
| 349 | 350 | w.FocusID(7) | |
| 350 | 351 | m.ToggleOverview() | |
| @@ -473,10 +474,9 @@ func TestOverviewCascadeStashPile(t *testing.T) { | |||
| 473 | 474 | c.swipeUpdate(ports.SwipeUpdate{DX: 40}) | |
| 474 | 475 | } | |
| 475 | 476 | c.swipeEnd(ports.SwipeEnd{}) | |
| 476 | - | // A horizontal three-finger swipe is a workspace step, even over a | |
| 477 | - | // stash card. | |
| 478 | - | if m.Current() != m.Workspaces[1] { | |
| 479 | - | t.Fatalf("horizontal swipe over a stash card: workspace %d", indexOf(m.Workspaces, m.Current())) | |
| 477 | + | // A horizontal three-finger swipe walks the carousel, wrapping round. | |
| 478 | + | if want := (at + 1) % len(w.Stash); m.Current() != w || m.cardAt(w) != want { | |
| 479 | + | t.Fatalf("horizontal swipe over a stash card: workspace %d, card %d, want %d", indexOf(m.Workspaces, m.Current()), m.cardAt(w), want) | |
| 480 | 480 | } | |
| 481 | 481 | if got := stashIDs(w); !slices.Equal(got, before) { | |
| 482 | 482 | t.Fatal("stash reordered") | |
| @@ -930,8 +930,8 @@ func TestOverviewFingerLatchSurvivesAxisChange(t *testing.T) { | |||
| 930 | 930 | } | |
| 931 | 931 | } | |
| 932 | 932 | ||
| 933 | - | // A stash-only cascade workspace has no columns to select: right from its | |
| 934 | - | // pile goes straight to the next workspace. | |
| 933 | + | // A stash-only cascade workspace has nothing above its pile: right from its | |
| 934 | + | // pile goes straight to the next workspace, left to the previous one. | |
| 935 | 935 | func TestOverviewCascadeStashOnlyRight(t *testing.T) { | |
| 936 | 936 | m := overviewMonitor() | |
| 937 | 937 | m.Focus(1) | |
| @@ -344,7 +344,8 @@ func TestOverviewCascadeKeys(t *testing.T) { | |||||
| 344 | // With a stash: left from the first column still goes to the | 344 | // With a stash: left from the first column still goes to the | ||
| 345 | // previous workspace. Down past the last band enters the | 345 | // previous workspace. Down past the last band enters the | ||
| 346 | // stash, up comes back to the band, and left past the first | 346 | // stash, up comes back to the band, and left past the first | ||
| 347 | - | // card goes to the previous workspace. | 347 | + | // card goes to the previous workspace: two cards do not |
| 348 | + | // wrap round. | |||
| 348 | withStash(m, 2) | 349 | withStash(m, 2) | ||
| 349 | w.FocusID(7) | 350 | w.FocusID(7) | ||
| 350 | m.ToggleOverview() | 351 | m.ToggleOverview() | ||
| @@ -473,10 +474,9 @@ func TestOverviewCascadeStashPile(t *testing.T) { | |||||
| 473 | c.swipeUpdate(ports.SwipeUpdate{DX: 40}) | 474 | c.swipeUpdate(ports.SwipeUpdate{DX: 40}) | ||
| 474 | } | 475 | } | ||
| 475 | c.swipeEnd(ports.SwipeEnd{}) | 476 | c.swipeEnd(ports.SwipeEnd{}) | ||
| 476 | - | // A horizontal three-finger swipe is a workspace step, even over a | 477 | + | // A horizontal three-finger swipe walks the carousel, wrapping round. |
| 477 | - | // stash card. | 478 | + | if want := (at + 1) % len(w.Stash); m.Current() != w || m.cardAt(w) != want { |
| 478 | - | if m.Current() != m.Workspaces[1] { | 479 | + | t.Fatalf("horizontal swipe over a stash card: workspace %d, card %d, want %d", indexOf(m.Workspaces, m.Current()), m.cardAt(w), want) |
| 479 | - | t.Fatalf("horizontal swipe over a stash card: workspace %d", indexOf(m.Workspaces, m.Current())) | |||
| 480 | } | 480 | } | ||
| 481 | if got := stashIDs(w); !slices.Equal(got, before) { | 481 | if got := stashIDs(w); !slices.Equal(got, before) { | ||
| 482 | t.Fatal("stash reordered") | 482 | t.Fatal("stash reordered") | ||
| @@ -930,8 +930,8 @@ func TestOverviewFingerLatchSurvivesAxisChange(t *testing.T) { | |||||
| 930 | } | 930 | } | ||
| 931 | } | 931 | } | ||
| 932 | 932 | ||||
| 933 | - | // A stash-only cascade workspace has no columns to select: right from its | 933 | + | // A stash-only cascade workspace has nothing above its pile: right from its |
| 934 | - | // pile goes straight to the next workspace. | 934 | + | // pile goes straight to the next workspace, left to the previous one. |
| 935 | func TestOverviewCascadeStashOnlyRight(t *testing.T) { | 935 | func TestOverviewCascadeStashOnlyRight(t *testing.T) { | ||
| 936 | m := overviewMonitor() | 936 | m := overviewMonitor() | ||
| 937 | m.Focus(1) | 937 | m.Focus(1) | ||
M internal/core/overview_unfold_test.go +146 -78
| @@ -14,9 +14,10 @@ func shownCards(t *testing.T, w *Workspace, ps []Placement) []Placement { | |||
| 14 | 14 | return out | |
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | - | // Selected, the cascade pile unfolds into a row under the lane: every card | |
| 18 | - | // side by side, in stash order, none over another, the selection the only | |
| 19 | - | // framed one. Left and right walk it. | |
| 17 | + | // Selected, the cascade pile unfolds into a carousel under the lane: every | |
| 18 | + | // card side by side, none over another, the selection the only framed one, | |
| 19 | + | // in the middle. Left and right walk it, wrapping round, without leaving | |
| 20 | + | // the workspace. | |
| 20 | 21 | func TestOverviewCascadeStashUnfolds(t *testing.T) { | |
| 21 | 22 | for _, d := range cascadeDrivers { | |
| 22 | 23 | t.Run(d.name, func(t *testing.T) { | |
| @@ -36,42 +37,88 @@ func TestOverviewCascadeStashUnfolds(t *testing.T) { | |||
| 36 | 37 | t.Fatalf("%d cards shown, want 3", len(cards)) | |
| 37 | 38 | } | |
| 38 | 39 | lane := previewOf(t, ps, 10).Rect | |
| 39 | - | for i, c := range cards { | |
| 40 | + | // Left to right: the card before the selection, the | |
| 41 | + | // selection, the one after it (wrapping round). | |
| 42 | + | row := []Placement{cards[(at+2)%3], cards[at], cards[(at+1)%3]} | |
| 43 | + | for i, c := range row { | |
| 40 | 44 | if c.Rect.Y <= lane.Y+lane.H { | |
| 41 | 45 | t.Fatalf("card %d %v not under the lane %v", c.ID, c.Rect, lane) | |
| 42 | 46 | } | |
| 43 | - | if i > 0 && (c.Rect.Y != cards[0].Rect.Y || c.Rect.X < cards[i-1].Rect.X+cards[i-1].Rect.W) { | |
| 44 | - | t.Fatalf("cards not side by side: %v then %v", cards[i-1].Rect, c.Rect) | |
| 47 | + | if i > 0 && (c.Rect.Y != row[0].Rect.Y || c.Rect.X < row[i-1].Rect.X+row[i-1].Rect.W) { | |
| 48 | + | t.Fatalf("cards not side by side: %v then %v", row[i-1].Rect, c.Rect) | |
| 45 | 49 | } | |
| 46 | - | if c.Focused != (i == at) { | |
| 47 | - | t.Fatalf("card %d focused %v, selection %d", i, c.Focused, at) | |
| 50 | + | if c.Focused != (i == 1) { | |
| 51 | + | t.Fatalf("card %d focused %v, selection %d", c.ID, c.Focused, at) | |
| 48 | 52 | } | |
| 49 | 53 | } | |
| 50 | - | for i := range 2 { | |
| 54 | + | for i := range 4 { | |
| 51 | 55 | d.move(m, -1, 0) | |
| 52 | - | if want := max(at-i-1, 0); m.Current() != w || m.cardAt(w) != want { | |
| 56 | + | if want := (at - i - 1 + 6) % 3; m.Current() != w || m.cardAt(w) != want { | |
| 53 | 57 | t.Fatalf("left %d: card %d, want %d", i, m.cardAt(w), want) | |
| 54 | 58 | } | |
| 55 | - | if at-i-1 < 0 { | |
| 56 | - | break | |
| 57 | - | } | |
| 58 | 59 | } | |
| 59 | - | for m.cardAt(w) < len(w.Stash)-1 { | |
| 60 | + | for i := range 4 { | |
| 60 | 61 | before := m.cardAt(w) | |
| 61 | 62 | d.move(m, 1, 0) | |
| 62 | - | if m.cardAt(w) != before+1 { | |
| 63 | - | t.Fatalf("right: card %d, was %d", m.cardAt(w), before) | |
| 63 | + | if m.Current() != w || m.cardAt(w) != (before+1)%3 { | |
| 64 | + | t.Fatalf("right %d: card %d, was %d", i, m.cardAt(w), before) | |
| 64 | 65 | } | |
| 65 | 66 | } | |
| 66 | - | // Right past the last card goes to the next workspace. | |
| 67 | - | d.move(m, 1, 0) | |
| 68 | - | if m.Current() != m.Workspaces[2] { | |
| 69 | - | t.Fatal("right past the last card did not change workspace") | |
| 70 | - | } | |
| 71 | 67 | }) | |
| 72 | 68 | } | |
| 73 | 69 | } | |
| 74 | 70 | ||
| 71 | + | // Fewer than three cards keep the stash order without wrapping round: each | |
| 72 | + | // side has the card its move selects, and past the ends the selection | |
| 73 | + | // changes workspace, as from a lane. | |
| 74 | + | func TestOverviewCascadeSmallCarousel(t *testing.T) { | |
| 75 | + | for n := 1; n <= 2; n++ { | |
| 76 | + | m := cascadeOverview() | |
| 77 | + | withStash(m, n) | |
| 78 | + | w := m.Current() | |
| 79 | + | w.FocusID(10) | |
| 80 | + | m.ToggleOverview() | |
| 81 | + | m.selectCard(w, 0) | |
| 82 | + | if n == 2 { | |
| 83 | + | ps := m.Layout() | |
| 84 | + | if a, b := previewOf(t, ps, w.Stash[0].ID).Rect.X, previewOf(t, ps, w.Stash[1].ID).Rect.X; b <= a { | |
| 85 | + | t.Fatalf("2 cards: the next card at %d, not right of the selection at %d", b, a) | |
| 86 | + | } | |
| 87 | + | m.OverviewMove(1, 0) | |
| 88 | + | if m.Current() != w || m.cardAt(w) != 1 { | |
| 89 | + | t.Fatalf("2 cards: right selected card %d", m.cardAt(w)) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | m.OverviewMove(1, 0) | |
| 93 | + | if m.Current() != m.Workspaces[2] { | |
| 94 | + | t.Fatalf("%d cards: right past the last card stayed on workspace %d", n, indexOf(m.Workspaces, m.Current())) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // The carousel keeps the stash order, wrapping round: the stride between | |
| 100 | + | // neighbors is even, wider at the seam between the newest card and the | |
| 101 | + | // oldest. | |
| 102 | + | func TestOverviewCascadeCarouselSeam(t *testing.T) { | |
| 103 | + | m := cascadeOverview() | |
| 104 | + | withStash(m, 5) | |
| 105 | + | w := m.Current() | |
| 106 | + | m.ToggleOverview() | |
| 107 | + | m.selectCard(w, 4) | |
| 108 | + | ps := m.Layout() | |
| 109 | + | x := func(i int) int { return previewOf(t, ps, w.Stash[i].ID).Rect.X } | |
| 110 | + | stride := x(4) - x(3) | |
| 111 | + | if stride <= 0 || x(3)-x(2) != stride { | |
| 112 | + | t.Fatalf("left of the newest card: %d %d %d", x(2), x(3), x(4)) | |
| 113 | + | } | |
| 114 | + | if seam := x(0) - x(4); seam <= stride { | |
| 115 | + | t.Fatalf("seam %d not wider than the stride %d", seam, stride) | |
| 116 | + | } | |
| 117 | + | if x(1)-x(0) != stride { | |
| 118 | + | t.Fatalf("right of the seam: %d %d", x(0), x(1)) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 75 | 122 | // Down past the last lane enters the stash at its own selection; up leaves | |
| 76 | 123 | // it, and the pile folds again. | |
| 77 | 124 | func TestOverviewCascadeStashFolds(t *testing.T) { | |
| @@ -97,65 +144,86 @@ func TestOverviewCascadeStashFolds(t *testing.T) { | |||
| 97 | 144 | } | |
| 98 | 145 | } | |
| 99 | 146 | ||
| 100 | - | // A stash longer than the area scrolls like a wide row, on either axis: | |
| 101 | - | // every card is reached in order, the selection stays inside the area and | |
| 102 | - | // is centred unless the strip stops at an edge of it, and cards past the | |
| 103 | - | // area are hidden. | |
| 147 | + | // A vertical overview's stash longer than the area scrolls like a wide | |
| 148 | + | // row: every card is reached in order, the selection stays inside the area | |
| 149 | + | // and is centred unless the strip stops at an edge of it, and cards past | |
| 150 | + | // the area are hidden. | |
| 104 | 151 | func TestOverviewStashUnfoldScrolls(t *testing.T) { | |
| 105 | - | for _, axis := range []layoutAxis{horizontalAxis, verticalAxis} { | |
| 106 | - | t.Run(map[layoutAxis]string{horizontalAxis: "cascade", verticalAxis: "scroll"}[axis], func(t *testing.T) { | |
| 107 | - | m := cascadeOverview() | |
| 108 | - | w := m.Current() | |
| 109 | - | if axis == verticalAxis { | |
| 110 | - | w.Overflow = OverflowScroll | |
| 111 | - | } | |
| 112 | - | withStash(m, 12) | |
| 113 | - | m.ToggleOverview() | |
| 114 | - | if m.overviewAxis() != axis { | |
| 115 | - | t.Fatal("setup: wrong overview axis") | |
| 116 | - | } | |
| 117 | - | m.selectCard(w, 0) | |
| 118 | - | u := w.overviewArea() | |
| 119 | - | pos := func(r Rect) (int, int, int, int) { | |
| 120 | - | if axis == verticalAxis { | |
| 121 | - | return r.Y, r.H, u.Y, u.Y + u.H | |
| 122 | - | } | |
| 123 | - | return r.X, r.W, u.X, u.X + u.W | |
| 124 | - | } | |
| 125 | - | centred, hidden := false, false | |
| 126 | - | for i := range len(w.Stash) { | |
| 127 | - | if m.cardAt(w) != i { | |
| 128 | - | t.Fatalf("step %d: card %d", i, m.cardAt(w)) | |
| 129 | - | } | |
| 130 | - | ps := m.Layout() | |
| 131 | - | sel := previewOf(t, ps, w.Stash[i].ID) | |
| 132 | - | at, size, lo, hi := pos(sel.Rect) | |
| 133 | - | if sel.Hidden || !sel.Focused || at < lo || at+size > hi { | |
| 134 | - | t.Fatalf("card %d off the area: %+v in %v", i, sel, u) | |
| 135 | - | } | |
| 136 | - | first, _, _, _ := pos(previewOf(t, ps, w.Stash[0].ID).Rect) | |
| 137 | - | last, _, _, _ := pos(previewOf(t, ps, w.Stash[len(w.Stash)-1].ID).Rect) | |
| 138 | - | if first > lo || last+size < hi { | |
| 139 | - | t.Fatalf("card %d: strip %d..%d does not fill the area %d..%d", i, first, last+size, lo, hi) | |
| 140 | - | } | |
| 141 | - | if mid := at + size/2; abs(mid-(lo+hi)/2) <= 1 { | |
| 142 | - | centred = true | |
| 143 | - | } else if first != lo && last+size != hi { | |
| 144 | - | t.Fatalf("card %d at %d, neither centred on %d nor clamped", i, mid, (lo+hi)/2) | |
| 145 | - | } | |
| 146 | - | for _, e := range w.Stash { | |
| 147 | - | hidden = hidden || previewOf(t, ps, e.ID).Hidden | |
| 148 | - | } | |
| 149 | - | if axis == verticalAxis { | |
| 150 | - | m.OverviewMove(0, 1) | |
| 151 | - | } else if i < len(w.Stash)-1 { | |
| 152 | - | m.OverviewMove(1, 0) | |
| 153 | - | } | |
| 154 | - | } | |
| 155 | - | if !hidden || !centred { | |
| 156 | - | t.Fatalf("12 cards: hidden %v, centred %v; the test does not scroll", hidden, centred) | |
| 152 | + | m := cascadeOverview() | |
| 153 | + | w := m.Current() | |
| 154 | + | w.Overflow = OverflowScroll | |
| 155 | + | withStash(m, 12) | |
| 156 | + | m.ToggleOverview() | |
| 157 | + | if m.overviewAxis() != verticalAxis { | |
| 158 | + | t.Fatal("setup: wrong overview axis") | |
| 159 | + | } | |
| 160 | + | m.selectCard(w, 0) | |
| 161 | + | u := w.overviewArea() | |
| 162 | + | lo, hi := u.Y, u.Y+u.H | |
| 163 | + | centred, hidden := false, false | |
| 164 | + | for i := range len(w.Stash) { | |
| 165 | + | if m.cardAt(w) != i { | |
| 166 | + | t.Fatalf("step %d: card %d", i, m.cardAt(w)) | |
| 167 | + | } | |
| 168 | + | ps := m.Layout() | |
| 169 | + | sel := previewOf(t, ps, w.Stash[i].ID) | |
| 170 | + | at, size := sel.Rect.Y, sel.Rect.H | |
| 171 | + | if sel.Hidden || !sel.Focused || at < lo || at+size > hi { | |
| 172 | + | t.Fatalf("card %d off the area: %+v in %v", i, sel, u) | |
| 173 | + | } | |
| 174 | + | first := previewOf(t, ps, w.Stash[0].ID).Rect.Y | |
| 175 | + | last := previewOf(t, ps, w.Stash[len(w.Stash)-1].ID).Rect.Y | |
| 176 | + | if first > lo || last+size < hi { | |
| 177 | + | t.Fatalf("card %d: strip %d..%d does not fill the area %d..%d", i, first, last+size, lo, hi) | |
| 178 | + | } | |
| 179 | + | if mid := at + size/2; abs(mid-(lo+hi)/2) <= 1 { | |
| 180 | + | centred = true | |
| 181 | + | } else if first != lo && last+size != hi { | |
| 182 | + | t.Fatalf("card %d at %d, neither centred on %d nor clamped", i, mid, (lo+hi)/2) | |
| 183 | + | } | |
| 184 | + | for _, e := range w.Stash { | |
| 185 | + | hidden = hidden || previewOf(t, ps, e.ID).Hidden | |
| 186 | + | } | |
| 187 | + | m.OverviewMove(0, 1) | |
| 188 | + | } | |
| 189 | + | if !hidden || !centred { | |
| 190 | + | t.Fatalf("12 cards: hidden %v, centred %v; the test does not scroll", hidden, centred) | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // A carousel wider than the area keeps every selection centred in it, with | |
| 195 | + | // cards on both sides; the cards past the area are hidden. | |
| 196 | + | func TestOverviewCascadeCarouselScrolls(t *testing.T) { | |
| 197 | + | m := cascadeOverview() | |
| 198 | + | w := m.Current() | |
| 199 | + | withStash(m, 12) | |
| 200 | + | m.ToggleOverview() | |
| 201 | + | if m.overviewAxis() != horizontalAxis { | |
| 202 | + | t.Fatal("setup: wrong overview axis") | |
| 203 | + | } | |
| 204 | + | m.selectCard(w, 11) | |
| 205 | + | u := w.overviewArea() | |
| 206 | + | for step := range len(w.Stash) + 1 { | |
| 207 | + | i := m.cardAt(w) | |
| 208 | + | ps := m.Layout() | |
| 209 | + | sel := previewOf(t, ps, w.Stash[i].ID) | |
| 210 | + | if sel.Hidden || !sel.Focused || abs(sel.Rect.X+sel.Rect.W/2-(u.X+u.W/2)) > 1 { | |
| 211 | + | t.Fatalf("step %d: card %d not centred: %+v in %v", step, i, sel, u) | |
| 212 | + | } | |
| 213 | + | left, right, hidden := false, false, false | |
| 214 | + | for _, e := range w.Stash { | |
| 215 | + | p := previewOf(t, ps, e.ID) | |
| 216 | + | if p.Hidden { | |
| 217 | + | hidden = true | |
| 218 | + | continue | |
| 157 | 219 | } | |
| 158 | - | }) | |
| 220 | + | left = left || p.Rect.X < sel.Rect.X | |
| 221 | + | right = right || p.Rect.X > sel.Rect.X | |
| 222 | + | } | |
| 223 | + | if !left || !right || !hidden { | |
| 224 | + | t.Fatalf("step %d: cards left %v, right %v, hidden %v", step, left, right, hidden) | |
| 225 | + | } | |
| 226 | + | m.OverviewMove(1, 0) | |
| 159 | 227 | } | |
| 160 | 228 | } | |
| 161 | 229 | ||
| @@ -14,9 +14,10 @@ func shownCards(t *testing.T, w *Workspace, ps []Placement) []Placement { | |||||
| 14 | return out | 14 | return out | ||
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
| 17 | - | // Selected, the cascade pile unfolds into a row under the lane: every card | 17 | + | // Selected, the cascade pile unfolds into a carousel under the lane: every |
| 18 | - | // side by side, in stash order, none over another, the selection the only | 18 | + | // card side by side, none over another, the selection the only framed one, |
| 19 | - | // framed one. Left and right walk it. | 19 | + | // in the middle. Left and right walk it, wrapping round, without leaving |
| 20 | + | // the workspace. | |||
| 20 | func TestOverviewCascadeStashUnfolds(t *testing.T) { | 21 | func TestOverviewCascadeStashUnfolds(t *testing.T) { | ||
| 21 | for _, d := range cascadeDrivers { | 22 | for _, d := range cascadeDrivers { | ||
| 22 | t.Run(d.name, func(t *testing.T) { | 23 | t.Run(d.name, func(t *testing.T) { | ||
| @@ -36,42 +37,88 @@ func TestOverviewCascadeStashUnfolds(t *testing.T) { | |||||
| 36 | t.Fatalf("%d cards shown, want 3", len(cards)) | 37 | t.Fatalf("%d cards shown, want 3", len(cards)) | ||
| 37 | } | 38 | } | ||
| 38 | lane := previewOf(t, ps, 10).Rect | 39 | lane := previewOf(t, ps, 10).Rect | ||
| 39 | - | for i, c := range cards { | 40 | + | // Left to right: the card before the selection, the |
| 41 | + | // selection, the one after it (wrapping round). | |||
| 42 | + | row := []Placement{cards[(at+2)%3], cards[at], cards[(at+1)%3]} | |||
| 43 | + | for i, c := range row { | |||
| 40 | if c.Rect.Y <= lane.Y+lane.H { | 44 | if c.Rect.Y <= lane.Y+lane.H { | ||
| 41 | t.Fatalf("card %d %v not under the lane %v", c.ID, c.Rect, lane) | 45 | t.Fatalf("card %d %v not under the lane %v", c.ID, c.Rect, lane) | ||
| 42 | } | 46 | } | ||
| 43 | - | if i > 0 && (c.Rect.Y != cards[0].Rect.Y || c.Rect.X < cards[i-1].Rect.X+cards[i-1].Rect.W) { | 47 | + | if i > 0 && (c.Rect.Y != row[0].Rect.Y || c.Rect.X < row[i-1].Rect.X+row[i-1].Rect.W) { |
| 44 | - | t.Fatalf("cards not side by side: %v then %v", cards[i-1].Rect, c.Rect) | 48 | + | t.Fatalf("cards not side by side: %v then %v", row[i-1].Rect, c.Rect) |
| 45 | } | 49 | } | ||
| 46 | - | if c.Focused != (i == at) { | 50 | + | if c.Focused != (i == 1) { |
| 47 | - | t.Fatalf("card %d focused %v, selection %d", i, c.Focused, at) | 51 | + | t.Fatalf("card %d focused %v, selection %d", c.ID, c.Focused, at) |
| 48 | } | 52 | } | ||
| 49 | } | 53 | } | ||
| 50 | - | for i := range 2 { | 54 | + | for i := range 4 { |
| 51 | d.move(m, -1, 0) | 55 | d.move(m, -1, 0) | ||
| 52 | - | if want := max(at-i-1, 0); m.Current() != w || m.cardAt(w) != want { | 56 | + | if want := (at - i - 1 + 6) % 3; m.Current() != w || m.cardAt(w) != want { |
| 53 | t.Fatalf("left %d: card %d, want %d", i, m.cardAt(w), want) | 57 | t.Fatalf("left %d: card %d, want %d", i, m.cardAt(w), want) | ||
| 54 | } | 58 | } | ||
| 55 | - | if at-i-1 < 0 { | |||
| 56 | - | break | |||
| 57 | - | } | |||
| 58 | } | 59 | } | ||
| 59 | - | for m.cardAt(w) < len(w.Stash)-1 { | 60 | + | for i := range 4 { |
| 60 | before := m.cardAt(w) | 61 | before := m.cardAt(w) | ||
| 61 | d.move(m, 1, 0) | 62 | d.move(m, 1, 0) | ||
| 62 | - | if m.cardAt(w) != before+1 { | 63 | + | if m.Current() != w || m.cardAt(w) != (before+1)%3 { |
| 63 | - | t.Fatalf("right: card %d, was %d", m.cardAt(w), before) | 64 | + | t.Fatalf("right %d: card %d, was %d", i, m.cardAt(w), before) |
| 64 | } | 65 | } | ||
| 65 | } | 66 | } | ||
| 66 | - | // Right past the last card goes to the next workspace. | |||
| 67 | - | d.move(m, 1, 0) | |||
| 68 | - | if m.Current() != m.Workspaces[2] { | |||
| 69 | - | t.Fatal("right past the last card did not change workspace") | |||
| 70 | - | } | |||
| 71 | }) | 67 | }) | ||
| 72 | } | 68 | } | ||
| 73 | } | 69 | } | ||
| 74 | 70 | ||||
| 71 | + | // Fewer than three cards keep the stash order without wrapping round: each | |||
| 72 | + | // side has the card its move selects, and past the ends the selection | |||
| 73 | + | // changes workspace, as from a lane. | |||
| 74 | + | func TestOverviewCascadeSmallCarousel(t *testing.T) { | |||
| 75 | + | for n := 1; n <= 2; n++ { | |||
| 76 | + | m := cascadeOverview() | |||
| 77 | + | withStash(m, n) | |||
| 78 | + | w := m.Current() | |||
| 79 | + | w.FocusID(10) | |||
| 80 | + | m.ToggleOverview() | |||
| 81 | + | m.selectCard(w, 0) | |||
| 82 | + | if n == 2 { | |||
| 83 | + | ps := m.Layout() | |||
| 84 | + | if a, b := previewOf(t, ps, w.Stash[0].ID).Rect.X, previewOf(t, ps, w.Stash[1].ID).Rect.X; b <= a { | |||
| 85 | + | t.Fatalf("2 cards: the next card at %d, not right of the selection at %d", b, a) | |||
| 86 | + | } | |||
| 87 | + | m.OverviewMove(1, 0) | |||
| 88 | + | if m.Current() != w || m.cardAt(w) != 1 { | |||
| 89 | + | t.Fatalf("2 cards: right selected card %d", m.cardAt(w)) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | m.OverviewMove(1, 0) | |||
| 93 | + | if m.Current() != m.Workspaces[2] { | |||
| 94 | + | t.Fatalf("%d cards: right past the last card stayed on workspace %d", n, indexOf(m.Workspaces, m.Current())) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // The carousel keeps the stash order, wrapping round: the stride between | |||
| 100 | + | // neighbors is even, wider at the seam between the newest card and the | |||
| 101 | + | // oldest. | |||
| 102 | + | func TestOverviewCascadeCarouselSeam(t *testing.T) { | |||
| 103 | + | m := cascadeOverview() | |||
| 104 | + | withStash(m, 5) | |||
| 105 | + | w := m.Current() | |||
| 106 | + | m.ToggleOverview() | |||
| 107 | + | m.selectCard(w, 4) | |||
| 108 | + | ps := m.Layout() | |||
| 109 | + | x := func(i int) int { return previewOf(t, ps, w.Stash[i].ID).Rect.X } | |||
| 110 | + | stride := x(4) - x(3) | |||
| 111 | + | if stride <= 0 || x(3)-x(2) != stride { | |||
| 112 | + | t.Fatalf("left of the newest card: %d %d %d", x(2), x(3), x(4)) | |||
| 113 | + | } | |||
| 114 | + | if seam := x(0) - x(4); seam <= stride { | |||
| 115 | + | t.Fatalf("seam %d not wider than the stride %d", seam, stride) | |||
| 116 | + | } | |||
| 117 | + | if x(1)-x(0) != stride { | |||
| 118 | + | t.Fatalf("right of the seam: %d %d", x(0), x(1)) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 75 | // Down past the last lane enters the stash at its own selection; up leaves | 122 | // Down past the last lane enters the stash at its own selection; up leaves | ||
| 76 | // it, and the pile folds again. | 123 | // it, and the pile folds again. | ||
| 77 | func TestOverviewCascadeStashFolds(t *testing.T) { | 124 | func TestOverviewCascadeStashFolds(t *testing.T) { | ||
| @@ -97,65 +144,86 @@ func TestOverviewCascadeStashFolds(t *testing.T) { | |||||
| 97 | } | 144 | } | ||
| 98 | } | 145 | } | ||
| 99 | 146 | ||||
| 100 | - | // A stash longer than the area scrolls like a wide row, on either axis: | 147 | + | // A vertical overview's stash longer than the area scrolls like a wide |
| 101 | - | // every card is reached in order, the selection stays inside the area and | 148 | + | // row: every card is reached in order, the selection stays inside the area |
| 102 | - | // is centred unless the strip stops at an edge of it, and cards past the | 149 | + | // and is centred unless the strip stops at an edge of it, and cards past |
| 103 | - | // area are hidden. | 150 | + | // the area are hidden. |
| 104 | func TestOverviewStashUnfoldScrolls(t *testing.T) { | 151 | func TestOverviewStashUnfoldScrolls(t *testing.T) { | ||
| 105 | - | for _, axis := range []layoutAxis{horizontalAxis, verticalAxis} { | 152 | + | m := cascadeOverview() |
| 106 | - | t.Run(map[layoutAxis]string{horizontalAxis: "cascade", verticalAxis: "scroll"}[axis], func(t *testing.T) { | 153 | + | w := m.Current() |
| 107 | - | m := cascadeOverview() | 154 | + | w.Overflow = OverflowScroll |
| 108 | - | w := m.Current() | 155 | + | withStash(m, 12) |
| 109 | - | if axis == verticalAxis { | 156 | + | m.ToggleOverview() |
| 110 | - | w.Overflow = OverflowScroll | 157 | + | if m.overviewAxis() != verticalAxis { |
| 111 | - | } | 158 | + | t.Fatal("setup: wrong overview axis") |
| 112 | - | withStash(m, 12) | 159 | + | } |
| 113 | - | m.ToggleOverview() | 160 | + | m.selectCard(w, 0) |
| 114 | - | if m.overviewAxis() != axis { | 161 | + | u := w.overviewArea() |
| 115 | - | t.Fatal("setup: wrong overview axis") | 162 | + | lo, hi := u.Y, u.Y+u.H |
| 116 | - | } | 163 | + | centred, hidden := false, false |
| 117 | - | m.selectCard(w, 0) | 164 | + | for i := range len(w.Stash) { |
| 118 | - | u := w.overviewArea() | 165 | + | if m.cardAt(w) != i { |
| 119 | - | pos := func(r Rect) (int, int, int, int) { | 166 | + | t.Fatalf("step %d: card %d", i, m.cardAt(w)) |
| 120 | - | if axis == verticalAxis { | 167 | + | } |
| 121 | - | return r.Y, r.H, u.Y, u.Y + u.H | 168 | + | ps := m.Layout() |
| 122 | - | } | 169 | + | sel := previewOf(t, ps, w.Stash[i].ID) |
| 123 | - | return r.X, r.W, u.X, u.X + u.W | 170 | + | at, size := sel.Rect.Y, sel.Rect.H |
| 124 | - | } | 171 | + | if sel.Hidden || !sel.Focused || at < lo || at+size > hi { |
| 125 | - | centred, hidden := false, false | 172 | + | t.Fatalf("card %d off the area: %+v in %v", i, sel, u) |
| 126 | - | for i := range len(w.Stash) { | 173 | + | } |
| 127 | - | if m.cardAt(w) != i { | 174 | + | first := previewOf(t, ps, w.Stash[0].ID).Rect.Y |
| 128 | - | t.Fatalf("step %d: card %d", i, m.cardAt(w)) | 175 | + | last := previewOf(t, ps, w.Stash[len(w.Stash)-1].ID).Rect.Y |
| 129 | - | } | 176 | + | if first > lo || last+size < hi { |
| 130 | - | ps := m.Layout() | 177 | + | t.Fatalf("card %d: strip %d..%d does not fill the area %d..%d", i, first, last+size, lo, hi) |
| 131 | - | sel := previewOf(t, ps, w.Stash[i].ID) | 178 | + | } |
| 132 | - | at, size, lo, hi := pos(sel.Rect) | 179 | + | if mid := at + size/2; abs(mid-(lo+hi)/2) <= 1 { |
| 133 | - | if sel.Hidden || !sel.Focused || at < lo || at+size > hi { | 180 | + | centred = true |
| 134 | - | t.Fatalf("card %d off the area: %+v in %v", i, sel, u) | 181 | + | } else if first != lo && last+size != hi { |
| 135 | - | } | 182 | + | t.Fatalf("card %d at %d, neither centred on %d nor clamped", i, mid, (lo+hi)/2) |
| 136 | - | first, _, _, _ := pos(previewOf(t, ps, w.Stash[0].ID).Rect) | 183 | + | } |
| 137 | - | last, _, _, _ := pos(previewOf(t, ps, w.Stash[len(w.Stash)-1].ID).Rect) | 184 | + | for _, e := range w.Stash { |
| 138 | - | if first > lo || last+size < hi { | 185 | + | hidden = hidden || previewOf(t, ps, e.ID).Hidden |
| 139 | - | t.Fatalf("card %d: strip %d..%d does not fill the area %d..%d", i, first, last+size, lo, hi) | 186 | + | } |
| 140 | - | } | 187 | + | m.OverviewMove(0, 1) |
| 141 | - | if mid := at + size/2; abs(mid-(lo+hi)/2) <= 1 { | 188 | + | } |
| 142 | - | centred = true | 189 | + | if !hidden || !centred { |
| 143 | - | } else if first != lo && last+size != hi { | 190 | + | t.Fatalf("12 cards: hidden %v, centred %v; the test does not scroll", hidden, centred) |
| 144 | - | t.Fatalf("card %d at %d, neither centred on %d nor clamped", i, mid, (lo+hi)/2) | 191 | + | } |
| 145 | - | } | 192 | + | } |
| 146 | - | for _, e := range w.Stash { | 193 | + | |
| 147 | - | hidden = hidden || previewOf(t, ps, e.ID).Hidden | 194 | + | // A carousel wider than the area keeps every selection centred in it, with |
| 148 | - | } | 195 | + | // cards on both sides; the cards past the area are hidden. |
| 149 | - | if axis == verticalAxis { | 196 | + | func TestOverviewCascadeCarouselScrolls(t *testing.T) { |
| 150 | - | m.OverviewMove(0, 1) | 197 | + | m := cascadeOverview() |
| 151 | - | } else if i < len(w.Stash)-1 { | 198 | + | w := m.Current() |
| 152 | - | m.OverviewMove(1, 0) | 199 | + | withStash(m, 12) |
| 153 | - | } | 200 | + | m.ToggleOverview() |
| 154 | - | } | 201 | + | if m.overviewAxis() != horizontalAxis { |
| 155 | - | if !hidden || !centred { | 202 | + | t.Fatal("setup: wrong overview axis") |
| 156 | - | t.Fatalf("12 cards: hidden %v, centred %v; the test does not scroll", hidden, centred) | 203 | + | } |
| 204 | + | m.selectCard(w, 11) | |||
| 205 | + | u := w.overviewArea() | |||
| 206 | + | for step := range len(w.Stash) + 1 { | |||
| 207 | + | i := m.cardAt(w) | |||
| 208 | + | ps := m.Layout() | |||
| 209 | + | sel := previewOf(t, ps, w.Stash[i].ID) | |||
| 210 | + | if sel.Hidden || !sel.Focused || abs(sel.Rect.X+sel.Rect.W/2-(u.X+u.W/2)) > 1 { | |||
| 211 | + | t.Fatalf("step %d: card %d not centred: %+v in %v", step, i, sel, u) | |||
| 212 | + | } | |||
| 213 | + | left, right, hidden := false, false, false | |||
| 214 | + | for _, e := range w.Stash { | |||
| 215 | + | p := previewOf(t, ps, e.ID) | |||
| 216 | + | if p.Hidden { | |||
| 217 | + | hidden = true | |||
| 218 | + | continue | |||
| 157 | } | 219 | } | ||
| 158 | - | }) | 220 | + | left = left || p.Rect.X < sel.Rect.X |
| 221 | + | right = right || p.Rect.X > sel.Rect.X | |||
| 222 | + | } | |||
| 223 | + | if !left || !right || !hidden { | |||
| 224 | + | t.Fatalf("step %d: cards left %v, right %v, hidden %v", step, left, right, hidden) | |||
| 225 | + | } | |||
| 226 | + | m.OverviewMove(1, 0) | |||
| 159 | } | 227 | } | ||
| 160 | } | 228 | } | ||
| 161 | 229 | ||||
M internal/core/transition.go +33 -0
| @@ -45,6 +45,9 @@ type viewShot struct { | |||
| 45 | 45 | stashShown bool | |
| 46 | 46 | stashAt, stashLen int | |
| 47 | 47 | stashPos, stashV float64 | |
| 48 | + | // card is the selected card of an unfolded stash carousel in the | |
| 49 | + | // preview (Workspace.carousel), -1 without one. | |
| 50 | + | card int | |
| 48 | 51 | // list and pos are the numbered workspaces and the monitor's fractional | |
| 49 | 52 | // position in them (index of the current one plus switchView.off); ok is | |
| 50 | 53 | // false when there is none (a shown stash workspace). | |
| @@ -637,6 +640,7 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||
| 637 | 640 | _, s.switchV = m.switchView.motion.sampleAt(now) | |
| 638 | 641 | } | |
| 639 | 642 | s.stashShown, s.stashAt, s.stashLen, s.stashPos = w.stashShown(), w.stashAt, len(w.Stash), float64(w.stashAt)+w.stashView.off | |
| 643 | + | s.card = m.carouselCard(w) | |
| 640 | 644 | if w.stashView.motion.on { | |
| 641 | 645 | _, s.stashV = w.stashView.motion.sampleAt(now) | |
| 642 | 646 | } | |
| @@ -903,12 +907,22 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) { | |||
| 903 | 907 | if changed { | |
| 904 | 908 | sc.stopRects() | |
| 905 | 909 | } | |
| 910 | + | at := -1 | |
| 911 | + | if !changed { | |
| 912 | + | at = carouselStep(b) | |
| 913 | + | } | |
| 914 | + | w := sc.mon.Current() | |
| 906 | 915 | for _, p := range sc.mon.Layout() { | |
| 907 | 916 | if p.Hidden { | |
| 908 | 917 | continue | |
| 909 | 918 | } | |
| 910 | 919 | rm := sc.rects[p.ID] | |
| 911 | 920 | i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }) | |
| 921 | + | if i >= 0 && at >= 0 && wrapped(w.stashIndex(p.ID), b.card, at, len(w.Stash)) { | |
| 922 | + | // The card went round the carousel to its other end: it | |
| 923 | + | // fades in there instead of crossing the row. | |
| 924 | + | rm, i = rectMotion{}, -1 | |
| 925 | + | } | |
| 912 | 926 | if i >= 0 && changed { | |
| 913 | 927 | c.retargetLevel(&rm.fade, &rm.df, b.rects[i].fade, b.rects[i].fadeV, now) | |
| 914 | 928 | } | |
| @@ -934,6 +948,25 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) { | |||
| 934 | 948 | } | |
| 935 | 949 | } | |
| 936 | 950 | ||
| 951 | + | // carouselStep is the selected card of the stash carousel b's screen shows | |
| 952 | + | // now, when it showed the same carousel in b too (the same workspace and | |
| 953 | + | // stash size); else -1. | |
| 954 | + | func carouselStep(b *viewShot) int { | |
| 955 | + | m := b.sc.mon | |
| 956 | + | w := m.Current() | |
| 957 | + | if b.card < 0 || w != b.ws || len(w.Stash) != b.stashLen { | |
| 958 | + | return -1 | |
| 959 | + | } | |
| 960 | + | return m.carouselCard(w) | |
| 961 | + | } | |
| 962 | + | ||
| 963 | + | // wrapped reports whether stash card i of a carousel of n cards went round | |
| 964 | + | // it, from one end to the other: it moved more than one place | |
| 965 | + | // (carouselSlot) when the selection went from card from to card to. | |
| 966 | + | func wrapped(i, from, to, n int) bool { | |
| 967 | + | return i >= 0 && abs(carouselSlot(i, to, n)-carouselSlot(i, from, n)) > 1 | |
| 968 | + | } | |
| 969 | + | ||
| 937 | 970 | // movedFrom is the shot of the other screen that showed the workspace b's | |
| 938 | 971 | // screen shows now (moved to it by an action), or nil. | |
| 939 | 972 | func movedFrom(b *viewShot, before []viewShot) *viewShot { | |
| @@ -45,6 +45,9 @@ type viewShot struct { | |||||
| 45 | stashShown bool | 45 | stashShown bool | ||
| 46 | stashAt, stashLen int | 46 | stashAt, stashLen int | ||
| 47 | stashPos, stashV float64 | 47 | stashPos, stashV float64 | ||
| 48 | + | // card is the selected card of an unfolded stash carousel in the | |||
| 49 | + | // preview (Workspace.carousel), -1 without one. | |||
| 50 | + | card int | |||
| 48 | // list and pos are the numbered workspaces and the monitor's fractional | 51 | // list and pos are the numbered workspaces and the monitor's fractional | ||
| 49 | // position in them (index of the current one plus switchView.off); ok is | 52 | // position in them (index of the current one plus switchView.off); ok is | ||
| 50 | // false when there is none (a shown stash workspace). | 53 | // false when there is none (a shown stash workspace). | ||
| @@ -637,6 +640,7 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||||
| 637 | _, s.switchV = m.switchView.motion.sampleAt(now) | 640 | _, s.switchV = m.switchView.motion.sampleAt(now) | ||
| 638 | } | 641 | } | ||
| 639 | s.stashShown, s.stashAt, s.stashLen, s.stashPos = w.stashShown(), w.stashAt, len(w.Stash), float64(w.stashAt)+w.stashView.off | 642 | s.stashShown, s.stashAt, s.stashLen, s.stashPos = w.stashShown(), w.stashAt, len(w.Stash), float64(w.stashAt)+w.stashView.off | ||
| 643 | + | s.card = m.carouselCard(w) | |||
| 640 | if w.stashView.motion.on { | 644 | if w.stashView.motion.on { | ||
| 641 | _, s.stashV = w.stashView.motion.sampleAt(now) | 645 | _, s.stashV = w.stashView.motion.sampleAt(now) | ||
| 642 | } | 646 | } | ||
| @@ -903,12 +907,22 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) { | |||||
| 903 | if changed { | 907 | if changed { | ||
| 904 | sc.stopRects() | 908 | sc.stopRects() | ||
| 905 | } | 909 | } | ||
| 910 | + | at := -1 | |||
| 911 | + | if !changed { | |||
| 912 | + | at = carouselStep(b) | |||
| 913 | + | } | |||
| 914 | + | w := sc.mon.Current() | |||
| 906 | for _, p := range sc.mon.Layout() { | 915 | for _, p := range sc.mon.Layout() { | ||
| 907 | if p.Hidden { | 916 | if p.Hidden { | ||
| 908 | continue | 917 | continue | ||
| 909 | } | 918 | } | ||
| 910 | rm := sc.rects[p.ID] | 919 | rm := sc.rects[p.ID] | ||
| 911 | i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }) | 920 | i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }) | ||
| 921 | + | if i >= 0 && at >= 0 && wrapped(w.stashIndex(p.ID), b.card, at, len(w.Stash)) { | |||
| 922 | + | // The card went round the carousel to its other end: it | |||
| 923 | + | // fades in there instead of crossing the row. | |||
| 924 | + | rm, i = rectMotion{}, -1 | |||
| 925 | + | } | |||
| 912 | if i >= 0 && changed { | 926 | if i >= 0 && changed { | ||
| 913 | c.retargetLevel(&rm.fade, &rm.df, b.rects[i].fade, b.rects[i].fadeV, now) | 927 | c.retargetLevel(&rm.fade, &rm.df, b.rects[i].fade, b.rects[i].fadeV, now) | ||
| 914 | } | 928 | } | ||
| @@ -934,6 +948,25 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) { | |||||
| 934 | } | 948 | } | ||
| 935 | } | 949 | } | ||
| 936 | 950 | ||||
| 951 | + | // carouselStep is the selected card of the stash carousel b's screen shows | |||
| 952 | + | // now, when it showed the same carousel in b too (the same workspace and | |||
| 953 | + | // stash size); else -1. | |||
| 954 | + | func carouselStep(b *viewShot) int { | |||
| 955 | + | m := b.sc.mon | |||
| 956 | + | w := m.Current() | |||
| 957 | + | if b.card < 0 || w != b.ws || len(w.Stash) != b.stashLen { | |||
| 958 | + | return -1 | |||
| 959 | + | } | |||
| 960 | + | return m.carouselCard(w) | |||
| 961 | + | } | |||
| 962 | + | ||||
| 963 | + | // wrapped reports whether stash card i of a carousel of n cards went round | |||
| 964 | + | // it, from one end to the other: it moved more than one place | |||
| 965 | + | // (carouselSlot) when the selection went from card from to card to. | |||
| 966 | + | func wrapped(i, from, to, n int) bool { | |||
| 967 | + | return i >= 0 && abs(carouselSlot(i, to, n)-carouselSlot(i, from, n)) > 1 | |||
| 968 | + | } | |||
| 969 | + | ||||
| 937 | // movedFrom is the shot of the other screen that showed the workspace b's | 970 | // movedFrom is the shot of the other screen that showed the workspace b's | ||
| 938 | // screen shows now (moved to it by an action), or nil. | 971 | // screen shows now (moved to it by an action), or nil. | ||
| 939 | func movedFrom(b *viewShot, before []viewShot) *viewShot { | 972 | func movedFrom(b *viewShot, before []viewShot) *viewShot { | ||
M internal/core/transition_stash_internal_test.go +33 -0
| @@ -473,3 +473,36 @@ func TestWithLeavingOrderIsDeterministic(t *testing.T) { | |||
| 473 | 473 | } | |
| 474 | 474 | } | |
| 475 | 475 | } | |
| 476 | + | ||
| 477 | + | // Walking the stash carousel slides its cards one place; the card that goes | |
| 478 | + | // round to the other end fades in there instead of crossing the row. | |
| 479 | + | func TestCarouselWrapFadesIn(t *testing.T) { | |
| 480 | + | c, ic, sc, _ := stashRig(t, true) | |
| 481 | + | m := sc.mon | |
| 482 | + | w := m.Current() | |
| 483 | + | w.Overflow = OverflowCascade | |
| 484 | + | c.applyAction(ActionToggleOverview) | |
| 485 | + | settleShown(t, c) | |
| 486 | + | m.selectCard(w, 0) | |
| 487 | + | settleShown(t, c) | |
| 488 | + | shots := c.snapshot(ic.now) | |
| 489 | + | m.OverviewMove(1, 0) | |
| 490 | + | c.transition(shots, ic.now) | |
| 491 | + | if m.cardAt(w) != 1 { | |
| 492 | + | t.Fatalf("setup: card %d", m.cardAt(w)) | |
| 493 | + | } | |
| 494 | + | // Three cards: the selection moved from 0 to 1, card 2 went round | |
| 495 | + | // from the right end to the left one. | |
| 496 | + | for i, e := range w.Stash { | |
| 497 | + | rm := sc.rects[e.ID] | |
| 498 | + | if i == 2 { | |
| 499 | + | if rm.dx != 0 || !rm.fade.on { | |
| 500 | + | t.Fatalf("wrapped card: dx %v, fade %v", rm.dx, rm.fade.on) | |
| 501 | + | } | |
| 502 | + | continue | |
| 503 | + | } | |
| 504 | + | if rm.dx == 0 || rm.fade.on { | |
| 505 | + | t.Fatalf("card %d: dx %v, fade %v", i, rm.dx, rm.fade.on) | |
| 506 | + | } | |
| 507 | + | } | |
| 508 | + | } | |
| @@ -473,3 +473,36 @@ func TestWithLeavingOrderIsDeterministic(t *testing.T) { | |||||
| 473 | } | 473 | } | ||
| 474 | } | 474 | } | ||
| 475 | } | 475 | } | ||
| 476 | + | ||||
| 477 | + | // Walking the stash carousel slides its cards one place; the card that goes | |||
| 478 | + | // round to the other end fades in there instead of crossing the row. | |||
| 479 | + | func TestCarouselWrapFadesIn(t *testing.T) { | |||
| 480 | + | c, ic, sc, _ := stashRig(t, true) | |||
| 481 | + | m := sc.mon | |||
| 482 | + | w := m.Current() | |||
| 483 | + | w.Overflow = OverflowCascade | |||
| 484 | + | c.applyAction(ActionToggleOverview) | |||
| 485 | + | settleShown(t, c) | |||
| 486 | + | m.selectCard(w, 0) | |||
| 487 | + | settleShown(t, c) | |||
| 488 | + | shots := c.snapshot(ic.now) | |||
| 489 | + | m.OverviewMove(1, 0) | |||
| 490 | + | c.transition(shots, ic.now) | |||
| 491 | + | if m.cardAt(w) != 1 { | |||
| 492 | + | t.Fatalf("setup: card %d", m.cardAt(w)) | |||
| 493 | + | } | |||
| 494 | + | // Three cards: the selection moved from 0 to 1, card 2 went round | |||
| 495 | + | // from the right end to the left one. | |||
| 496 | + | for i, e := range w.Stash { | |||
| 497 | + | rm := sc.rects[e.ID] | |||
| 498 | + | if i == 2 { | |||
| 499 | + | if rm.dx != 0 || !rm.fade.on { | |||
| 500 | + | t.Fatalf("wrapped card: dx %v, fade %v", rm.dx, rm.fade.on) | |||
| 501 | + | } | |||
| 502 | + | continue | |||
| 503 | + | } | |||
| 504 | + | if rm.dx == 0 || rm.fade.on { | |||
| 505 | + | t.Fatalf("card %d: dx %v, fade %v", i, rm.dx, rm.fade.on) | |||
| 506 | + | } | |||
| 507 | + | } | |||
| 508 | + | } | |||