feat(overview): present cascade bands as dimmed lanes
The overview is the scroll overview rotated by the current workspace's layout policy. A cascade workspace centres its selected band at the normal zoom, dims its other bands above and below (hidden off screen) and puts its neighbors left and right, selected lane level, the stash pile left of the band in its slot. Up/down change band, left/right walk the band then cross to the stash or a neighbor workspace; three-finger swipes follow the fingers. Scroll and fixed overviews are unchanged.
- commit
- 194a80beb56a965c2ee32b53e641130dc42d2571
- author
- brice · · 2026-10-07 06:08 UTC
- committer
- brice · · 2026-10-07 07:12 UTC
- parent
- 82d6128
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +1410 -107
M internal/core/cascade.go +36 -10
| @@ -22,7 +22,7 @@ func (w *Workspace) cellInBand(b int) int { | |||
| 22 | 22 | return min(b*k+w.Focus%k, len(w.Columns)-1) | |
| 23 | 23 | } | |
| 24 | 24 | func (w *Workspace) focusBand(dir int) bool { | |
| 25 | - | i := w.bandNeighbor(dir) | |
| 25 | + | i := w.laneNeighbor(dir) | |
| 26 | 26 | if i < 0 { | |
| 27 | 27 | return false | |
| 28 | 28 | } | |
| @@ -36,15 +36,6 @@ func (w *Workspace) focusBand(dir int) bool { | |||
| 36 | 36 | w.scroll() | |
| 37 | 37 | return true | |
| 38 | 38 | } | |
| 39 | - | func (w *Workspace) bandNeighbor(dir int) int { | |
| 40 | - | k := max(w.MaxColumns, 1) | |
| 41 | - | b := w.band(w.Focus) + dir | |
| 42 | - | if b < 0 || b*k >= len(w.Columns) { | |
| 43 | - | return -1 | |
| 44 | - | } | |
| 45 | - | return w.cellInBand(b) | |
| 46 | - | } | |
| 47 | - | ||
| 48 | 39 | func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |
| 49 | 40 | g := w.gap() | |
| 50 | 41 | width := w.cellWidth() | |
| @@ -77,3 +68,38 @@ func (w *Workspace) cascadeFocus(view int) int { | |||
| 77 | 68 | b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1)) | |
| 78 | 69 | return w.cellInBand(b) | |
| 79 | 70 | } | |
| 71 | + | ||
| 72 | + | // laneOf is the lane of column i in the overview: its band, or the only | |
| 73 | + | // lane of a layout that does not wrap. | |
| 74 | + | func (w *Workspace) laneOf(i int) int { | |
| 75 | + | if w.policy().wraps { | |
| 76 | + | return w.band(i) | |
| 77 | + | } | |
| 78 | + | return 0 | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // laneBounds is the range of columns of a lane, first > last when the lane | |
| 82 | + | // does not exist. A layout that does not wrap has the whole row as lane 0. | |
| 83 | + | func (w *Workspace) laneBounds(lane int) (first, last int) { | |
| 84 | + | k := len(w.Columns) | |
| 85 | + | if w.policy().wraps { | |
| 86 | + | k = max(w.MaxColumns, 1) | |
| 87 | + | } | |
| 88 | + | first = lane * k | |
| 89 | + | if lane < 0 || first >= len(w.Columns) { | |
| 90 | + | return 0, -1 | |
| 91 | + | } | |
| 92 | + | return first, min(first+k, len(w.Columns)) - 1 | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // laneNeighbor is the closest column to the focus in the lane d lanes away, | |
| 96 | + | // the focus's offset in its lane kept; -1 past the first or last lane. | |
| 97 | + | func (w *Workspace) laneNeighbor(d int) int { | |
| 98 | + | lane := w.laneOf(w.Focus) | |
| 99 | + | first, _ := w.laneBounds(lane) | |
| 100 | + | nfirst, nlast := w.laneBounds(lane + d) | |
| 101 | + | if nfirst > nlast { | |
| 102 | + | return -1 | |
| 103 | + | } | |
| 104 | + | return min(nfirst+w.Focus-first, nlast) | |
| 105 | + | } | |
| @@ -22,7 +22,7 @@ func (w *Workspace) cellInBand(b int) int { | |||||
| 22 | return min(b*k+w.Focus%k, len(w.Columns)-1) | 22 | return min(b*k+w.Focus%k, len(w.Columns)-1) | ||
| 23 | } | 23 | } | ||
| 24 | func (w *Workspace) focusBand(dir int) bool { | 24 | func (w *Workspace) focusBand(dir int) bool { | ||
| 25 | - | i := w.bandNeighbor(dir) | 25 | + | i := w.laneNeighbor(dir) |
| 26 | if i < 0 { | 26 | if i < 0 { | ||
| 27 | return false | 27 | return false | ||
| 28 | } | 28 | } | ||
| @@ -36,15 +36,6 @@ func (w *Workspace) focusBand(dir int) bool { | |||||
| 36 | w.scroll() | 36 | w.scroll() | ||
| 37 | return true | 37 | return true | ||
| 38 | } | 38 | } | ||
| 39 | - | func (w *Workspace) bandNeighbor(dir int) int { | |||
| 40 | - | k := max(w.MaxColumns, 1) | |||
| 41 | - | b := w.band(w.Focus) + dir | |||
| 42 | - | if b < 0 || b*k >= len(w.Columns) { | |||
| 43 | - | return -1 | |||
| 44 | - | } | |||
| 45 | - | return w.cellInBand(b) | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | 39 | func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | ||
| 49 | g := w.gap() | 40 | g := w.gap() | ||
| 50 | width := w.cellWidth() | 41 | width := w.cellWidth() | ||
| @@ -77,3 +68,38 @@ func (w *Workspace) cascadeFocus(view int) int { | |||||
| 77 | b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1)) | 68 | b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1)) | ||
| 78 | return w.cellInBand(b) | 69 | return w.cellInBand(b) | ||
| 79 | } | 70 | } | ||
| 71 | + | ||||
| 72 | + | // laneOf is the lane of column i in the overview: its band, or the only | |||
| 73 | + | // lane of a layout that does not wrap. | |||
| 74 | + | func (w *Workspace) laneOf(i int) int { | |||
| 75 | + | if w.policy().wraps { | |||
| 76 | + | return w.band(i) | |||
| 77 | + | } | |||
| 78 | + | return 0 | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // laneBounds is the range of columns of a lane, first > last when the lane | |||
| 82 | + | // does not exist. A layout that does not wrap has the whole row as lane 0. | |||
| 83 | + | func (w *Workspace) laneBounds(lane int) (first, last int) { | |||
| 84 | + | k := len(w.Columns) | |||
| 85 | + | if w.policy().wraps { | |||
| 86 | + | k = max(w.MaxColumns, 1) | |||
| 87 | + | } | |||
| 88 | + | first = lane * k | |||
| 89 | + | if lane < 0 || first >= len(w.Columns) { | |||
| 90 | + | return 0, -1 | |||
| 91 | + | } | |||
| 92 | + | return first, min(first+k, len(w.Columns)) - 1 | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // laneNeighbor is the closest column to the focus in the lane d lanes away, | |||
| 96 | + | // the focus's offset in its lane kept; -1 past the first or last lane. | |||
| 97 | + | func (w *Workspace) laneNeighbor(d int) int { | |||
| 98 | + | lane := w.laneOf(w.Focus) | |||
| 99 | + | first, _ := w.laneBounds(lane) | |||
| 100 | + | nfirst, nlast := w.laneBounds(lane + d) | |||
| 101 | + | if nfirst > nlast { | |||
| 102 | + | return -1 | |||
| 103 | + | } | |||
| 104 | + | return min(nfirst+w.Focus-first, nlast) | |||
| 105 | + | } | |||
M internal/core/cascade_test.go +56 -0
| @@ -166,6 +166,62 @@ func TestCascadeGeometryReusesBuffer(t *testing.T) { | |||
| 166 | 166 | if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 { | |
| 167 | 167 | t.Fatalf("allocations=%v", n) | |
| 168 | 168 | } | |
| 169 | + | // The overview's lane-encoded tiles reuse their buffer too. | |
| 170 | + | tiles, _, _ := w.previewTiles() | |
| 171 | + | if n := testing.AllocsPerRun(100, func() { tiles, _, _ = w.previewTilesInto(tiles[:0]) }); n != 0 { | |
| 172 | + | t.Fatalf("preview tile allocations=%v", n) | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // Lanes are the bands of a cascade workspace and the whole row of any other. | |
| 177 | + | func TestLaneGeometry(t *testing.T) { | |
| 178 | + | w := cascadeWorkspace(7) // bands {0,1,2} {3,4,5} {6} | |
| 179 | + | for _, c := range []struct{ i, lane int }{{0, 0}, {2, 0}, {3, 1}, {6, 2}} { | |
| 180 | + | if got := w.laneOf(c.i); got != c.lane { | |
| 181 | + | t.Fatalf("cascade laneOf(%d)=%d, want %d", c.i, got, c.lane) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | for lane, want := range [][2]int{{0, 2}, {3, 5}, {6, 6}} { | |
| 185 | + | if first, last := w.laneBounds(lane); first != want[0] || last != want[1] { | |
| 186 | + | t.Fatalf("cascade laneBounds(%d)=%d,%d, want %v", lane, first, last, want) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | for _, lane := range []int{-1, 3} { | |
| 190 | + | if first, last := w.laneBounds(lane); first <= last { | |
| 191 | + | t.Fatalf("cascade laneBounds(%d)=%d,%d exists", lane, first, last) | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | // The closest column of the adjacent lane keeps its offset, clamped. | |
| 195 | + | for _, c := range []struct{ focus, d, want int }{{1, 1, 4}, {5, 1, 6}, {4, -1, 1}, {2, -1, -1}, {6, 1, -1}, {6, -1, 3}} { | |
| 196 | + | w.Focus = c.focus | |
| 197 | + | if got := w.laneNeighbor(c.d); got != c.want { | |
| 198 | + | t.Fatalf("cascade laneNeighbor from %d by %d = %d, want %d", c.focus, c.d, got, c.want) | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | s := &Workspace{Overflow: OverflowScroll, MaxColumns: 3} | |
| 203 | + | s.SetOutput(900, 600) | |
| 204 | + | for i := 1; i <= 5; i++ { | |
| 205 | + | s.AddWindow(WindowID(i)) | |
| 206 | + | } | |
| 207 | + | for i := range s.Columns { | |
| 208 | + | if s.laneOf(i) != 0 { | |
| 209 | + | t.Fatalf("scroll laneOf(%d)=%d", i, s.laneOf(i)) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | if first, last := s.laneBounds(0); first != 0 || last != 4 { | |
| 213 | + | t.Fatalf("scroll laneBounds(0)=%d,%d", first, last) | |
| 214 | + | } | |
| 215 | + | for _, lane := range []int{-1, 1} { | |
| 216 | + | if first, last := s.laneBounds(lane); first <= last { | |
| 217 | + | t.Fatalf("scroll laneBounds(%d)=%d,%d exists", lane, first, last) | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | for _, d := range []int{-1, 1} { | |
| 221 | + | if got := s.laneNeighbor(d); got != -1 { | |
| 222 | + | t.Fatalf("scroll laneNeighbor(%d)=%d", d, got) | |
| 223 | + | } | |
| 224 | + | } | |
| 169 | 225 | } | |
| 170 | 226 | ||
| 171 | 227 | func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) { | |
| @@ -166,6 +166,62 @@ func TestCascadeGeometryReusesBuffer(t *testing.T) { | |||||
| 166 | if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 { | 166 | if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 { | ||
| 167 | t.Fatalf("allocations=%v", n) | 167 | t.Fatalf("allocations=%v", n) | ||
| 168 | } | 168 | } | ||
| 169 | + | // The overview's lane-encoded tiles reuse their buffer too. | |||
| 170 | + | tiles, _, _ := w.previewTiles() | |||
| 171 | + | if n := testing.AllocsPerRun(100, func() { tiles, _, _ = w.previewTilesInto(tiles[:0]) }); n != 0 { | |||
| 172 | + | t.Fatalf("preview tile allocations=%v", n) | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // Lanes are the bands of a cascade workspace and the whole row of any other. | |||
| 177 | + | func TestLaneGeometry(t *testing.T) { | |||
| 178 | + | w := cascadeWorkspace(7) // bands {0,1,2} {3,4,5} {6} | |||
| 179 | + | for _, c := range []struct{ i, lane int }{{0, 0}, {2, 0}, {3, 1}, {6, 2}} { | |||
| 180 | + | if got := w.laneOf(c.i); got != c.lane { | |||
| 181 | + | t.Fatalf("cascade laneOf(%d)=%d, want %d", c.i, got, c.lane) | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | for lane, want := range [][2]int{{0, 2}, {3, 5}, {6, 6}} { | |||
| 185 | + | if first, last := w.laneBounds(lane); first != want[0] || last != want[1] { | |||
| 186 | + | t.Fatalf("cascade laneBounds(%d)=%d,%d, want %v", lane, first, last, want) | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | for _, lane := range []int{-1, 3} { | |||
| 190 | + | if first, last := w.laneBounds(lane); first <= last { | |||
| 191 | + | t.Fatalf("cascade laneBounds(%d)=%d,%d exists", lane, first, last) | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | // The closest column of the adjacent lane keeps its offset, clamped. | |||
| 195 | + | for _, c := range []struct{ focus, d, want int }{{1, 1, 4}, {5, 1, 6}, {4, -1, 1}, {2, -1, -1}, {6, 1, -1}, {6, -1, 3}} { | |||
| 196 | + | w.Focus = c.focus | |||
| 197 | + | if got := w.laneNeighbor(c.d); got != c.want { | |||
| 198 | + | t.Fatalf("cascade laneNeighbor from %d by %d = %d, want %d", c.focus, c.d, got, c.want) | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | s := &Workspace{Overflow: OverflowScroll, MaxColumns: 3} | |||
| 203 | + | s.SetOutput(900, 600) | |||
| 204 | + | for i := 1; i <= 5; i++ { | |||
| 205 | + | s.AddWindow(WindowID(i)) | |||
| 206 | + | } | |||
| 207 | + | for i := range s.Columns { | |||
| 208 | + | if s.laneOf(i) != 0 { | |||
| 209 | + | t.Fatalf("scroll laneOf(%d)=%d", i, s.laneOf(i)) | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | if first, last := s.laneBounds(0); first != 0 || last != 4 { | |||
| 213 | + | t.Fatalf("scroll laneBounds(0)=%d,%d", first, last) | |||
| 214 | + | } | |||
| 215 | + | for _, lane := range []int{-1, 1} { | |||
| 216 | + | if first, last := s.laneBounds(lane); first <= last { | |||
| 217 | + | t.Fatalf("scroll laneBounds(%d)=%d,%d exists", lane, first, last) | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | for _, d := range []int{-1, 1} { | |||
| 221 | + | if got := s.laneNeighbor(d); got != -1 { | |||
| 222 | + | t.Fatalf("scroll laneNeighbor(%d)=%d", d, got) | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 169 | } | 225 | } | ||
| 170 | 226 | ||||
| 171 | func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) { | 227 | func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) { | ||
M internal/core/core.go +11 -0
| @@ -287,6 +287,11 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 287 | 287 | if err != nil { | |
| 288 | 288 | return err | |
| 289 | 289 | } | |
| 290 | + | // Reload is cold-path work; unrelated changes keep a scroll or gesture. | |
| 291 | + | axes := make(map[*Monitor]layoutPolicy, len(c.screens)) | |
| 292 | + | for _, s := range c.screens { | |
| 293 | + | axes[s.mon] = s.mon.Current().policy() | |
| 294 | + | } | |
| 290 | 295 | c.cfg = cfg | |
| 291 | 296 | c.binds = binds | |
| 292 | 297 | c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey] | |
| @@ -297,6 +302,12 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 297 | 302 | c.settings(s.mon) | |
| 298 | 303 | } | |
| 299 | 304 | c.applyOutputConfig() | |
| 305 | + | for _, s := range c.screens { | |
| 306 | + | old, now := axes[s.mon], s.mon.Current().policy() | |
| 307 | + | if old.content != now.content || old.workspace != now.workspace { | |
| 308 | + | s.mon.ov.dropScroll() | |
| 309 | + | } | |
| 310 | + | } | |
| 300 | 311 | return nil | |
| 301 | 312 | } | |
| 302 | 313 | ||
| @@ -287,6 +287,11 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 287 | if err != nil { | 287 | if err != nil { | ||
| 288 | return err | 288 | return err | ||
| 289 | } | 289 | } | ||
| 290 | + | // Reload is cold-path work; unrelated changes keep a scroll or gesture. | |||
| 291 | + | axes := make(map[*Monitor]layoutPolicy, len(c.screens)) | |||
| 292 | + | for _, s := range c.screens { | |||
| 293 | + | axes[s.mon] = s.mon.Current().policy() | |||
| 294 | + | } | |||
| 290 | c.cfg = cfg | 295 | c.cfg = cfg | ||
| 291 | c.binds = binds | 296 | c.binds = binds | ||
| 292 | c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey] | 297 | c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey] | ||
| @@ -297,6 +302,12 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 297 | c.settings(s.mon) | 302 | c.settings(s.mon) | ||
| 298 | } | 303 | } | ||
| 299 | c.applyOutputConfig() | 304 | c.applyOutputConfig() | ||
| 305 | + | for _, s := range c.screens { | |||
| 306 | + | old, now := axes[s.mon], s.mon.Current().policy() | |||
| 307 | + | if old.content != now.content || old.workspace != now.workspace { | |||
| 308 | + | s.mon.ov.dropScroll() | |||
| 309 | + | } | |||
| 310 | + | } | |||
| 300 | return nil | 311 | return nil | ||
| 301 | } | 312 | } | ||
| 302 | 313 | ||||
M internal/core/gesture.go +1 -1
| @@ -418,7 +418,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 418 | 418 | } | |
| 419 | 419 | } else if p.wraps { | |
| 420 | 420 | // A content swipe cannot escape the last band to another workspace. | |
| 421 | - | if w.onFloat() || w.fullscreen != 0 || w.bandNeighbor(step) < 0 { | |
| 421 | + | if w.onFloat() || w.fullscreen != 0 || w.laneNeighbor(step) < 0 { | |
| 422 | 422 | return false | |
| 423 | 423 | } | |
| 424 | 424 | } | |
| @@ -418,7 +418,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 418 | } | 418 | } | ||
| 419 | } else if p.wraps { | 419 | } else if p.wraps { | ||
| 420 | // A content swipe cannot escape the last band to another workspace. | 420 | // A content swipe cannot escape the last band to another workspace. | ||
| 421 | - | if w.onFloat() || w.fullscreen != 0 || w.bandNeighbor(step) < 0 { | 421 | + | if w.onFloat() || w.fullscreen != 0 || w.laneNeighbor(step) < 0 { |
| 422 | return false | 422 | return false | ||
| 423 | } | 423 | } | ||
| 424 | } | 424 | } | ||
M internal/core/overview.go +334 -53
| @@ -31,6 +31,11 @@ type overviewState struct { | |||
| 31 | 31 | scrolled, sideways bool | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | + | // dropScroll forgets the scroll an overview axis accumulated, which belongs | |
| 35 | + | // to the old axis once it turns. The finger latch (scrolled) stays: it waits | |
| 36 | + | // for the fingers to lift. | |
| 37 | + | func (o *overviewState) dropScroll() { o.scrollX, o.scrollY, o.sideways = 0, 0, false } | |
| 38 | + | ||
| 34 | 39 | // rowSnapshot is a row as the overview first showed it: browsing moves its | |
| 35 | 40 | // focus and scroll, closing puts them back. | |
| 36 | 41 | type rowSnapshot struct { | |
| @@ -353,6 +358,9 @@ func (m *Monitor) overviewWorkspaces() []*Workspace { | |||
| 353 | 358 | // a named group uses its invocation anchor as the toggle return target, not | |
| 354 | 359 | // rows merely browsed in the overview. | |
| 355 | 360 | func (m *Monitor) showOverview(w *Workspace) { | |
| 361 | + | if w.policy().workspace != m.overviewAxis() { | |
| 362 | + | m.ov.dropScroll() | |
| 363 | + | } | |
| 356 | 364 | if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) { | |
| 357 | 365 | m.back = m.Current() | |
| 358 | 366 | if indexOf(m.Workspaces, w.overviewAfter) >= 0 { | |
| @@ -366,9 +374,113 @@ func (m *Monitor) showOverview(w *Workspace) { | |||
| 366 | 374 | // OverviewMove shares navigation rules across keys, scrolls and swipes. It | |
| 367 | 375 | // is the physical entry point: it maps dx and dy to the overview's moves. | |
| 368 | 376 | func (m *Monitor) OverviewMove(dx, dy int) { | |
| 377 | + | if m.overviewAxis() == horizontalAxis { | |
| 378 | + | if dy != 0 { | |
| 379 | + | m.overviewLane(dy) | |
| 380 | + | } else if dx != 0 { | |
| 381 | + | m.overviewAcross(dx) | |
| 382 | + | } | |
| 383 | + | return | |
| 384 | + | } | |
| 369 | 385 | m.overviewMoveVertical(dx, dy) | |
| 370 | 386 | } | |
| 371 | 387 | ||
| 388 | + | // overviewWorkspaceStep is a focus-workspace bind. A vertical overview | |
| 389 | + | // walks the stack cards before changing workspace (overviewVertical). A | |
| 390 | + | // side-by-side one always changes workspace, since its stack fans left and | |
| 391 | + | // right. A selected stash card cycles the pile first, in every layout. | |
| 392 | + | func (m *Monitor) overviewWorkspaceStep(d int) { | |
| 393 | + | if m.overviewAxis() == verticalAxis { | |
| 394 | + | m.overviewVertical(d) | |
| 395 | + | return | |
| 396 | + | } | |
| 397 | + | if !m.cycleCard(m.Current(), d) { | |
| 398 | + | m.overviewStep(d) | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | // cycleCard brings the stash card d entries from the selected one of w to | |
| 403 | + | // the front, stopping at either end. It reports whether a card was selected. | |
| 404 | + | func (m *Monitor) cycleCard(w *Workspace, d int) bool { | |
| 405 | + | at := m.cardAt(w) | |
| 406 | + | if at < 0 { | |
| 407 | + | return false | |
| 408 | + | } | |
| 409 | + | m.selectCard(w, min(max(at+d, 0), len(w.Stash)-1)) | |
| 410 | + | return true | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | // overviewLane is the vertical move of an overview whose workspaces sit side | |
| 414 | + | // by side (cascade): a selected stash card cycles the pile; else the | |
| 415 | + | // selection goes to the previous or next lane (band), at the closest column, | |
| 416 | + | // stopping at the first and last. Covering floats stack along the horizontal | |
| 417 | + | // axis, not this one. | |
| 418 | + | func (m *Monitor) overviewLane(dy int) { | |
| 419 | + | w := m.Current() | |
| 420 | + | if m.cycleCard(w, dy) || m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 { | |
| 421 | + | return | |
| 422 | + | } | |
| 423 | + | if i := w.laneNeighbor(dy); i >= 0 { | |
| 424 | + | m.selectLaneColumn(w, i) | |
| 425 | + | } | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | // selectLaneColumn selects column i of w. | |
| 429 | + | func (m *Monitor) selectLaneColumn(w *Workspace, i int) { | |
| 430 | + | w.selectOverviewColumn(i) | |
| 431 | + | m.ov.selected = w.Columns[i].Windows[w.Columns[i].Focus] | |
| 432 | + | m.ov.selectedAt = i | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | // overviewAcross is the horizontal move of an overview whose workspaces sit | |
| 436 | + | // side by side (cascade): a column inside the selected lane. Past the | |
| 437 | + | // lane's first column it goes to the stash, else to the previous workspace; | |
| 438 | + | // past the last column, to the next workspace. Covering floats fan along | |
| 439 | + | // this axis and are traversed before the stash or a workspace: the card on | |
| 440 | + | // the left is the one behind. | |
| 441 | + | func (m *Monitor) overviewAcross(dx int) { | |
| 442 | + | w := m.Current() | |
| 443 | + | if m.cardAt(w) >= 0 { | |
| 444 | + | if dx < 0 || len(w.stack()) == 0 { | |
| 445 | + | // Left leaves the pile; a stash-only workspace has nothing | |
| 446 | + | // to its right to select. | |
| 447 | + | m.overviewStep(dx) | |
| 448 | + | return | |
| 449 | + | } | |
| 450 | + | m.ov.card, m.ov.cardOf = 0, nil | |
| 451 | + | m.ov.selected = 0 | |
| 452 | + | if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 { | |
| 453 | + | first, _ := w.laneBounds(w.laneOf(w.Focus)) | |
| 454 | + | m.selectLaneColumn(w, first) | |
| 455 | + | } | |
| 456 | + | return | |
| 457 | + | } | |
| 458 | + | if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 { | |
| 459 | + | m.laneEdge(w, dx) | |
| 460 | + | return | |
| 461 | + | } | |
| 462 | + | first, last := w.laneBounds(w.laneOf(w.Focus)) | |
| 463 | + | if next := w.Focus + dx; next >= first && next <= last { | |
| 464 | + | m.selectLaneColumn(w, next) | |
| 465 | + | return | |
| 466 | + | } | |
| 467 | + | m.laneEdge(w, dx) | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | // laneEdge is a horizontal move off the edge of the selected lane, or off a | |
| 471 | + | // card that is not the columns: the stack first, then the stash on the left | |
| 472 | + | // or a workspace. | |
| 473 | + | func (m *Monitor) laneEdge(w *Workspace, dx int) { | |
| 474 | + | if m.moveStack(-dx) { | |
| 475 | + | return | |
| 476 | + | } | |
| 477 | + | if dx < 0 && len(w.Stash) > 0 { | |
| 478 | + | m.selectCard(w, w.stashAt) | |
| 479 | + | return | |
| 480 | + | } | |
| 481 | + | m.overviewStep(dx) | |
| 482 | + | } | |
| 483 | + | ||
| 372 | 484 | // overviewMoveVertical is the move of an overview whose workspaces stack | |
| 373 | 485 | // vertically. Vertical moves stay in a selected stash pile; left stays, and | |
| 374 | 486 | // right deselects it, even when the row has no main card. | |
| @@ -381,9 +493,7 @@ func (m *Monitor) overviewMoveVertical(dx, dy int) { | |||
| 381 | 493 | } | |
| 382 | 494 | ||
| 383 | 495 | func (m *Monitor) overviewVertical(dy int) { | |
| 384 | - | w := m.Current() | |
| 385 | - | if at := m.cardAt(w); at >= 0 { | |
| 386 | - | m.selectCard(w, min(max(at+dy, 0), len(w.Stash)-1)) | |
| 496 | + | if m.cycleCard(m.Current(), dy) { | |
| 387 | 497 | return | |
| 388 | 498 | } | |
| 389 | 499 | if m.moveStack(-dy) { | |
| @@ -402,6 +512,8 @@ func (m *Monitor) overviewStep(d int) { | |||
| 402 | 512 | } | |
| 403 | 513 | } | |
| 404 | 514 | ||
| 515 | + | // overviewHorizontal is the horizontal move of an overview whose workspaces | |
| 516 | + | // stack vertically: it never changes workspace. | |
| 405 | 517 | func (m *Monitor) overviewHorizontal(dx int) { | |
| 406 | 518 | w := m.Current() | |
| 407 | 519 | if m.cardAt(w) >= 0 { | |
| @@ -500,21 +612,36 @@ func (m *Monitor) OverviewPick(id WindowID) { | |||
| 500 | 612 | } | |
| 501 | 613 | ||
| 502 | 614 | // overviewAxis is the axis the overview lays workspaces out along, and the | |
| 503 | - | // stacked cards of a row fan along. | |
| 504 | - | func (m *Monitor) overviewAxis() layoutAxis { return verticalAxis } | |
| 505 | - | ||
| 506 | - | // overviewRows centers the selected row with its neighbors above and below. | |
| 507 | - | // Each shown workspace gets a slot, in output-local pixels, to place its | |
| 508 | - | // previews in. The numbered/named boundary gets extra spacing and an | |
| 509 | - | // inactive horizontal rule. Row widths, stash piles and preview scales | |
| 510 | - | // remain unchanged. | |
| 615 | + | // stacked cards of a row fan along: the navigation axis of the current | |
| 616 | + | // workspace's layout. | |
| 617 | + | func (m *Monitor) overviewAxis() layoutAxis { return m.Current().policy().workspace } | |
| 618 | + | ||
| 619 | + | // overviewRows places the current workspace and its overview neighbors in | |
| 620 | + | // slots, in output-local pixels, along the overview axis; each workspace | |
| 621 | + | // places its previews in its slot. The numbered/named boundary gets extra | |
| 622 | + | // spacing and an inactive rule across the axis. | |
| 623 | + | // | |
| 624 | + | // Vertical axis: full-width slots, the current one centred; the rule is | |
| 625 | + | // horizontal. Horizontal axis: full-height slots as wide as their content, | |
| 626 | + | // stash pile and stack peeks included, the current one centred; the rule is | |
| 627 | + | // vertical, in the spacing between two slots. | |
| 511 | 628 | func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) { | |
| 512 | 629 | cur := m.Current() | |
| 513 | 630 | u := cur.overviewArea() | |
| 514 | 631 | gap := max(1, u.H*3/100) | |
| 515 | - | hc := cur.rowHeight() | |
| 516 | - | y := u.Y + (u.H-hc)/2 | |
| 517 | - | rows := map[*Workspace]Rect{cur: {X: u.X, Y: y, W: u.W, H: hc}} | |
| 632 | + | axis := m.overviewAxis() | |
| 633 | + | var cs Rect | |
| 634 | + | if axis == horizontalAxis { | |
| 635 | + | // The row stays centred on the area; the pile extends the slot to | |
| 636 | + | // the left, pushing the previous workspace, unless it would leave | |
| 637 | + | // the area. | |
| 638 | + | pile, row := cur.pileWidth(), cur.rowWidth() | |
| 639 | + | cs = Rect{X: max(u.X+(u.W-row)/2-pile, u.X), Y: u.Y, W: pile + row, H: u.H} | |
| 640 | + | } else { | |
| 641 | + | hc := cur.rowHeight() | |
| 642 | + | cs = Rect{X: u.X, Y: u.Y + (u.H-hc)/2, W: u.W, H: hc} | |
| 643 | + | } | |
| 644 | + | rows := map[*Workspace]Rect{cur: cs} | |
| 518 | 645 | var dividers []ports.Separator | |
| 519 | 646 | list := m.overviewWorkspaces() | |
| 520 | 647 | i := indexOf(list, cur) | |
| @@ -528,23 +655,53 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) { | |||
| 528 | 655 | if boundary { | |
| 529 | 656 | spacing *= 2 | |
| 530 | 657 | } | |
| 531 | - | top, h := y+hc, w.rowHeight() | |
| 532 | - | if next < i { | |
| 533 | - | rows[w] = Rect{X: u.X, Y: y - spacing - h, W: u.W, H: h} | |
| 534 | - | top = y - spacing | |
| 535 | - | } else { | |
| 536 | - | rows[w] = Rect{X: u.X, Y: y + hc + spacing, W: u.W, H: h} | |
| 537 | - | } | |
| 658 | + | slot, gapAt := neighborSlot(axis, u, cs, w, next < i, spacing) | |
| 659 | + | rows[w] = slot | |
| 538 | 660 | if boundary { | |
| 539 | - | margin := u.W * 4 / 100 | |
| 540 | - | dividers = append(dividers, ports.Separator{Rect: Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1}}) | |
| 661 | + | dividers = append(dividers, overviewDivider(axis, u, gapAt+spacing/2)) | |
| 541 | 662 | } | |
| 542 | 663 | } | |
| 543 | 664 | return rows, dividers | |
| 544 | 665 | } | |
| 545 | 666 | ||
| 667 | + | // neighborSlot is the slot of w, the neighbor before or after the current | |
| 668 | + | // slot cs along axis, spacing away; gapAt is where the spacing begins. | |
| 669 | + | func neighborSlot(axis layoutAxis, u, cs Rect, w *Workspace, before bool, spacing int) (slot Rect, gapAt int) { | |
| 670 | + | if axis == horizontalAxis { | |
| 671 | + | slot = Rect{Y: u.Y, W: w.slotWidth(), H: u.H} | |
| 672 | + | if before { | |
| 673 | + | gapAt = cs.X - spacing | |
| 674 | + | slot.X = gapAt - slot.W | |
| 675 | + | } else { | |
| 676 | + | gapAt = cs.X + cs.W | |
| 677 | + | slot.X = gapAt + spacing | |
| 678 | + | } | |
| 679 | + | return slot, gapAt | |
| 680 | + | } | |
| 681 | + | h := w.rowHeight() | |
| 682 | + | slot = Rect{X: u.X, W: u.W, H: h} | |
| 683 | + | if before { | |
| 684 | + | gapAt = cs.Y - spacing | |
| 685 | + | slot.Y = gapAt - h | |
| 686 | + | } else { | |
| 687 | + | gapAt = cs.Y + cs.H | |
| 688 | + | slot.Y = gapAt + spacing | |
| 689 | + | } | |
| 690 | + | return slot, gapAt | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | // overviewDivider is the inactive rule across axis at pos, inset from the | |
| 694 | + | // edges of u. | |
| 695 | + | func overviewDivider(axis layoutAxis, u Rect, pos int) ports.Separator { | |
| 696 | + | if axis == horizontalAxis { | |
| 697 | + | return ports.Separator{Rect: Rect{X: pos, Y: u.Y + u.H*4/100, W: 1, H: u.H * 92 / 100}} | |
| 698 | + | } | |
| 699 | + | margin := u.W * 4 / 100 | |
| 700 | + | return ports.Separator{Rect: Rect{X: u.X + margin, Y: pos, W: u.W - 2*margin, H: 1}} | |
| 701 | + | } | |
| 702 | + | ||
| 546 | 703 | // overviewLayout places the current workspace and its overview neighbors, | |
| 547 | - | // each with its full-width stash pile and covering-float stack. Other rows | |
| 704 | + | // each with its stash pile and covering-float stack. Other rows | |
| 548 | 705 | // stay hidden; numbered and named workspaces share the same preview geometry. | |
| 549 | 706 | func (m *Monitor) overviewLayout(result []Placement) []Placement { | |
| 550 | 707 | cur := m.Current() | |
| @@ -556,7 +713,7 @@ func (m *Monitor) overviewLayout(result []Placement) []Placement { | |||
| 556 | 713 | at := m.cardAt(w) | |
| 557 | 714 | result = append(result, m.stackRow(w, axis, slot, w != cur, at < 0)...) | |
| 558 | 715 | if len(w.Stash) > 0 { | |
| 559 | - | result = append(result, w.pile(w.frontSlot(axis, slot), w != cur, at)...) | |
| 716 | + | result = append(result, w.pile(w.pileBand(axis, slot), w != cur, at)...) | |
| 560 | 717 | } | |
| 561 | 718 | } | |
| 562 | 719 | for _, f := range w.Floats { | |
| @@ -658,8 +815,8 @@ func (w *Workspace) cardStep() int { | |||
| 658 | 815 | return min(overviewCardStep, a.W/100, a.H/50) | |
| 659 | 816 | } | |
| 660 | 817 | ||
| 661 | - | // pile places the stash of w as cards on the left of band, the front band | |
| 662 | - | // of its row (see frontSlot): entry front (the stash selection when -1) in | |
| 818 | + | // pile places the stash of w as cards on the left of band (see pileBand): | |
| 819 | + | // entry front (the stash selection when -1) in | |
| 663 | 820 | // front, the next ones behind it, dimmed, the rest hidden. Only a front card | |
| 664 | 821 | // of the current row (dim false) with front set is selected. | |
| 665 | 822 | func (w *Workspace) pile(band Rect, dim bool, front int) []Placement { | |
| @@ -693,19 +850,48 @@ func (w *Workspace) pile(band Rect, dim bool, front int) []Placement { | |||
| 693 | 850 | // overviewZoom is the scale that fits every column of w in the usable | |
| 694 | 851 | // width, within the overview bounds. | |
| 695 | 852 | func (w *Workspace) overviewZoom() float64 { | |
| 853 | + | z, _ := w.zoomSpan() | |
| 854 | + | return z | |
| 855 | + | } | |
| 856 | + | ||
| 857 | + | // zoomSpan is overviewZoom with the unscaled span of the tiles it fits. A | |
| 858 | + | // cascade workspace spans one screen whatever its band count. | |
| 859 | + | func (w *Workspace) zoomSpan() (float64, int) { | |
| 696 | 860 | // Only the span is read: the tiles go in the zoom scratch, dead on return. | |
| 697 | 861 | var span int | |
| 698 | 862 | w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0]) | |
| 699 | 863 | if span <= 0 { | |
| 700 | - | return overviewMaxZoom | |
| 864 | + | return overviewMaxZoom, span | |
| 701 | 865 | } | |
| 702 | 866 | room := float64(w.overviewArea().W-w.pileWidth()) * 0.96 | |
| 703 | 867 | z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom) | |
| 704 | - | if len(w.stack()) > 1 { | |
| 868 | + | if w.stacked() { | |
| 705 | 869 | // Reserve two clear peeks on either side of the front row. | |
| 706 | 870 | z = min(z, overviewMaxZoom/(1+4*overviewPeekStep)) | |
| 707 | 871 | } | |
| 708 | - | return z | |
| 872 | + | return z, span | |
| 873 | + | } | |
| 874 | + | ||
| 875 | + | // slotWidth is the width of the slot of w in a horizontal overview: its | |
| 876 | + | // stash pile and its row. | |
| 877 | + | func (w *Workspace) slotWidth() int { | |
| 878 | + | return w.pileWidth() + w.rowWidth() | |
| 879 | + | } | |
| 880 | + | ||
| 881 | + | // rowWidth is the width of the row of w in a horizontal overview: what its | |
| 882 | + | // tiles take (at most what the pile leaves of the area), plus the room for | |
| 883 | + | // the covering floats peeking on either side of the front card. | |
| 884 | + | func (w *Workspace) rowWidth() int { | |
| 885 | + | z, span := w.zoomSpan() | |
| 886 | + | if span <= 0 { | |
| 887 | + | // An empty workspace keeps the room of a screen. | |
| 888 | + | span = w.Usable.W | |
| 889 | + | } | |
| 890 | + | width := min(int(math.Round(float64(span)*z)), w.overviewArea().W-w.pileWidth()) | |
| 891 | + | if w.stacked() { | |
| 892 | + | width += 4 * w.peekStep() | |
| 893 | + | } | |
| 894 | + | return width | |
| 709 | 895 | } | |
| 710 | 896 | ||
| 711 | 897 | // peekStep is the shared step for a stack row and its front-card inset. | |
| @@ -714,20 +900,37 @@ func (w *Workspace) peekStep() int { | |||
| 714 | 900 | } | |
| 715 | 901 | ||
| 716 | 902 | // frontSlot is the part of slot the front card of w takes: slot shifted | |
| 717 | - | // along axis past the room reserved for two cards peeking before it. A | |
| 718 | - | // single-card row keeps slot. | |
| 903 | + | // along axis past the room reserved for two cards peeking before it, and | |
| 904 | + | // (along a horizontal axis, where the slot is as wide as its cards) without | |
| 905 | + | // the room of the two after it. A single-card row keeps slot. | |
| 719 | 906 | func (w *Workspace) frontSlot(axis layoutAxis, slot Rect) Rect { | |
| 720 | - | if len(w.stack()) > 1 { | |
| 721 | - | axis.offset(&slot, 2*w.peekStep()) | |
| 907 | + | if w.stacked() { | |
| 908 | + | p := w.peekStep() | |
| 909 | + | axis.offset(&slot, 2*p) | |
| 910 | + | if axis == horizontalAxis { | |
| 911 | + | slot.W -= 4 * p | |
| 912 | + | } | |
| 722 | 913 | } | |
| 723 | 914 | return slot | |
| 724 | 915 | } | |
| 725 | 916 | ||
| 917 | + | // pileBand is the band the stash pile of w aligns with in its slot. A | |
| 918 | + | // vertical overview aligns it with the front card (see frontSlot). A | |
| 919 | + | // horizontal one puts it level with the selected lane, left of the whole | |
| 920 | + | // stack of cards. | |
| 921 | + | func (w *Workspace) pileBand(axis layoutAxis, slot Rect) Rect { | |
| 922 | + | if axis == verticalAxis { | |
| 923 | + | return w.frontSlot(axis, slot) | |
| 924 | + | } | |
| 925 | + | slot.Y += (slot.H - int(math.Round(float64(w.Usable.H)*w.overviewZoom()))) / 2 | |
| 926 | + | return slot | |
| 927 | + | } | |
| 928 | + | ||
| 726 | 929 | // rowHeight includes the largest possible stack envelope (two peeks above | |
| 727 | 930 | // and two below). Neighbor rows use the same measure as the current row. | |
| 728 | 931 | func (w *Workspace) rowHeight() int { | |
| 729 | 932 | h := int(math.Round(float64(w.Usable.H) * w.overviewZoom())) | |
| 730 | - | if len(w.stack()) > 1 { | |
| 933 | + | if w.stacked() { | |
| 731 | 934 | h += 4 * w.peekStep() | |
| 732 | 935 | } | |
| 733 | 936 | return h | |
| @@ -771,9 +974,10 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||
| 771 | 974 | for i, c := range w.Columns { | |
| 772 | 975 | r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h} | |
| 773 | 976 | if w.policy().wraps { | |
| 774 | - | width := w.cellWidth() | |
| 775 | - | r.X = w.band(i)*w.Usable.W + w.cellX(i) | |
| 776 | - | r.W = width | |
| 977 | + | // The band is the lane: it is encoded in Y, one screen apart. | |
| 978 | + | r.X = w.cellX(i) | |
| 979 | + | r.Y = g + w.band(i)*w.Usable.H | |
| 980 | + | r.W = w.cellWidth() | |
| 777 | 981 | } else if w.Overflow == OverflowFixed { | |
| 778 | 982 | r = rects[i] | |
| 779 | 983 | r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y | |
| @@ -794,13 +998,16 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||
| 794 | 998 | for j, t := range w.rowBuf { | |
| 795 | 999 | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus}) | |
| 796 | 1000 | } | |
| 797 | - | if w.Overflow != OverflowFixed { | |
| 1001 | + | if w.Overflow != OverflowFixed && !w.policy().wraps { | |
| 798 | 1002 | span = max(span, r.X+r.W+g) | |
| 799 | 1003 | } | |
| 800 | 1004 | if i == w.Focus { | |
| 801 | 1005 | sel = r | |
| 802 | 1006 | } | |
| 803 | 1007 | } | |
| 1008 | + | if w.policy().wraps && len(w.Columns) > 0 { | |
| 1009 | + | span = w.Usable.W | |
| 1010 | + | } | |
| 804 | 1011 | return tiles, span, sel | |
| 805 | 1012 | } | |
| 806 | 1013 | ||
| @@ -809,17 +1016,30 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||
| 809 | 1016 | // row is wider, right of the stash pile. Dimmed rows (neighbor | |
| 810 | 1017 | // workspaces) have no focus; neither has a row whose pile has it (lit | |
| 811 | 1018 | // false). | |
| 812 | - | func (w *Workspace) previewRow(slot Rect, dim, lit bool) []Placement { | |
| 1019 | + | func (w *Workspace) previewRow(axis layoutAxis, slot Rect, dim, lit bool) []Placement { | |
| 813 | 1020 | tiles, span, sel := w.previewTiles() | |
| 814 | - | return w.previewRowTiles(slot, dim, lit, tiles, span, sel) | |
| 1021 | + | return w.previewRowTiles(axis, slot, dim, lit, w.policy().wraps, tiles, span, sel) | |
| 815 | 1022 | } | |
| 816 | 1023 | ||
| 817 | 1024 | // previewRowTiles scales tiles, laid out unscrolled over span with sel the | |
| 818 | - | // focused one, into slot. Placement stays inside slot. | |
| 819 | - | func (w *Workspace) previewRowTiles(slot Rect, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement { | |
| 1025 | + | // focused one, into slot of an overview along axis. Placement stays inside | |
| 1026 | + | // slot. | |
| 1027 | + | // | |
| 1028 | + | // With lanes set (the tiles of a cascade workspace), the tiles carry their | |
| 1029 | + | // band in Y, one Usable.H apart. The band of sel is the selected lane, | |
| 1030 | + | // centred across the axis; the other bands sit a lane away from it, dimmed, | |
| 1031 | + | // and are hidden when they leave the area. A workspace whose lanes run along | |
| 1032 | + | // the overview axis shows only its selected lane. In a horizontal overview, | |
| 1033 | + | // tiles entirely outside the area are hidden. | |
| 1034 | + | func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes bool, tiles []Placement, span int, sel Rect) []Placement { | |
| 820 | 1035 | z := w.overviewZoom() | |
| 821 | 1036 | scale := func(v int) int { return int(math.Round(float64(v) * z)) } | |
| 822 | 1037 | width := scale(span) | |
| 1038 | + | u := w.overviewArea() | |
| 1039 | + | top := slot.Y | |
| 1040 | + | if axis == horizontalAxis { | |
| 1041 | + | top = slot.Y + (slot.H-scale(w.Usable.H))/2 | |
| 1042 | + | } | |
| 823 | 1043 | // Rows stay centred on the output like rows without a stash; the pile | |
| 824 | 1044 | // only pushes a row right when they would overlap. | |
| 825 | 1045 | left := w.pileWidth() | |
| @@ -829,18 +1049,41 @@ func (w *Workspace) previewRowTiles(slot Rect, dim, lit bool, tiles []Placement, | |||
| 829 | 1049 | x = ux + uw/2 - scale(sel.X+sel.W/2) | |
| 830 | 1050 | x = min(max(x, ux+uw-width), ux) | |
| 831 | 1051 | } | |
| 1052 | + | stride := w.policy().content.span(w.Usable) | |
| 1053 | + | lanes = lanes && stride > 0 | |
| 1054 | + | // Lanes along the overview axis would overlap the neighbor workspaces. | |
| 1055 | + | otherLanes := w.policy().content != axis | |
| 1056 | + | laneStep := scale(stride) + max(1, u.H*3/100) | |
| 1057 | + | selLane := 0 | |
| 1058 | + | if lanes { | |
| 1059 | + | selLane = max(sel.Y, 0) / stride | |
| 1060 | + | } | |
| 832 | 1061 | for i := range tiles { | |
| 833 | 1062 | if tiles[i].Hidden { | |
| 834 | 1063 | continue | |
| 835 | 1064 | } | |
| 836 | 1065 | r := tiles[i].Rect | |
| 837 | - | x0, y0 := scale(r.X), scale(r.Y) | |
| 838 | - | tiles[i].Rect = Rect{X: x + x0, Y: slot.Y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0} | |
| 1066 | + | lane, ly := 0, r.Y | |
| 1067 | + | if lanes { | |
| 1068 | + | l := r.Y / stride | |
| 1069 | + | lane, ly = l-selLane, r.Y-l*stride | |
| 1070 | + | } | |
| 1071 | + | x0, y0 := scale(r.X), scale(ly) | |
| 1072 | + | tiles[i].Rect = Rect{X: x + x0, Y: top + y0 + lane*laneStep, W: scale(r.X+r.W) - x0, H: scale(ly+r.H) - y0} | |
| 839 | 1073 | tiles[i].Preview = z | |
| 840 | - | if dim { | |
| 1074 | + | if dim || lane != 0 { | |
| 841 | 1075 | tiles[i].peeking(1) | |
| 842 | 1076 | } | |
| 843 | - | tiles[i].Focused = tiles[i].Focused && !dim && lit | |
| 1077 | + | tiles[i].Focused = tiles[i].Focused && !dim && lit && lane == 0 | |
| 1078 | + | out := false | |
| 1079 | + | if axis == horizontalAxis { | |
| 1080 | + | // A row wider than its slot scrolls: what leaves the slot | |
| 1081 | + | // would sit on the neighbor workspaces. | |
| 1082 | + | out = tiles[i].Rect.X < ux || tiles[i].Rect.X+tiles[i].Rect.W > ux+uw | |
| 1083 | + | } | |
| 1084 | + | if lane != 0 && !otherLanes || out || (lanes || axis == horizontalAxis) && !tiles[i].Rect.Overlaps(u) { | |
| 1085 | + | tiles[i].Hidden, tiles[i].Preview, tiles[i].Focused = true, 0, false | |
| 1086 | + | } | |
| 844 | 1087 | } | |
| 845 | 1088 | return tiles | |
| 846 | 1089 | } | |
| @@ -944,10 +1187,14 @@ func (m *Monitor) overviewFocus(a Action) bool { | |||
| 944 | 1187 | m.OverviewMove(-1, 0) | |
| 945 | 1188 | case ActionFocusColumnRight: | |
| 946 | 1189 | m.OverviewMove(1, 0) | |
| 947 | - | case ActionFocusWindowUp, ActionFocusWorkspacePrev: | |
| 1190 | + | case ActionFocusWindowUp: | |
| 948 | 1191 | m.OverviewMove(0, -1) | |
| 949 | - | case ActionFocusWindowDown, ActionFocusWorkspaceNext: | |
| 1192 | + | case ActionFocusWindowDown: | |
| 950 | 1193 | m.OverviewMove(0, 1) | |
| 1194 | + | case ActionFocusWorkspacePrev: | |
| 1195 | + | m.overviewWorkspaceStep(-1) | |
| 1196 | + | case ActionFocusWorkspaceNext: | |
| 1197 | + | m.overviewWorkspaceStep(1) | |
| 951 | 1198 | default: | |
| 952 | 1199 | return false | |
| 953 | 1200 | } | |
| @@ -1007,20 +1254,43 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |||
| 1007 | 1254 | add(&m.ov.scrollY, a.Vertical) | |
| 1008 | 1255 | } | |
| 1009 | 1256 | add(&m.ov.scrollX, a.Horizontal) | |
| 1257 | + | // A step that turns the overview returns, so the axis holds for the loop. | |
| 1258 | + | axis := m.overviewAxis() | |
| 1259 | + | cascade := axis == horizontalAxis | |
| 1010 | 1260 | for { | |
| 1011 | 1261 | switch { | |
| 1012 | 1262 | case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX): | |
| 1013 | - | m.OverviewMove(0, sign(m.ov.scrollY)) | |
| 1014 | - | m.ov.scrollY -= float64(sign(m.ov.scrollY)) * overviewScrollStep | |
| 1263 | + | d := sign(m.ov.scrollY) | |
| 1264 | + | // The step leaves before the move: a move that turns the | |
| 1265 | + | // overview drops the scroll left (showOverview). | |
| 1266 | + | m.ov.scrollY -= float64(d) * overviewScrollStep | |
| 1015 | 1267 | m.ov.scrollX = 0 | |
| 1268 | + | if cascade && !finger && m.cardAt(m.Current()) < 0 { | |
| 1269 | + | m.overviewStep(d) | |
| 1270 | + | } else { | |
| 1271 | + | m.OverviewMove(0, d) | |
| 1272 | + | } | |
| 1273 | + | if m.overviewTurned(axis, finger) { | |
| 1274 | + | return true | |
| 1275 | + | } | |
| 1016 | 1276 | if finger { | |
| 1017 | 1277 | m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true | |
| 1018 | 1278 | return true | |
| 1019 | 1279 | } | |
| 1020 | 1280 | case math.Abs(m.ov.scrollX) >= stepX: | |
| 1021 | - | m.OverviewMove(sign(m.ov.scrollX), 0) | |
| 1022 | - | m.ov.scrollX -= float64(sign(m.ov.scrollX)) * stepX | |
| 1281 | + | d := sign(m.ov.scrollX) | |
| 1282 | + | if cascade && finger { | |
| 1283 | + | // One workspace per gesture, even over a stash card. | |
| 1284 | + | m.overviewStep(d) | |
| 1285 | + | m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true | |
| 1286 | + | return true | |
| 1287 | + | } | |
| 1288 | + | m.ov.scrollX -= float64(d) * stepX | |
| 1023 | 1289 | m.ov.scrollY = 0 | |
| 1290 | + | m.OverviewMove(d, 0) | |
| 1291 | + | if m.overviewTurned(axis, finger) { | |
| 1292 | + | return true | |
| 1293 | + | } | |
| 1024 | 1294 | if finger { | |
| 1025 | 1295 | m.ov.sideways, stepX = true, overviewScrollRepeat | |
| 1026 | 1296 | } | |
| @@ -1031,6 +1301,17 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |||
| 1031 | 1301 | } | |
| 1032 | 1302 | } | |
| 1033 | 1303 | ||
| 1304 | + | // overviewTurned reports whether a scroll step turned the overview from the | |
| 1305 | + | // axis before. A finger scroll then waits for the fingers to lift, so it | |
| 1306 | + | // cannot bounce back. | |
| 1307 | + | func (m *Monitor) overviewTurned(before layoutAxis, finger bool) bool { | |
| 1308 | + | if m.overviewAxis() == before { | |
| 1309 | + | return false | |
| 1310 | + | } | |
| 1311 | + | m.ov.scrolled = m.ov.scrolled || finger | |
| 1312 | + | return true | |
| 1313 | + | } | |
| 1314 | + | ||
| 1034 | 1315 | // scrollStop ends a scroll on every monitor: the fingers lifted, maybe | |
| 1035 | 1316 | // after the pointer took the focus to another output, so the next scroll | |
| 1036 | 1317 | // starts from zero wherever it goes. | |
| @@ -31,6 +31,11 @@ type overviewState struct { | |||||
| 31 | scrolled, sideways bool | 31 | scrolled, sideways bool | ||
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
| 34 | + | // dropScroll forgets the scroll an overview axis accumulated, which belongs | |||
| 35 | + | // to the old axis once it turns. The finger latch (scrolled) stays: it waits | |||
| 36 | + | // for the fingers to lift. | |||
| 37 | + | func (o *overviewState) dropScroll() { o.scrollX, o.scrollY, o.sideways = 0, 0, false } | |||
| 38 | + | ||||
| 34 | // rowSnapshot is a row as the overview first showed it: browsing moves its | 39 | // rowSnapshot is a row as the overview first showed it: browsing moves its | ||
| 35 | // focus and scroll, closing puts them back. | 40 | // focus and scroll, closing puts them back. | ||
| 36 | type rowSnapshot struct { | 41 | type rowSnapshot struct { | ||
| @@ -353,6 +358,9 @@ func (m *Monitor) overviewWorkspaces() []*Workspace { | |||||
| 353 | // a named group uses its invocation anchor as the toggle return target, not | 358 | // a named group uses its invocation anchor as the toggle return target, not | ||
| 354 | // rows merely browsed in the overview. | 359 | // rows merely browsed in the overview. | ||
| 355 | func (m *Monitor) showOverview(w *Workspace) { | 360 | func (m *Monitor) showOverview(w *Workspace) { | ||
| 361 | + | if w.policy().workspace != m.overviewAxis() { | |||
| 362 | + | m.ov.dropScroll() | |||
| 363 | + | } | |||
| 356 | if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) { | 364 | if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) { | ||
| 357 | m.back = m.Current() | 365 | m.back = m.Current() | ||
| 358 | if indexOf(m.Workspaces, w.overviewAfter) >= 0 { | 366 | if indexOf(m.Workspaces, w.overviewAfter) >= 0 { | ||
| @@ -366,9 +374,113 @@ func (m *Monitor) showOverview(w *Workspace) { | |||||
| 366 | // OverviewMove shares navigation rules across keys, scrolls and swipes. It | 374 | // OverviewMove shares navigation rules across keys, scrolls and swipes. It | ||
| 367 | // is the physical entry point: it maps dx and dy to the overview's moves. | 375 | // is the physical entry point: it maps dx and dy to the overview's moves. | ||
| 368 | func (m *Monitor) OverviewMove(dx, dy int) { | 376 | func (m *Monitor) OverviewMove(dx, dy int) { | ||
| 377 | + | if m.overviewAxis() == horizontalAxis { | |||
| 378 | + | if dy != 0 { | |||
| 379 | + | m.overviewLane(dy) | |||
| 380 | + | } else if dx != 0 { | |||
| 381 | + | m.overviewAcross(dx) | |||
| 382 | + | } | |||
| 383 | + | return | |||
| 384 | + | } | |||
| 369 | m.overviewMoveVertical(dx, dy) | 385 | m.overviewMoveVertical(dx, dy) | ||
| 370 | } | 386 | } | ||
| 371 | 387 | ||||
| 388 | + | // overviewWorkspaceStep is a focus-workspace bind. A vertical overview | |||
| 389 | + | // walks the stack cards before changing workspace (overviewVertical). A | |||
| 390 | + | // side-by-side one always changes workspace, since its stack fans left and | |||
| 391 | + | // right. A selected stash card cycles the pile first, in every layout. | |||
| 392 | + | func (m *Monitor) overviewWorkspaceStep(d int) { | |||
| 393 | + | if m.overviewAxis() == verticalAxis { | |||
| 394 | + | m.overviewVertical(d) | |||
| 395 | + | return | |||
| 396 | + | } | |||
| 397 | + | if !m.cycleCard(m.Current(), d) { | |||
| 398 | + | m.overviewStep(d) | |||
| 399 | + | } | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | // cycleCard brings the stash card d entries from the selected one of w to | |||
| 403 | + | // the front, stopping at either end. It reports whether a card was selected. | |||
| 404 | + | func (m *Monitor) cycleCard(w *Workspace, d int) bool { | |||
| 405 | + | at := m.cardAt(w) | |||
| 406 | + | if at < 0 { | |||
| 407 | + | return false | |||
| 408 | + | } | |||
| 409 | + | m.selectCard(w, min(max(at+d, 0), len(w.Stash)-1)) | |||
| 410 | + | return true | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | // overviewLane is the vertical move of an overview whose workspaces sit side | |||
| 414 | + | // by side (cascade): a selected stash card cycles the pile; else the | |||
| 415 | + | // selection goes to the previous or next lane (band), at the closest column, | |||
| 416 | + | // stopping at the first and last. Covering floats stack along the horizontal | |||
| 417 | + | // axis, not this one. | |||
| 418 | + | func (m *Monitor) overviewLane(dy int) { | |||
| 419 | + | w := m.Current() | |||
| 420 | + | if m.cycleCard(w, dy) || m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 { | |||
| 421 | + | return | |||
| 422 | + | } | |||
| 423 | + | if i := w.laneNeighbor(dy); i >= 0 { | |||
| 424 | + | m.selectLaneColumn(w, i) | |||
| 425 | + | } | |||
| 426 | + | } | |||
| 427 | + | ||||
| 428 | + | // selectLaneColumn selects column i of w. | |||
| 429 | + | func (m *Monitor) selectLaneColumn(w *Workspace, i int) { | |||
| 430 | + | w.selectOverviewColumn(i) | |||
| 431 | + | m.ov.selected = w.Columns[i].Windows[w.Columns[i].Focus] | |||
| 432 | + | m.ov.selectedAt = i | |||
| 433 | + | } | |||
| 434 | + | ||||
| 435 | + | // overviewAcross is the horizontal move of an overview whose workspaces sit | |||
| 436 | + | // side by side (cascade): a column inside the selected lane. Past the | |||
| 437 | + | // lane's first column it goes to the stash, else to the previous workspace; | |||
| 438 | + | // past the last column, to the next workspace. Covering floats fan along | |||
| 439 | + | // this axis and are traversed before the stash or a workspace: the card on | |||
| 440 | + | // the left is the one behind. | |||
| 441 | + | func (m *Monitor) overviewAcross(dx int) { | |||
| 442 | + | w := m.Current() | |||
| 443 | + | if m.cardAt(w) >= 0 { | |||
| 444 | + | if dx < 0 || len(w.stack()) == 0 { | |||
| 445 | + | // Left leaves the pile; a stash-only workspace has nothing | |||
| 446 | + | // to its right to select. | |||
| 447 | + | m.overviewStep(dx) | |||
| 448 | + | return | |||
| 449 | + | } | |||
| 450 | + | m.ov.card, m.ov.cardOf = 0, nil | |||
| 451 | + | m.ov.selected = 0 | |||
| 452 | + | if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 { | |||
| 453 | + | first, _ := w.laneBounds(w.laneOf(w.Focus)) | |||
| 454 | + | m.selectLaneColumn(w, first) | |||
| 455 | + | } | |||
| 456 | + | return | |||
| 457 | + | } | |||
| 458 | + | if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 { | |||
| 459 | + | m.laneEdge(w, dx) | |||
| 460 | + | return | |||
| 461 | + | } | |||
| 462 | + | first, last := w.laneBounds(w.laneOf(w.Focus)) | |||
| 463 | + | if next := w.Focus + dx; next >= first && next <= last { | |||
| 464 | + | m.selectLaneColumn(w, next) | |||
| 465 | + | return | |||
| 466 | + | } | |||
| 467 | + | m.laneEdge(w, dx) | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | // laneEdge is a horizontal move off the edge of the selected lane, or off a | |||
| 471 | + | // card that is not the columns: the stack first, then the stash on the left | |||
| 472 | + | // or a workspace. | |||
| 473 | + | func (m *Monitor) laneEdge(w *Workspace, dx int) { | |||
| 474 | + | if m.moveStack(-dx) { | |||
| 475 | + | return | |||
| 476 | + | } | |||
| 477 | + | if dx < 0 && len(w.Stash) > 0 { | |||
| 478 | + | m.selectCard(w, w.stashAt) | |||
| 479 | + | return | |||
| 480 | + | } | |||
| 481 | + | m.overviewStep(dx) | |||
| 482 | + | } | |||
| 483 | + | ||||
| 372 | // overviewMoveVertical is the move of an overview whose workspaces stack | 484 | // overviewMoveVertical is the move of an overview whose workspaces stack | ||
| 373 | // vertically. Vertical moves stay in a selected stash pile; left stays, and | 485 | // vertically. Vertical moves stay in a selected stash pile; left stays, and | ||
| 374 | // right deselects it, even when the row has no main card. | 486 | // right deselects it, even when the row has no main card. | ||
| @@ -381,9 +493,7 @@ func (m *Monitor) overviewMoveVertical(dx, dy int) { | |||||
| 381 | } | 493 | } | ||
| 382 | 494 | ||||
| 383 | func (m *Monitor) overviewVertical(dy int) { | 495 | func (m *Monitor) overviewVertical(dy int) { | ||
| 384 | - | w := m.Current() | 496 | + | if m.cycleCard(m.Current(), dy) { |
| 385 | - | if at := m.cardAt(w); at >= 0 { | |||
| 386 | - | m.selectCard(w, min(max(at+dy, 0), len(w.Stash)-1)) | |||
| 387 | return | 497 | return | ||
| 388 | } | 498 | } | ||
| 389 | if m.moveStack(-dy) { | 499 | if m.moveStack(-dy) { | ||
| @@ -402,6 +512,8 @@ func (m *Monitor) overviewStep(d int) { | |||||
| 402 | } | 512 | } | ||
| 403 | } | 513 | } | ||
| 404 | 514 | ||||
| 515 | + | // overviewHorizontal is the horizontal move of an overview whose workspaces | |||
| 516 | + | // stack vertically: it never changes workspace. | |||
| 405 | func (m *Monitor) overviewHorizontal(dx int) { | 517 | func (m *Monitor) overviewHorizontal(dx int) { | ||
| 406 | w := m.Current() | 518 | w := m.Current() | ||
| 407 | if m.cardAt(w) >= 0 { | 519 | if m.cardAt(w) >= 0 { | ||
| @@ -500,21 +612,36 @@ func (m *Monitor) OverviewPick(id WindowID) { | |||||
| 500 | } | 612 | } | ||
| 501 | 613 | ||||
| 502 | // overviewAxis is the axis the overview lays workspaces out along, and the | 614 | // overviewAxis is the axis the overview lays workspaces out along, and the | ||
| 503 | - | // stacked cards of a row fan along. | 615 | + | // stacked cards of a row fan along: the navigation axis of the current |
| 504 | - | func (m *Monitor) overviewAxis() layoutAxis { return verticalAxis } | 616 | + | // workspace's layout. |
| 505 | - | 617 | + | func (m *Monitor) overviewAxis() layoutAxis { return m.Current().policy().workspace } | |
| 506 | - | // overviewRows centers the selected row with its neighbors above and below. | 618 | + | |
| 507 | - | // Each shown workspace gets a slot, in output-local pixels, to place its | 619 | + | // overviewRows places the current workspace and its overview neighbors in |
| 508 | - | // previews in. The numbered/named boundary gets extra spacing and an | 620 | + | // slots, in output-local pixels, along the overview axis; each workspace |
| 509 | - | // inactive horizontal rule. Row widths, stash piles and preview scales | 621 | + | // places its previews in its slot. The numbered/named boundary gets extra |
| 510 | - | // remain unchanged. | 622 | + | // spacing and an inactive rule across the axis. |
| 623 | + | // | |||
| 624 | + | // Vertical axis: full-width slots, the current one centred; the rule is | |||
| 625 | + | // horizontal. Horizontal axis: full-height slots as wide as their content, | |||
| 626 | + | // stash pile and stack peeks included, the current one centred; the rule is | |||
| 627 | + | // vertical, in the spacing between two slots. | |||
| 511 | func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) { | 628 | func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) { | ||
| 512 | cur := m.Current() | 629 | cur := m.Current() | ||
| 513 | u := cur.overviewArea() | 630 | u := cur.overviewArea() | ||
| 514 | gap := max(1, u.H*3/100) | 631 | gap := max(1, u.H*3/100) | ||
| 515 | - | hc := cur.rowHeight() | 632 | + | axis := m.overviewAxis() |
| 516 | - | y := u.Y + (u.H-hc)/2 | 633 | + | var cs Rect |
| 517 | - | rows := map[*Workspace]Rect{cur: {X: u.X, Y: y, W: u.W, H: hc}} | 634 | + | if axis == horizontalAxis { |
| 635 | + | // The row stays centred on the area; the pile extends the slot to | |||
| 636 | + | // the left, pushing the previous workspace, unless it would leave | |||
| 637 | + | // the area. | |||
| 638 | + | pile, row := cur.pileWidth(), cur.rowWidth() | |||
| 639 | + | cs = Rect{X: max(u.X+(u.W-row)/2-pile, u.X), Y: u.Y, W: pile + row, H: u.H} | |||
| 640 | + | } else { | |||
| 641 | + | hc := cur.rowHeight() | |||
| 642 | + | cs = Rect{X: u.X, Y: u.Y + (u.H-hc)/2, W: u.W, H: hc} | |||
| 643 | + | } | |||
| 644 | + | rows := map[*Workspace]Rect{cur: cs} | |||
| 518 | var dividers []ports.Separator | 645 | var dividers []ports.Separator | ||
| 519 | list := m.overviewWorkspaces() | 646 | list := m.overviewWorkspaces() | ||
| 520 | i := indexOf(list, cur) | 647 | i := indexOf(list, cur) | ||
| @@ -528,23 +655,53 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) { | |||||
| 528 | if boundary { | 655 | if boundary { | ||
| 529 | spacing *= 2 | 656 | spacing *= 2 | ||
| 530 | } | 657 | } | ||
| 531 | - | top, h := y+hc, w.rowHeight() | 658 | + | slot, gapAt := neighborSlot(axis, u, cs, w, next < i, spacing) |
| 532 | - | if next < i { | 659 | + | rows[w] = slot |
| 533 | - | rows[w] = Rect{X: u.X, Y: y - spacing - h, W: u.W, H: h} | |||
| 534 | - | top = y - spacing | |||
| 535 | - | } else { | |||
| 536 | - | rows[w] = Rect{X: u.X, Y: y + hc + spacing, W: u.W, H: h} | |||
| 537 | - | } | |||
| 538 | if boundary { | 660 | if boundary { | ||
| 539 | - | margin := u.W * 4 / 100 | 661 | + | dividers = append(dividers, overviewDivider(axis, u, gapAt+spacing/2)) |
| 540 | - | dividers = append(dividers, ports.Separator{Rect: Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1}}) | |||
| 541 | } | 662 | } | ||
| 542 | } | 663 | } | ||
| 543 | return rows, dividers | 664 | return rows, dividers | ||
| 544 | } | 665 | } | ||
| 545 | 666 | ||||
| 667 | + | // neighborSlot is the slot of w, the neighbor before or after the current | |||
| 668 | + | // slot cs along axis, spacing away; gapAt is where the spacing begins. | |||
| 669 | + | func neighborSlot(axis layoutAxis, u, cs Rect, w *Workspace, before bool, spacing int) (slot Rect, gapAt int) { | |||
| 670 | + | if axis == horizontalAxis { | |||
| 671 | + | slot = Rect{Y: u.Y, W: w.slotWidth(), H: u.H} | |||
| 672 | + | if before { | |||
| 673 | + | gapAt = cs.X - spacing | |||
| 674 | + | slot.X = gapAt - slot.W | |||
| 675 | + | } else { | |||
| 676 | + | gapAt = cs.X + cs.W | |||
| 677 | + | slot.X = gapAt + spacing | |||
| 678 | + | } | |||
| 679 | + | return slot, gapAt | |||
| 680 | + | } | |||
| 681 | + | h := w.rowHeight() | |||
| 682 | + | slot = Rect{X: u.X, W: u.W, H: h} | |||
| 683 | + | if before { | |||
| 684 | + | gapAt = cs.Y - spacing | |||
| 685 | + | slot.Y = gapAt - h | |||
| 686 | + | } else { | |||
| 687 | + | gapAt = cs.Y + cs.H | |||
| 688 | + | slot.Y = gapAt + spacing | |||
| 689 | + | } | |||
| 690 | + | return slot, gapAt | |||
| 691 | + | } | |||
| 692 | + | ||||
| 693 | + | // overviewDivider is the inactive rule across axis at pos, inset from the | |||
| 694 | + | // edges of u. | |||
| 695 | + | func overviewDivider(axis layoutAxis, u Rect, pos int) ports.Separator { | |||
| 696 | + | if axis == horizontalAxis { | |||
| 697 | + | return ports.Separator{Rect: Rect{X: pos, Y: u.Y + u.H*4/100, W: 1, H: u.H * 92 / 100}} | |||
| 698 | + | } | |||
| 699 | + | margin := u.W * 4 / 100 | |||
| 700 | + | return ports.Separator{Rect: Rect{X: u.X + margin, Y: pos, W: u.W - 2*margin, H: 1}} | |||
| 701 | + | } | |||
| 702 | + | ||||
| 546 | // overviewLayout places the current workspace and its overview neighbors, | 703 | // overviewLayout places the current workspace and its overview neighbors, | ||
| 547 | - | // each with its full-width stash pile and covering-float stack. Other rows | 704 | + | // each with its stash pile and covering-float stack. Other rows |
| 548 | // stay hidden; numbered and named workspaces share the same preview geometry. | 705 | // stay hidden; numbered and named workspaces share the same preview geometry. | ||
| 549 | func (m *Monitor) overviewLayout(result []Placement) []Placement { | 706 | func (m *Monitor) overviewLayout(result []Placement) []Placement { | ||
| 550 | cur := m.Current() | 707 | cur := m.Current() | ||
| @@ -556,7 +713,7 @@ func (m *Monitor) overviewLayout(result []Placement) []Placement { | |||||
| 556 | at := m.cardAt(w) | 713 | at := m.cardAt(w) | ||
| 557 | result = append(result, m.stackRow(w, axis, slot, w != cur, at < 0)...) | 714 | result = append(result, m.stackRow(w, axis, slot, w != cur, at < 0)...) | ||
| 558 | if len(w.Stash) > 0 { | 715 | if len(w.Stash) > 0 { | ||
| 559 | - | result = append(result, w.pile(w.frontSlot(axis, slot), w != cur, at)...) | 716 | + | result = append(result, w.pile(w.pileBand(axis, slot), w != cur, at)...) |
| 560 | } | 717 | } | ||
| 561 | } | 718 | } | ||
| 562 | for _, f := range w.Floats { | 719 | for _, f := range w.Floats { | ||
| @@ -658,8 +815,8 @@ func (w *Workspace) cardStep() int { | |||||
| 658 | return min(overviewCardStep, a.W/100, a.H/50) | 815 | return min(overviewCardStep, a.W/100, a.H/50) | ||
| 659 | } | 816 | } | ||
| 660 | 817 | ||||
| 661 | - | // pile places the stash of w as cards on the left of band, the front band | 818 | + | // pile places the stash of w as cards on the left of band (see pileBand): |
| 662 | - | // of its row (see frontSlot): entry front (the stash selection when -1) in | 819 | + | // entry front (the stash selection when -1) in |
| 663 | // front, the next ones behind it, dimmed, the rest hidden. Only a front card | 820 | // front, the next ones behind it, dimmed, the rest hidden. Only a front card | ||
| 664 | // of the current row (dim false) with front set is selected. | 821 | // of the current row (dim false) with front set is selected. | ||
| 665 | func (w *Workspace) pile(band Rect, dim bool, front int) []Placement { | 822 | func (w *Workspace) pile(band Rect, dim bool, front int) []Placement { | ||
| @@ -693,19 +850,48 @@ func (w *Workspace) pile(band Rect, dim bool, front int) []Placement { | |||||
| 693 | // overviewZoom is the scale that fits every column of w in the usable | 850 | // overviewZoom is the scale that fits every column of w in the usable | ||
| 694 | // width, within the overview bounds. | 851 | // width, within the overview bounds. | ||
| 695 | func (w *Workspace) overviewZoom() float64 { | 852 | func (w *Workspace) overviewZoom() float64 { | ||
| 853 | + | z, _ := w.zoomSpan() | |||
| 854 | + | return z | |||
| 855 | + | } | |||
| 856 | + | ||||
| 857 | + | // zoomSpan is overviewZoom with the unscaled span of the tiles it fits. A | |||
| 858 | + | // cascade workspace spans one screen whatever its band count. | |||
| 859 | + | func (w *Workspace) zoomSpan() (float64, int) { | |||
| 696 | // Only the span is read: the tiles go in the zoom scratch, dead on return. | 860 | // Only the span is read: the tiles go in the zoom scratch, dead on return. | ||
| 697 | var span int | 861 | var span int | ||
| 698 | w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0]) | 862 | w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0]) | ||
| 699 | if span <= 0 { | 863 | if span <= 0 { | ||
| 700 | - | return overviewMaxZoom | 864 | + | return overviewMaxZoom, span |
| 701 | } | 865 | } | ||
| 702 | room := float64(w.overviewArea().W-w.pileWidth()) * 0.96 | 866 | room := float64(w.overviewArea().W-w.pileWidth()) * 0.96 | ||
| 703 | z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom) | 867 | z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom) | ||
| 704 | - | if len(w.stack()) > 1 { | 868 | + | if w.stacked() { |
| 705 | // Reserve two clear peeks on either side of the front row. | 869 | // Reserve two clear peeks on either side of the front row. | ||
| 706 | z = min(z, overviewMaxZoom/(1+4*overviewPeekStep)) | 870 | z = min(z, overviewMaxZoom/(1+4*overviewPeekStep)) | ||
| 707 | } | 871 | } | ||
| 708 | - | return z | 872 | + | return z, span |
| 873 | + | } | |||
| 874 | + | ||||
| 875 | + | // slotWidth is the width of the slot of w in a horizontal overview: its | |||
| 876 | + | // stash pile and its row. | |||
| 877 | + | func (w *Workspace) slotWidth() int { | |||
| 878 | + | return w.pileWidth() + w.rowWidth() | |||
| 879 | + | } | |||
| 880 | + | ||||
| 881 | + | // rowWidth is the width of the row of w in a horizontal overview: what its | |||
| 882 | + | // tiles take (at most what the pile leaves of the area), plus the room for | |||
| 883 | + | // the covering floats peeking on either side of the front card. | |||
| 884 | + | func (w *Workspace) rowWidth() int { | |||
| 885 | + | z, span := w.zoomSpan() | |||
| 886 | + | if span <= 0 { | |||
| 887 | + | // An empty workspace keeps the room of a screen. | |||
| 888 | + | span = w.Usable.W | |||
| 889 | + | } | |||
| 890 | + | width := min(int(math.Round(float64(span)*z)), w.overviewArea().W-w.pileWidth()) | |||
| 891 | + | if w.stacked() { | |||
| 892 | + | width += 4 * w.peekStep() | |||
| 893 | + | } | |||
| 894 | + | return width | |||
| 709 | } | 895 | } | ||
| 710 | 896 | ||||
| 711 | // peekStep is the shared step for a stack row and its front-card inset. | 897 | // peekStep is the shared step for a stack row and its front-card inset. | ||
| @@ -714,20 +900,37 @@ func (w *Workspace) peekStep() int { | |||||
| 714 | } | 900 | } | ||
| 715 | 901 | ||||
| 716 | // frontSlot is the part of slot the front card of w takes: slot shifted | 902 | // frontSlot is the part of slot the front card of w takes: slot shifted | ||
| 717 | - | // along axis past the room reserved for two cards peeking before it. A | 903 | + | // along axis past the room reserved for two cards peeking before it, and |
| 718 | - | // single-card row keeps slot. | 904 | + | // (along a horizontal axis, where the slot is as wide as its cards) without |
| 905 | + | // the room of the two after it. A single-card row keeps slot. | |||
| 719 | func (w *Workspace) frontSlot(axis layoutAxis, slot Rect) Rect { | 906 | func (w *Workspace) frontSlot(axis layoutAxis, slot Rect) Rect { | ||
| 720 | - | if len(w.stack()) > 1 { | 907 | + | if w.stacked() { |
| 721 | - | axis.offset(&slot, 2*w.peekStep()) | 908 | + | p := w.peekStep() |
| 909 | + | axis.offset(&slot, 2*p) | |||
| 910 | + | if axis == horizontalAxis { | |||
| 911 | + | slot.W -= 4 * p | |||
| 912 | + | } | |||
| 722 | } | 913 | } | ||
| 723 | return slot | 914 | return slot | ||
| 724 | } | 915 | } | ||
| 725 | 916 | ||||
| 917 | + | // pileBand is the band the stash pile of w aligns with in its slot. A | |||
| 918 | + | // vertical overview aligns it with the front card (see frontSlot). A | |||
| 919 | + | // horizontal one puts it level with the selected lane, left of the whole | |||
| 920 | + | // stack of cards. | |||
| 921 | + | func (w *Workspace) pileBand(axis layoutAxis, slot Rect) Rect { | |||
| 922 | + | if axis == verticalAxis { | |||
| 923 | + | return w.frontSlot(axis, slot) | |||
| 924 | + | } | |||
| 925 | + | slot.Y += (slot.H - int(math.Round(float64(w.Usable.H)*w.overviewZoom()))) / 2 | |||
| 926 | + | return slot | |||
| 927 | + | } | |||
| 928 | + | ||||
| 726 | // rowHeight includes the largest possible stack envelope (two peeks above | 929 | // rowHeight includes the largest possible stack envelope (two peeks above | ||
| 727 | // and two below). Neighbor rows use the same measure as the current row. | 930 | // and two below). Neighbor rows use the same measure as the current row. | ||
| 728 | func (w *Workspace) rowHeight() int { | 931 | func (w *Workspace) rowHeight() int { | ||
| 729 | h := int(math.Round(float64(w.Usable.H) * w.overviewZoom())) | 932 | h := int(math.Round(float64(w.Usable.H) * w.overviewZoom())) | ||
| 730 | - | if len(w.stack()) > 1 { | 933 | + | if w.stacked() { |
| 731 | h += 4 * w.peekStep() | 934 | h += 4 * w.peekStep() | ||
| 732 | } | 935 | } | ||
| 733 | return h | 936 | return h | ||
| @@ -771,9 +974,10 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||||
| 771 | for i, c := range w.Columns { | 974 | for i, c := range w.Columns { | ||
| 772 | r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h} | 975 | r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h} | ||
| 773 | if w.policy().wraps { | 976 | if w.policy().wraps { | ||
| 774 | - | width := w.cellWidth() | 977 | + | // The band is the lane: it is encoded in Y, one screen apart. |
| 775 | - | r.X = w.band(i)*w.Usable.W + w.cellX(i) | 978 | + | r.X = w.cellX(i) |
| 776 | - | r.W = width | 979 | + | r.Y = g + w.band(i)*w.Usable.H |
| 980 | + | r.W = w.cellWidth() | |||
| 777 | } else if w.Overflow == OverflowFixed { | 981 | } else if w.Overflow == OverflowFixed { | ||
| 778 | r = rects[i] | 982 | r = rects[i] | ||
| 779 | r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y | 983 | r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y | ||
| @@ -794,13 +998,16 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||||
| 794 | for j, t := range w.rowBuf { | 998 | for j, t := range w.rowBuf { | ||
| 795 | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus}) | 999 | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus}) | ||
| 796 | } | 1000 | } | ||
| 797 | - | if w.Overflow != OverflowFixed { | 1001 | + | if w.Overflow != OverflowFixed && !w.policy().wraps { |
| 798 | span = max(span, r.X+r.W+g) | 1002 | span = max(span, r.X+r.W+g) | ||
| 799 | } | 1003 | } | ||
| 800 | if i == w.Focus { | 1004 | if i == w.Focus { | ||
| 801 | sel = r | 1005 | sel = r | ||
| 802 | } | 1006 | } | ||
| 803 | } | 1007 | } | ||
| 1008 | + | if w.policy().wraps && len(w.Columns) > 0 { | |||
| 1009 | + | span = w.Usable.W | |||
| 1010 | + | } | |||
| 804 | return tiles, span, sel | 1011 | return tiles, span, sel | ||
| 805 | } | 1012 | } | ||
| 806 | 1013 | ||||
| @@ -809,17 +1016,30 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||||
| 809 | // row is wider, right of the stash pile. Dimmed rows (neighbor | 1016 | // row is wider, right of the stash pile. Dimmed rows (neighbor | ||
| 810 | // workspaces) have no focus; neither has a row whose pile has it (lit | 1017 | // workspaces) have no focus; neither has a row whose pile has it (lit | ||
| 811 | // false). | 1018 | // false). | ||
| 812 | - | func (w *Workspace) previewRow(slot Rect, dim, lit bool) []Placement { | 1019 | + | func (w *Workspace) previewRow(axis layoutAxis, slot Rect, dim, lit bool) []Placement { |
| 813 | tiles, span, sel := w.previewTiles() | 1020 | tiles, span, sel := w.previewTiles() | ||
| 814 | - | return w.previewRowTiles(slot, dim, lit, tiles, span, sel) | 1021 | + | return w.previewRowTiles(axis, slot, dim, lit, w.policy().wraps, tiles, span, sel) |
| 815 | } | 1022 | } | ||
| 816 | 1023 | ||||
| 817 | // previewRowTiles scales tiles, laid out unscrolled over span with sel the | 1024 | // previewRowTiles scales tiles, laid out unscrolled over span with sel the | ||
| 818 | - | // focused one, into slot. Placement stays inside slot. | 1025 | + | // focused one, into slot of an overview along axis. Placement stays inside |
| 819 | - | func (w *Workspace) previewRowTiles(slot Rect, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement { | 1026 | + | // slot. |
| 1027 | + | // | |||
| 1028 | + | // With lanes set (the tiles of a cascade workspace), the tiles carry their | |||
| 1029 | + | // band in Y, one Usable.H apart. The band of sel is the selected lane, | |||
| 1030 | + | // centred across the axis; the other bands sit a lane away from it, dimmed, | |||
| 1031 | + | // and are hidden when they leave the area. A workspace whose lanes run along | |||
| 1032 | + | // the overview axis shows only its selected lane. In a horizontal overview, | |||
| 1033 | + | // tiles entirely outside the area are hidden. | |||
| 1034 | + | func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes bool, tiles []Placement, span int, sel Rect) []Placement { | |||
| 820 | z := w.overviewZoom() | 1035 | z := w.overviewZoom() | ||
| 821 | scale := func(v int) int { return int(math.Round(float64(v) * z)) } | 1036 | scale := func(v int) int { return int(math.Round(float64(v) * z)) } | ||
| 822 | width := scale(span) | 1037 | width := scale(span) | ||
| 1038 | + | u := w.overviewArea() | |||
| 1039 | + | top := slot.Y | |||
| 1040 | + | if axis == horizontalAxis { | |||
| 1041 | + | top = slot.Y + (slot.H-scale(w.Usable.H))/2 | |||
| 1042 | + | } | |||
| 823 | // Rows stay centred on the output like rows without a stash; the pile | 1043 | // Rows stay centred on the output like rows without a stash; the pile | ||
| 824 | // only pushes a row right when they would overlap. | 1044 | // only pushes a row right when they would overlap. | ||
| 825 | left := w.pileWidth() | 1045 | left := w.pileWidth() | ||
| @@ -829,18 +1049,41 @@ func (w *Workspace) previewRowTiles(slot Rect, dim, lit bool, tiles []Placement, | |||||
| 829 | x = ux + uw/2 - scale(sel.X+sel.W/2) | 1049 | x = ux + uw/2 - scale(sel.X+sel.W/2) | ||
| 830 | x = min(max(x, ux+uw-width), ux) | 1050 | x = min(max(x, ux+uw-width), ux) | ||
| 831 | } | 1051 | } | ||
| 1052 | + | stride := w.policy().content.span(w.Usable) | |||
| 1053 | + | lanes = lanes && stride > 0 | |||
| 1054 | + | // Lanes along the overview axis would overlap the neighbor workspaces. | |||
| 1055 | + | otherLanes := w.policy().content != axis | |||
| 1056 | + | laneStep := scale(stride) + max(1, u.H*3/100) | |||
| 1057 | + | selLane := 0 | |||
| 1058 | + | if lanes { | |||
| 1059 | + | selLane = max(sel.Y, 0) / stride | |||
| 1060 | + | } | |||
| 832 | for i := range tiles { | 1061 | for i := range tiles { | ||
| 833 | if tiles[i].Hidden { | 1062 | if tiles[i].Hidden { | ||
| 834 | continue | 1063 | continue | ||
| 835 | } | 1064 | } | ||
| 836 | r := tiles[i].Rect | 1065 | r := tiles[i].Rect | ||
| 837 | - | x0, y0 := scale(r.X), scale(r.Y) | 1066 | + | lane, ly := 0, r.Y |
| 838 | - | tiles[i].Rect = Rect{X: x + x0, Y: slot.Y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0} | 1067 | + | if lanes { |
| 1068 | + | l := r.Y / stride | |||
| 1069 | + | lane, ly = l-selLane, r.Y-l*stride | |||
| 1070 | + | } | |||
| 1071 | + | x0, y0 := scale(r.X), scale(ly) | |||
| 1072 | + | tiles[i].Rect = Rect{X: x + x0, Y: top + y0 + lane*laneStep, W: scale(r.X+r.W) - x0, H: scale(ly+r.H) - y0} | |||
| 839 | tiles[i].Preview = z | 1073 | tiles[i].Preview = z | ||
| 840 | - | if dim { | 1074 | + | if dim || lane != 0 { |
| 841 | tiles[i].peeking(1) | 1075 | tiles[i].peeking(1) | ||
| 842 | } | 1076 | } | ||
| 843 | - | tiles[i].Focused = tiles[i].Focused && !dim && lit | 1077 | + | tiles[i].Focused = tiles[i].Focused && !dim && lit && lane == 0 |
| 1078 | + | out := false | |||
| 1079 | + | if axis == horizontalAxis { | |||
| 1080 | + | // A row wider than its slot scrolls: what leaves the slot | |||
| 1081 | + | // would sit on the neighbor workspaces. | |||
| 1082 | + | out = tiles[i].Rect.X < ux || tiles[i].Rect.X+tiles[i].Rect.W > ux+uw | |||
| 1083 | + | } | |||
| 1084 | + | if lane != 0 && !otherLanes || out || (lanes || axis == horizontalAxis) && !tiles[i].Rect.Overlaps(u) { | |||
| 1085 | + | tiles[i].Hidden, tiles[i].Preview, tiles[i].Focused = true, 0, false | |||
| 1086 | + | } | |||
| 844 | } | 1087 | } | ||
| 845 | return tiles | 1088 | return tiles | ||
| 846 | } | 1089 | } | ||
| @@ -944,10 +1187,14 @@ func (m *Monitor) overviewFocus(a Action) bool { | |||||
| 944 | m.OverviewMove(-1, 0) | 1187 | m.OverviewMove(-1, 0) | ||
| 945 | case ActionFocusColumnRight: | 1188 | case ActionFocusColumnRight: | ||
| 946 | m.OverviewMove(1, 0) | 1189 | m.OverviewMove(1, 0) | ||
| 947 | - | case ActionFocusWindowUp, ActionFocusWorkspacePrev: | 1190 | + | case ActionFocusWindowUp: |
| 948 | m.OverviewMove(0, -1) | 1191 | m.OverviewMove(0, -1) | ||
| 949 | - | case ActionFocusWindowDown, ActionFocusWorkspaceNext: | 1192 | + | case ActionFocusWindowDown: |
| 950 | m.OverviewMove(0, 1) | 1193 | m.OverviewMove(0, 1) | ||
| 1194 | + | case ActionFocusWorkspacePrev: | |||
| 1195 | + | m.overviewWorkspaceStep(-1) | |||
| 1196 | + | case ActionFocusWorkspaceNext: | |||
| 1197 | + | m.overviewWorkspaceStep(1) | |||
| 951 | default: | 1198 | default: | ||
| 952 | return false | 1199 | return false | ||
| 953 | } | 1200 | } | ||
| @@ -1007,20 +1254,43 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |||||
| 1007 | add(&m.ov.scrollY, a.Vertical) | 1254 | add(&m.ov.scrollY, a.Vertical) | ||
| 1008 | } | 1255 | } | ||
| 1009 | add(&m.ov.scrollX, a.Horizontal) | 1256 | add(&m.ov.scrollX, a.Horizontal) | ||
| 1257 | + | // A step that turns the overview returns, so the axis holds for the loop. | |||
| 1258 | + | axis := m.overviewAxis() | |||
| 1259 | + | cascade := axis == horizontalAxis | |||
| 1010 | for { | 1260 | for { | ||
| 1011 | switch { | 1261 | switch { | ||
| 1012 | case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX): | 1262 | case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX): | ||
| 1013 | - | m.OverviewMove(0, sign(m.ov.scrollY)) | 1263 | + | d := sign(m.ov.scrollY) |
| 1014 | - | m.ov.scrollY -= float64(sign(m.ov.scrollY)) * overviewScrollStep | 1264 | + | // The step leaves before the move: a move that turns the |
| 1265 | + | // overview drops the scroll left (showOverview). | |||
| 1266 | + | m.ov.scrollY -= float64(d) * overviewScrollStep | |||
| 1015 | m.ov.scrollX = 0 | 1267 | m.ov.scrollX = 0 | ||
| 1268 | + | if cascade && !finger && m.cardAt(m.Current()) < 0 { | |||
| 1269 | + | m.overviewStep(d) | |||
| 1270 | + | } else { | |||
| 1271 | + | m.OverviewMove(0, d) | |||
| 1272 | + | } | |||
| 1273 | + | if m.overviewTurned(axis, finger) { | |||
| 1274 | + | return true | |||
| 1275 | + | } | |||
| 1016 | if finger { | 1276 | if finger { | ||
| 1017 | m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true | 1277 | m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true | ||
| 1018 | return true | 1278 | return true | ||
| 1019 | } | 1279 | } | ||
| 1020 | case math.Abs(m.ov.scrollX) >= stepX: | 1280 | case math.Abs(m.ov.scrollX) >= stepX: | ||
| 1021 | - | m.OverviewMove(sign(m.ov.scrollX), 0) | 1281 | + | d := sign(m.ov.scrollX) |
| 1022 | - | m.ov.scrollX -= float64(sign(m.ov.scrollX)) * stepX | 1282 | + | if cascade && finger { |
| 1283 | + | // One workspace per gesture, even over a stash card. | |||
| 1284 | + | m.overviewStep(d) | |||
| 1285 | + | m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true | |||
| 1286 | + | return true | |||
| 1287 | + | } | |||
| 1288 | + | m.ov.scrollX -= float64(d) * stepX | |||
| 1023 | m.ov.scrollY = 0 | 1289 | m.ov.scrollY = 0 | ||
| 1290 | + | m.OverviewMove(d, 0) | |||
| 1291 | + | if m.overviewTurned(axis, finger) { | |||
| 1292 | + | return true | |||
| 1293 | + | } | |||
| 1024 | if finger { | 1294 | if finger { | ||
| 1025 | m.ov.sideways, stepX = true, overviewScrollRepeat | 1295 | m.ov.sideways, stepX = true, overviewScrollRepeat | ||
| 1026 | } | 1296 | } | ||
| @@ -1031,6 +1301,17 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |||||
| 1031 | } | 1301 | } | ||
| 1032 | } | 1302 | } | ||
| 1033 | 1303 | ||||
| 1304 | + | // overviewTurned reports whether a scroll step turned the overview from the | |||
| 1305 | + | // axis before. A finger scroll then waits for the fingers to lift, so it | |||
| 1306 | + | // cannot bounce back. | |||
| 1307 | + | func (m *Monitor) overviewTurned(before layoutAxis, finger bool) bool { | |||
| 1308 | + | if m.overviewAxis() == before { | |||
| 1309 | + | return false | |||
| 1310 | + | } | |||
| 1311 | + | m.ov.scrolled = m.ov.scrolled || finger | |||
| 1312 | + | return true | |||
| 1313 | + | } | |||
| 1314 | + | ||||
| 1034 | // scrollStop ends a scroll on every monitor: the fingers lifted, maybe | 1315 | // scrollStop ends a scroll on every monitor: the fingers lifted, maybe | ||
| 1035 | // after the pointer took the focus to another output, so the next scroll | 1316 | // after the pointer took the focus to another output, so the next scroll | ||
| 1036 | // starts from zero wherever it goes. | 1317 | // starts from zero wherever it goes. | ||
A internal/core/overview_cascade_test.go +858 -0
| @@ -0,0 +1,858 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "slices" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // cascadeOverview is overviewMonitor with workspace 2 (current) a cascade of | |
| 12 | + | // seven columns, 4..10, in three bands of three (the last holds 10 alone), | |
| 13 | + | // the focus on column 8 (band 2, middle). Workspace 1 (columns 1..3) is a | |
| 14 | + | // scroll workspace on its left, workspace 3 (column 30) on its right. | |
| 15 | + | func cascadeOverview() *Monitor { | |
| 16 | + | m := overviewMonitor() | |
| 17 | + | m.SetBorder(2) | |
| 18 | + | m.Focus(1) | |
| 19 | + | w := m.Current() | |
| 20 | + | w.Overflow = OverflowCascade | |
| 21 | + | for id := WindowID(5); id <= 10; id++ { | |
| 22 | + | w.AddWindow(id) | |
| 23 | + | } | |
| 24 | + | m.Focus(2) | |
| 25 | + | m.AddWindow(30) | |
| 26 | + | m.Focus(1) | |
| 27 | + | w.FocusID(8) | |
| 28 | + | return m | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // withStash stashes windows 40..40+n-1 of the current workspace, with the | |
| 32 | + | // focus back on column 8. | |
| 33 | + | func withStash(m *Monitor, n int) { | |
| 34 | + | w := m.Current() | |
| 35 | + | for id := WindowID(40); id < WindowID(40+n); id++ { | |
| 36 | + | w.AddWindow(id) | |
| 37 | + | w.FocusID(id) | |
| 38 | + | w.ToggleWindowStash() | |
| 39 | + | } | |
| 40 | + | w.FocusID(8) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // cascadeCore is a Core on one output with workspace 0 holding window 1, | |
| 44 | + | // workspace 1 (current) a cascade of windows 2..6 in bands of two ({2,3} | |
| 45 | + | // {4,5} {6}), workspace 2 window 30, and window 2 focused. | |
| 46 | + | func cascadeCore(t *testing.T) (*Core, *Monitor) { | |
| 47 | + | t.Helper() | |
| 48 | + | c, _ := indicatorCore(t) | |
| 49 | + | m := c.cur().mon | |
| 50 | + | m.AddWindow(1) | |
| 51 | + | m.Focus(1) | |
| 52 | + | w := m.Current() | |
| 53 | + | w.Overflow = OverflowCascade | |
| 54 | + | for id := WindowID(2); id <= 6; id++ { | |
| 55 | + | w.AddWindow(id) | |
| 56 | + | } | |
| 57 | + | m.normalize() | |
| 58 | + | m.Focus(2) | |
| 59 | + | m.AddWindow(30) | |
| 60 | + | m.Focus(1) | |
| 61 | + | w.FocusID(2) | |
| 62 | + | return c, m | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func centreY(r Rect) int { return r.Y + r.H/2 } | |
| 66 | + | ||
| 67 | + | func within1(a, b int) bool { return a-b <= 1 && b-a <= 1 } | |
| 68 | + | ||
| 69 | + | func hiddenOff(p Placement) bool { return p.Hidden && p.Preview == 0 && !p.Focused } | |
| 70 | + | ||
| 71 | + | // T1: the zoom does not depend on the number of bands. | |
| 72 | + | func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) { | |
| 73 | + | if one, three := cascadeWorkspace(3).overviewZoom(), cascadeWorkspace(7).overviewZoom(); one != three || one != overviewMaxZoom { | |
| 74 | + | t.Fatalf("zoom with one band %v, with three %v", one, three) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // T2: the selected band is the centre lane: undimmed, centred, selected tile | |
| 79 | + | // focused. | |
| 80 | + | func TestOverviewCascadeCentreLane(t *testing.T) { | |
| 81 | + | m := cascadeOverview() | |
| 82 | + | m.ToggleOverview() | |
| 83 | + | ps := m.Layout() | |
| 84 | + | u := m.Current().overviewArea() | |
| 85 | + | for _, id := range []WindowID{7, 8, 9} { | |
| 86 | + | p := previewOf(t, ps, id) | |
| 87 | + | if p.Hidden || p.Peek || p.Preview != overviewMaxZoom || p.Focused != (id == 8) { | |
| 88 | + | t.Fatalf("band tile %d: %+v", id, p) | |
| 89 | + | } | |
| 90 | + | if !within1(centreY(p.Rect), u.Y+u.H/2) { | |
| 91 | + | t.Fatalf("band tile %d centre %d, area centre %d", id, centreY(p.Rect), u.Y+u.H/2) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // T3: the other bands are dimmed lanes a screen (and a gap) away; those | |
| 97 | + | // outside the area are hidden. | |
| 98 | + | func TestOverviewCascadeOtherLanes(t *testing.T) { | |
| 99 | + | m := cascadeOverview() | |
| 100 | + | m.ToggleOverview() | |
| 101 | + | ps := m.Layout() | |
| 102 | + | sel := previewOf(t, ps, 8).Rect | |
| 103 | + | above, below := previewOf(t, ps, 5), previewOf(t, ps, 10) | |
| 104 | + | if above.Hidden || !above.Peek || above.Focused || below.Hidden || !below.Peek || below.Focused { | |
| 105 | + | t.Fatalf("adjacent bands: above %+v, below %+v", above, below) | |
| 106 | + | } | |
| 107 | + | // The lanes sit evenly around the selected one, apart from it (a gap), | |
| 108 | + | // and the area still shows part of each. | |
| 109 | + | up, down := sel.Y-above.Rect.Y, below.Rect.Y-sel.Y | |
| 110 | + | if !within1(up, down) { | |
| 111 | + | t.Fatalf("lanes not evenly spaced: %d above, %d below", up, down) | |
| 112 | + | } | |
| 113 | + | if gap := up - sel.H; gap <= 0 { | |
| 114 | + | t.Fatalf("lane above overlaps the selected one: %v over %v", above.Rect, sel) | |
| 115 | + | } | |
| 116 | + | u := m.Current().overviewArea() | |
| 117 | + | if above.Rect.Y+above.Rect.H <= u.Y || below.Rect.Y >= u.Y+u.H { | |
| 118 | + | t.Fatalf("adjacent lanes not visible in %v: %v, %v", u, above.Rect, below.Rect) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // Five bands (13 columns): the ones two lanes away are off the area. | |
| 122 | + | m = cascadeOverview() | |
| 123 | + | w := m.Current() | |
| 124 | + | for id := WindowID(11); id <= 16; id++ { | |
| 125 | + | w.AddWindow(id) | |
| 126 | + | } | |
| 127 | + | w.FocusID(8) | |
| 128 | + | m.ToggleOverview() | |
| 129 | + | // Columns 4..16: bands {4,5,6} {7,8,9} {10,11,12} {13,14,15} {16}. | |
| 130 | + | w.FocusID(11) | |
| 131 | + | ps = m.Layout() | |
| 132 | + | for _, id := range []WindowID{4, 5, 6, 16} { | |
| 133 | + | if p := previewOf(t, ps, id); !hiddenOff(p) { | |
| 134 | + | t.Fatalf("band two lanes away %d not hidden: %+v", id, p) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | for _, id := range []WindowID{7, 13} { | |
| 138 | + | if p := previewOf(t, ps, id); p.Hidden || !p.Peek { | |
| 139 | + | t.Fatalf("adjacent band %d: %+v", id, p) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // T4: neighbor workspaces sit left and right of the current slot, dimmed, | |
| 145 | + | // their selected lane level with the centre lane. | |
| 146 | + | func TestOverviewCascadeNeighbors(t *testing.T) { | |
| 147 | + | m := cascadeOverview() | |
| 148 | + | m.ToggleOverview() | |
| 149 | + | rows, _ := m.overviewRows() | |
| 150 | + | cur, left, right := rows[m.Workspaces[1]], rows[m.Workspaces[0]], rows[m.Workspaces[2]] | |
| 151 | + | if left.X+left.W > cur.X || right.X < cur.X+cur.W { | |
| 152 | + | t.Fatalf("slots overlap: %v %v %v", left, cur, right) | |
| 153 | + | } | |
| 154 | + | ps := m.Layout() | |
| 155 | + | centre := centreY(previewOf(t, ps, 8).Rect) | |
| 156 | + | shown := 0 | |
| 157 | + | for _, n := range []struct { | |
| 158 | + | id WindowID | |
| 159 | + | side int | |
| 160 | + | }{{1, -1}, {2, -1}, {3, -1}, {30, 1}} { | |
| 161 | + | id, side := n.id, n.side | |
| 162 | + | p := previewOf(t, ps, id) | |
| 163 | + | if p.Hidden { | |
| 164 | + | continue | |
| 165 | + | } | |
| 166 | + | shown++ | |
| 167 | + | if !p.Peek || p.Focused || p.Preview == 0 { | |
| 168 | + | t.Fatalf("neighbor %d not dimmed: %+v", id, p) | |
| 169 | + | } | |
| 170 | + | if side < 0 && p.Rect.X+p.Rect.W > cur.X || side > 0 && p.Rect.X < cur.X+cur.W { | |
| 171 | + | t.Fatalf("neighbor %d overlaps the current slot %v: %+v", id, cur, p.Rect) | |
| 172 | + | } | |
| 173 | + | if !within1(centreY(p.Rect), centre) { | |
| 174 | + | t.Fatalf("neighbor %d centre %d, centre lane %d", id, centreY(p.Rect), centre) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | if shown < 2 { | |
| 178 | + | t.Fatalf("only %d neighbor tiles drawn", shown) | |
| 179 | + | } | |
| 180 | + | for _, p := range ps { | |
| 181 | + | if p.Hidden || p.Peek { | |
| 182 | + | continue | |
| 183 | + | } | |
| 184 | + | if p.Rect.X < cur.X || p.Rect.X+p.Rect.W > cur.X+cur.W { | |
| 185 | + | t.Fatalf("current tile %d outside its slot %v: %+v", p.ID, cur, p.Rect) | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // T5: lanes along the overview axis would overlap the other workspaces: a | |
| 191 | + | // cascade neighbor of a scroll workspace shows its selected band only. | |
| 192 | + | func TestOverviewCascadeNeighborBelowScrollShowsOneBand(t *testing.T) { | |
| 193 | + | m := cascadeOverview() | |
| 194 | + | m.Focus(0) | |
| 195 | + | m.ToggleOverview() | |
| 196 | + | if m.overviewAxis() != verticalAxis { | |
| 197 | + | t.Fatal("setup: scroll overview is not vertical") | |
| 198 | + | } | |
| 199 | + | ps := m.Layout() | |
| 200 | + | for _, id := range []WindowID{4, 5, 6} { | |
| 201 | + | if p := previewOf(t, ps, id); !hiddenOff(p) { | |
| 202 | + | t.Fatalf("band %d of the neighbor drawn: %+v", id, p) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | if p := previewOf(t, ps, 8); p.Hidden { | |
| 206 | + | // The neighbor's selected column is 8. | |
| 207 | + | t.Fatalf("selected band hidden: %+v", p) | |
| 208 | + | } | |
| 209 | + | for _, id := range []WindowID{7, 9} { | |
| 210 | + | if p := previewOf(t, ps, id); p.Hidden || !p.Peek { | |
| 211 | + | t.Fatalf("selected band tile %d: %+v", id, p) | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | if p := previewOf(t, ps, 10); !hiddenOff(p) { | |
| 215 | + | t.Fatalf("band 3 drawn: %+v", p) | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // T6: the stash pile is inside the current slot, left of the band; the | |
| 220 | + | // previous workspace stays clear of it. | |
| 221 | + | func TestOverviewCascadeStashInSlot(t *testing.T) { | |
| 222 | + | m := cascadeOverview() | |
| 223 | + | withStash(m, 2) | |
| 224 | + | m.ToggleOverview() | |
| 225 | + | rows, _ := m.overviewRows() | |
| 226 | + | cur, prev := rows[m.Current()], rows[m.Workspaces[0]] | |
| 227 | + | ps := m.Layout() | |
| 228 | + | left := cur.X + cur.W | |
| 229 | + | for _, id := range []WindowID{7, 8, 9} { | |
| 230 | + | left = min(left, previewOf(t, ps, id).Rect.X) | |
| 231 | + | } | |
| 232 | + | for _, id := range []WindowID{40, 41} { | |
| 233 | + | p := previewOf(t, ps, id) | |
| 234 | + | if p.Hidden || p.Preview != overviewCardZoom { | |
| 235 | + | t.Fatalf("pile card %d: %+v", id, p) | |
| 236 | + | } | |
| 237 | + | if p.Rect.X < cur.X || p.Rect.X+p.Rect.W > left { | |
| 238 | + | t.Fatalf("pile card %d not inside the slot, left of the band (%d..%d): %+v", id, cur.X, left, p.Rect) | |
| 239 | + | } | |
| 240 | + | // 41 is the front card of the pile. | |
| 241 | + | if id == 41 && !within1(centreY(p.Rect), centreY(previewOf(t, ps, 8).Rect)) { | |
| 242 | + | t.Fatalf("pile not level with the selected lane: %+v", p.Rect) | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | // The pile does not shift the band: its row stays centred on the area. | |
| 246 | + | u := m.Current().overviewArea() | |
| 247 | + | lo, hi := previewOf(t, ps, 7).Rect, previewOf(t, ps, 9).Rect | |
| 248 | + | if mid := (lo.X + hi.X + hi.W) / 2; !within1(mid, u.X+u.W/2) { | |
| 249 | + | t.Fatalf("band centre %d with a stash, area centre %d", mid, u.X+u.W/2) | |
| 250 | + | } | |
| 251 | + | for _, id := range []WindowID{1, 2, 3} { | |
| 252 | + | if p := previewOf(t, ps, id); !p.Hidden && p.Rect.X+p.Rect.W > cur.X { | |
| 253 | + | t.Fatalf("previous workspace overlaps the slot with the pile: %+v", p.Rect) | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | if prev.X+prev.W > cur.X { | |
| 257 | + | t.Fatalf("slots overlap: %v %v", prev, cur) | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | type cascadeDriver struct { | |
| 262 | + | name string | |
| 263 | + | move func(m *Monitor, dx, dy int) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func keyDriver(left, right, up, down string) func(m *Monitor, dx, dy int) { | |
| 267 | + | return func(m *Monitor, dx, dy int) { | |
| 268 | + | name := map[[2]int]string{{-1, 0}: left, {1, 0}: right, {0, -1}: up, {0, 1}: down}[[2]int{dx, dy}] | |
| 269 | + | m.overviewKey(ports.KeyEvent{Keysym: name, Pressed: true}) | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | var cascadeDrivers = []cascadeDriver{ | |
| 274 | + | {"move", func(m *Monitor, dx, dy int) { m.OverviewMove(dx, dy) }}, | |
| 275 | + | {"hjkl", keyDriver("h", "l", "k", "j")}, | |
| 276 | + | {"arrows", keyDriver("Left", "Right", "Up", "Down")}, | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | func focusedID(m *Monitor) WindowID { | |
| 280 | + | id, _ := m.Focused() | |
| 281 | + | return id | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // T7: ↑/↓ change band and stop at the ends; ←/→ walk the band, then leave it | |
| 285 | + | // for the stash or the neighbor workspaces; Escape restores everything. | |
| 286 | + | func TestOverviewCascadeKeys(t *testing.T) { | |
| 287 | + | for _, d := range cascadeDrivers { | |
| 288 | + | t.Run(d.name, func(t *testing.T) { | |
| 289 | + | m := cascadeOverview() | |
| 290 | + | w, prev, next := m.Current(), m.Workspaces[0], m.Workspaces[2] | |
| 291 | + | w.FocusID(5) | |
| 292 | + | m.ToggleOverview() | |
| 293 | + | sel := func() WindowID { return w.Columns[w.Focus].Windows[0] } | |
| 294 | + | d.move(m, 0, 1) | |
| 295 | + | if sel() != 8 { | |
| 296 | + | t.Fatalf("down: %d, want 8", sel()) | |
| 297 | + | } | |
| 298 | + | d.move(m, 0, 1) | |
| 299 | + | d.move(m, 0, 1) | |
| 300 | + | if m.Current() != w || sel() != 10 { | |
| 301 | + | t.Fatalf("down at the last band: %d on the current workspace %v", sel(), m.Current() == w) | |
| 302 | + | } | |
| 303 | + | d.move(m, 0, -1) | |
| 304 | + | if sel() != 7 { | |
| 305 | + | t.Fatalf("up: %d, want 7", sel()) | |
| 306 | + | } | |
| 307 | + | d.move(m, 0, -1) | |
| 308 | + | d.move(m, 0, -1) | |
| 309 | + | if m.Current() != w || sel() != 4 { | |
| 310 | + | t.Fatalf("up at the first band: %d", sel()) | |
| 311 | + | } | |
| 312 | + | d.move(m, 1, 0) | |
| 313 | + | d.move(m, 1, 0) | |
| 314 | + | if sel() != 6 || m.Current() != w { | |
| 315 | + | t.Fatalf("right in the band: %d", sel()) | |
| 316 | + | } | |
| 317 | + | d.move(m, 1, 0) | |
| 318 | + | if m.Current() != next { | |
| 319 | + | t.Fatal("right past the last column did not reach the next workspace") | |
| 320 | + | } | |
| 321 | + | m.CancelOverview() | |
| 322 | + | if m.Current() != w || focusedID(m) != 5 { | |
| 323 | + | t.Fatalf("cancel left %d", focusedID(m)) | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | m.ToggleOverview() | |
| 327 | + | d.move(m, -1, 0) | |
| 328 | + | if sel() != 4 || m.Current() != w { | |
| 329 | + | t.Fatal("left within the band") | |
| 330 | + | } | |
| 331 | + | d.move(m, -1, 0) | |
| 332 | + | if m.Current() != prev { | |
| 333 | + | t.Fatal("left from the first column did not reach the previous workspace") | |
| 334 | + | } | |
| 335 | + | m.CancelOverview() | |
| 336 | + | ||
| 337 | + | // With a stash: left from the first column enters it, right | |
| 338 | + | // comes back to the band's first column, left twice goes to | |
| 339 | + | // the previous workspace. | |
| 340 | + | withStash(m, 2) | |
| 341 | + | w.FocusID(7) | |
| 342 | + | m.ToggleOverview() | |
| 343 | + | d.move(m, -1, 0) | |
| 344 | + | if m.cardAt(w) < 0 { | |
| 345 | + | t.Fatal("left from the first column did not select the stash") | |
| 346 | + | } | |
| 347 | + | d.move(m, 1, 0) | |
| 348 | + | if m.cardAt(w) >= 0 || sel() != 7 { | |
| 349 | + | t.Fatalf("right from the stash: card %d, column %d", m.cardAt(w), sel()) | |
| 350 | + | } | |
| 351 | + | d.move(m, -1, 0) | |
| 352 | + | d.move(m, -1, 0) | |
| 353 | + | if m.Current() != prev { | |
| 354 | + | t.Fatal("left from the stash did not reach the previous workspace") | |
| 355 | + | } | |
| 356 | + | m.CancelOverview() | |
| 357 | + | if m.Current() != w || focusedID(m) != 7 { | |
| 358 | + | t.Fatalf("cancel left %d", focusedID(m)) | |
| 359 | + | } | |
| 360 | + | }) | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | // T7b: with a stash card selected, up/down and the three-finger vertical | |
| 365 | + | // swipe cycle the pile as on main; confirming shows the picked window. | |
| 366 | + | func TestOverviewCascadeStashPile(t *testing.T) { | |
| 367 | + | for _, d := range cascadeDrivers { | |
| 368 | + | t.Run(d.name, func(t *testing.T) { | |
| 369 | + | m := cascadeOverview() | |
| 370 | + | withStash(m, 3) | |
| 371 | + | w := m.Current() | |
| 372 | + | w.FocusID(7) | |
| 373 | + | m.ToggleOverview() | |
| 374 | + | d.move(m, -1, 0) | |
| 375 | + | if m.cardAt(w) != w.stashAt { | |
| 376 | + | t.Fatalf("entered at %d", m.cardAt(w)) | |
| 377 | + | } | |
| 378 | + | at := m.cardAt(w) | |
| 379 | + | d.move(m, 0, -1) | |
| 380 | + | if m.Current() != w || m.cardAt(w) != at-1 { | |
| 381 | + | t.Fatalf("up: card %d, was %d", m.cardAt(w), at) | |
| 382 | + | } | |
| 383 | + | d.move(m, 0, 1) | |
| 384 | + | d.move(m, 0, 1) | |
| 385 | + | d.move(m, 0, 1) | |
| 386 | + | if m.Current() != w || m.cardAt(w) != len(w.Stash)-1 { | |
| 387 | + | t.Fatalf("down: card %d", m.cardAt(w)) | |
| 388 | + | } | |
| 389 | + | d.move(m, 0, -1) | |
| 390 | + | picked := w.Stash[m.cardAt(w)].ID | |
| 391 | + | m.ToggleOverview() | |
| 392 | + | if focusedID(m) != picked || w.stashHidden { | |
| 393 | + | t.Fatalf("confirm on %d, want %d (hidden %v)", focusedID(m), picked, w.stashHidden) | |
| 394 | + | } | |
| 395 | + | }) | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | c, _, sc, _ := stashRig(t, true) | |
| 399 | + | m := sc.mon | |
| 400 | + | w := m.Current() | |
| 401 | + | w.Overflow = OverflowCascade | |
| 402 | + | m.Focus(1) | |
| 403 | + | m.AddWindow(50) | |
| 404 | + | m.Focus(0) | |
| 405 | + | m.ToggleOverview() | |
| 406 | + | if m.cardAt(w) < 0 { | |
| 407 | + | t.Fatal("setup: no card selected") | |
| 408 | + | } | |
| 409 | + | at, before := m.cardAt(w), stashIDs(w) | |
| 410 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |
| 411 | + | for range 3 { | |
| 412 | + | c.swipeUpdate(ports.SwipeUpdate{DY: -40}) | |
| 413 | + | } | |
| 414 | + | c.swipeEnd(ports.SwipeEnd{}) | |
| 415 | + | if m.Current() != w || m.cardAt(w) != at-1 { | |
| 416 | + | t.Fatalf("swipe up: card %d, was %d", m.cardAt(w), at) | |
| 417 | + | } | |
| 418 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |
| 419 | + | for range 3 { | |
| 420 | + | c.swipeUpdate(ports.SwipeUpdate{DX: 40}) | |
| 421 | + | } | |
| 422 | + | c.swipeEnd(ports.SwipeEnd{}) | |
| 423 | + | // A horizontal three-finger swipe is a workspace step, even over a | |
| 424 | + | // stash card. | |
| 425 | + | if m.Current() != m.Workspaces[1] { | |
| 426 | + | t.Fatalf("horizontal swipe over a stash card: workspace %d", indexOf(m.Workspaces, m.Current())) | |
| 427 | + | } | |
| 428 | + | if got := stashIDs(w); !slices.Equal(got, before) { | |
| 429 | + | t.Fatal("stash reordered") | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | // T7c: three fingers follow the physical axes, once per gesture. | |
| 434 | + | func TestOverviewCascadeThreeFinger(t *testing.T) { | |
| 435 | + | c, m := cascadeCore(t) | |
| 436 | + | w := m.Current() | |
| 437 | + | m.ToggleOverview() | |
| 438 | + | swipe := func(dx, dy float64) { | |
| 439 | + | t.Helper() | |
| 440 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |
| 441 | + | for range 4 { | |
| 442 | + | c.swipeUpdate(ports.SwipeUpdate{DX: dx, DY: dy}) | |
| 443 | + | } | |
| 444 | + | c.swipeEnd(ports.SwipeEnd{}) | |
| 445 | + | } | |
| 446 | + | swipe(0, 40) | |
| 447 | + | if m.Current() != w || w.band(w.Focus) != 1 { | |
| 448 | + | t.Fatalf("one swipe down: band %d", w.band(w.Focus)) | |
| 449 | + | } | |
| 450 | + | swipe(0, -40) | |
| 451 | + | if w.band(w.Focus) != 0 { | |
| 452 | + | t.Fatalf("one swipe up: band %d", w.band(w.Focus)) | |
| 453 | + | } | |
| 454 | + | swipe(40, 0) | |
| 455 | + | if m.Current() != m.Workspaces[2] { | |
| 456 | + | t.Fatalf("one swipe right: workspace %d", indexOf(m.Workspaces, m.Current())) | |
| 457 | + | } | |
| 458 | + | // Onto a scroll workspace and back: the vertical swipe is the scroll | |
| 459 | + | // overview's workspace step; neither bounces during the gesture. | |
| 460 | + | swipe(0, -40) | |
| 461 | + | if m.Current() != w { | |
| 462 | + | t.Fatalf("swipe up from the scroll workspace: workspace %d", indexOf(m.Workspaces, m.Current())) | |
| 463 | + | } | |
| 464 | + | swipe(-40, 0) | |
| 465 | + | if m.Current() != m.Workspaces[0] { | |
| 466 | + | t.Fatalf("one swipe left: workspace %d", indexOf(m.Workspaces, m.Current())) | |
| 467 | + | } | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | // T8: a named boundary of a cascade overview is a vertical rule strictly | |
| 471 | + | // between the two slots. | |
| 472 | + | func TestOverviewCascadeNamedDivider(t *testing.T) { | |
| 473 | + | m := namedOverviewMonitor() | |
| 474 | + | m.Workspaces[1].Overflow = OverflowCascade | |
| 475 | + | m.ToggleOverview() | |
| 476 | + | rows, dividers := m.overviewRows() | |
| 477 | + | if len(dividers) != 1 { | |
| 478 | + | t.Fatalf("dividers: %+v", dividers) | |
| 479 | + | } | |
| 480 | + | d, cur, dev := dividers[0].Rect, rows[m.Workspaces[1]], rows[m.byName("dev")] | |
| 481 | + | if d.W != 1 || d.H <= 1 || dividers[0].Active { | |
| 482 | + | t.Fatalf("divider is not an inactive vertical rule: %+v", dividers[0]) | |
| 483 | + | } | |
| 484 | + | if d.X < cur.X+cur.W || d.X+d.W > dev.X { | |
| 485 | + | t.Fatalf("divider %v not between %v and %v", d, cur, dev) | |
| 486 | + | } | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | // T10: covering floats fan along the horizontal axis: left goes deeper in the | |
| 490 | + | // stack (the cards behind show on the left), right comes back to the front. | |
| 491 | + | func TestOverviewCascadeCoveringFloat(t *testing.T) { | |
| 492 | + | for _, d := range cascadeDrivers { | |
| 493 | + | t.Run(d.name, func(t *testing.T) { | |
| 494 | + | m := cascadeOverview() | |
| 495 | + | m.Current().AddFloating(90, 300, 200) | |
| 496 | + | w := m.Current() | |
| 497 | + | w.FocusID(10) | |
| 498 | + | m.ToggleOverview() | |
| 499 | + | if m.stackFront(w).kind != stackColumns { | |
| 500 | + | t.Fatal("setup: columns not in front") | |
| 501 | + | } | |
| 502 | + | d.move(m, 0, -1) // columns: up is a band | |
| 503 | + | d.move(m, -1, 0) | |
| 504 | + | if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 { | |
| 505 | + | t.Fatalf("left from the first column: front %+v", f) | |
| 506 | + | } | |
| 507 | + | if p := previewOf(t, m.Layout(), 90); p.Peek || !p.Focused { | |
| 508 | + | t.Fatalf("float not in front: %+v", p) | |
| 509 | + | } | |
| 510 | + | d.move(m, 0, 1) // a float front takes no band move | |
| 511 | + | if f := m.stackFront(w); f.kind != stackFloat { | |
| 512 | + | t.Fatalf("down left the float: %+v", f) | |
| 513 | + | } | |
| 514 | + | d.move(m, 1, 0) | |
| 515 | + | if m.stackFront(w).kind != stackColumns || m.Current() != w { | |
| 516 | + | t.Fatalf("right from the float: front %+v", m.stackFront(w)) | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | // The float in front of the columns: they fan to its left. | |
| 520 | + | m.CancelOverview() | |
| 521 | + | w.FocusID(90) | |
| 522 | + | m.ToggleOverview() | |
| 523 | + | if f := m.stackFront(w); f.kind != stackFloat { | |
| 524 | + | t.Fatalf("setup: front %+v", f) | |
| 525 | + | } | |
| 526 | + | behind := previewOf(t, m.Layout(), 10) | |
| 527 | + | if !behind.Peek { | |
| 528 | + | t.Fatalf("columns not behind the float: %+v", behind) | |
| 529 | + | } | |
| 530 | + | d.move(m, -1, 0) | |
| 531 | + | if m.stackFront(w).kind != stackColumns || m.Current() != w { | |
| 532 | + | t.Fatalf("left from the front float: front %+v", m.stackFront(w)) | |
| 533 | + | } | |
| 534 | + | // The cards behind fan to the left: coming to the front, the | |
| 535 | + | // columns move right by one peek step. | |
| 536 | + | front := previewOf(t, m.Layout(), 10) | |
| 537 | + | if front.Peek || front.Rect.X-behind.Rect.X != w.peekStep() { | |
| 538 | + | t.Fatalf("columns behind %v, in front %v, peek step %d", behind.Rect, front.Rect, w.peekStep()) | |
| 539 | + | } | |
| 540 | + | d.move(m, 1, 0) | |
| 541 | + | if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 { | |
| 542 | + | t.Fatalf("right from the columns behind the float: front %+v", f) | |
| 543 | + | } | |
| 544 | + | }) | |
| 545 | + | } | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | // T10b: a pinned fullscreen column keeps its Fullscreen tile in the band. | |
| 549 | + | func TestOverviewCascadePinnedFullscreenTile(t *testing.T) { | |
| 550 | + | m := cascadeOverview() | |
| 551 | + | w := m.Current() | |
| 552 | + | w.FocusID(8) | |
| 553 | + | m.ToggleFullscreen() | |
| 554 | + | if !w.pinned() { | |
| 555 | + | t.Fatal("setup: not pinned") | |
| 556 | + | } | |
| 557 | + | m.ToggleOverview() | |
| 558 | + | ps := m.Layout() | |
| 559 | + | fs := previewOf(t, ps, 8) | |
| 560 | + | if !fs.Fullscreen || fs.Hidden { | |
| 561 | + | t.Fatalf("fullscreen tile: %+v", fs) | |
| 562 | + | } | |
| 563 | + | for _, id := range []WindowID{7, 9} { | |
| 564 | + | if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen || p.Peek != (fs.Peek) { | |
| 565 | + | t.Fatalf("band tile %d: %+v", id, p) | |
| 566 | + | } | |
| 567 | + | } | |
| 568 | + | u := w.overviewArea() | |
| 569 | + | if !within1(centreY(fs.Rect), u.Y+u.H/2) { | |
| 570 | + | t.Fatalf("fullscreen tile not on the centre lane: %+v", fs.Rect) | |
| 571 | + | } | |
| 572 | + | } | |
| 573 | + | ||
| 574 | + | // T11: the focus binds keep their meaning in a cascade overview. | |
| 575 | + | func TestOverviewCascadeFocusBinds(t *testing.T) { | |
| 576 | + | m := cascadeOverview() | |
| 577 | + | w := m.Current() | |
| 578 | + | m.ToggleOverview() | |
| 579 | + | if !m.overviewFocus(ActionFocusWindowDown) || w.band(w.Focus) != 2 || m.Current() != w { | |
| 580 | + | t.Fatalf("focus-window-down: band %d", w.band(w.Focus)) | |
| 581 | + | } | |
| 582 | + | if !m.overviewFocus(ActionFocusWindowUp) || w.band(w.Focus) != 1 { | |
| 583 | + | t.Fatalf("focus-window-up: band %d", w.band(w.Focus)) | |
| 584 | + | } | |
| 585 | + | m.overviewFocus(ActionFocusWorkspaceNext) | |
| 586 | + | if m.Current() != m.Workspaces[2] { | |
| 587 | + | t.Fatal("focus-workspace-next did not change workspace") | |
| 588 | + | } | |
| 589 | + | m.overviewFocus(ActionFocusWorkspacePrev) | |
| 590 | + | if m.Current() != w { | |
| 591 | + | t.Fatal("focus-workspace-prev did not come back") | |
| 592 | + | } | |
| 593 | + | m.overviewFocus(ActionFocusWorkspacePrev) | |
| 594 | + | if m.Current() != m.Workspaces[0] { | |
| 595 | + | t.Fatal("focus-workspace-prev did not change workspace") | |
| 596 | + | } | |
| 597 | + | m.CancelOverview() | |
| 598 | + | ||
| 599 | + | // A selected stash card keeps the pile cycle. | |
| 600 | + | withStash(m, 3) | |
| 601 | + | w.FocusID(7) | |
| 602 | + | m.ToggleOverview() | |
| 603 | + | m.OverviewMove(-1, 0) | |
| 604 | + | at := m.cardAt(w) | |
| 605 | + | m.overviewFocus(ActionFocusWorkspacePrev) | |
| 606 | + | if m.Current() != w || m.cardAt(w) != at-1 { | |
| 607 | + | t.Fatalf("focus-workspace-prev over a card: card %d, was %d", m.cardAt(w), at) | |
| 608 | + | } | |
| 609 | + | m.overviewFocus(ActionFocusWorkspaceNext) | |
| 610 | + | if m.Current() != w || m.cardAt(w) != at { | |
| 611 | + | t.Fatalf("focus-workspace-next over a card: card %d", m.cardAt(w)) | |
| 612 | + | } | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | func TestOverviewMixedLayoutGestureAndCancel(t *testing.T) { | |
| 616 | + | m := overviewMonitor() | |
| 617 | + | m.SetOverflow(OverflowFixed) | |
| 618 | + | m.Current().Overflow = OverflowCascade | |
| 619 | + | from := m.Current() | |
| 620 | + | m.ToggleOverview() | |
| 621 | + | rows, _ := m.overviewRows() | |
| 622 | + | if rows[m.Workspaces[1]].X <= rows[from].X { | |
| 623 | + | t.Fatal("cascade neighbor not horizontal") | |
| 624 | + | } | |
| 625 | + | m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: overviewScrollStep}}) | |
| 626 | + | if m.Current() == from { | |
| 627 | + | t.Fatal("horizontal gesture did not change workspace") | |
| 628 | + | } | |
| 629 | + | if m.Current().Overflow != OverflowFixed { | |
| 630 | + | t.Fatal("destination lost fixed policy") | |
| 631 | + | } | |
| 632 | + | if m.ov.scrollX != 0 || m.ov.scrollY != 0 || !m.ov.scrolled { | |
| 633 | + | t.Fatalf("scroll state after the axis change: %+v", m.ov) | |
| 634 | + | } | |
| 635 | + | m.CancelOverview() | |
| 636 | + | if m.Current() != from { | |
| 637 | + | t.Fatal("cancel lost source workspace") | |
| 638 | + | } | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | // Showing a workspace with another overview axis drops the scroll leftovers | |
| 642 | + | // of the old one. | |
| 643 | + | func TestOverviewAxisChangeResetsScroll(t *testing.T) { | |
| 644 | + | m := cascadeOverview() | |
| 645 | + | m.ToggleOverview() | |
| 646 | + | m.ov.scrollX, m.ov.scrollY, m.ov.sideways, m.ov.scrolled = 20, 20, true, true | |
| 647 | + | m.showOverview(m.Workspaces[2]) | |
| 648 | + | if m.ov.scrollX != 0 || m.ov.scrollY != 0 || m.ov.sideways || !m.ov.scrolled { | |
| 649 | + | t.Fatalf("scroll kept or latch dropped: %+v", m.ov) | |
| 650 | + | } | |
| 651 | + | m.ov.scrollX = 20 | |
| 652 | + | m.showOverview(m.Workspaces[0]) | |
| 653 | + | if m.ov.scrollX != 20 { | |
| 654 | + | t.Fatal("scroll reset between workspaces of the same axis") | |
| 655 | + | } | |
| 656 | + | } | |
| 657 | + | ||
| 658 | + | // A reload that changes the current workspace's axes drops the scroll state; | |
| 659 | + | // one that does not, keeps it. | |
| 660 | + | func TestOverviewReloadResetsScrollOnAxisChange(t *testing.T) { | |
| 661 | + | c, _ := indicatorCore(t) | |
| 662 | + | m := c.cur().mon | |
| 663 | + | m.AddWindow(1) | |
| 664 | + | m.ToggleOverview() | |
| 665 | + | w := m.Current() | |
| 666 | + | set := func() { m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 20, 10, true, true } | |
| 667 | + | set() | |
| 668 | + | cfg := c.cfg | |
| 669 | + | cfg.Layout.Gaps++ | |
| 670 | + | if err := c.apply(cfg); err != nil { | |
| 671 | + | t.Fatal(err) | |
| 672 | + | } | |
| 673 | + | if m.ov.scrollX != 20 || !m.ov.scrolled { | |
| 674 | + | t.Fatalf("unrelated reload reset the scroll: %+v", m.ov) | |
| 675 | + | } | |
| 676 | + | cfg.Layout.Overflow = string(OverflowCascade) | |
| 677 | + | if err := c.apply(cfg); err != nil { | |
| 678 | + | t.Fatal(err) | |
| 679 | + | } | |
| 680 | + | if m.Current().policy().workspace != horizontalAxis { | |
| 681 | + | t.Fatal("setup: not cascade") | |
| 682 | + | } | |
| 683 | + | // The reload keeps the overview open on its selection, and cancelling | |
| 684 | + | // still gives the original focus back. | |
| 685 | + | if id, _ := w.Focused(); !m.ov.open || id != 1 { | |
| 686 | + | t.Fatalf("reload lost the overview selection: open %v, focus %d", m.ov.open, id) | |
| 687 | + | } | |
| 688 | + | // The scroll is dropped, but not the latch that waits for the fingers | |
| 689 | + | // to lift. | |
| 690 | + | if m.ov.scrollX != 0 || m.ov.scrollY != 0 || !m.ov.scrolled || m.ov.sideways { | |
| 691 | + | t.Fatalf("axis change kept the scroll or dropped the latch: %+v", m.ov) | |
| 692 | + | } | |
| 693 | + | set() | |
| 694 | + | cfg.Layout.Overflow = string(OverflowFixed) | |
| 695 | + | if err := c.apply(cfg); err != nil { | |
| 696 | + | t.Fatal(err) | |
| 697 | + | } | |
| 698 | + | if m.ov.scrollX != 0 || !m.ov.scrolled { | |
| 699 | + | t.Fatalf("workspace axis change kept the scroll or dropped the latch: %+v", m.ov) | |
| 700 | + | } | |
| 701 | + | m.CancelOverview() | |
| 702 | + | if id, _ := w.Focused(); id != 1 { | |
| 703 | + | t.Fatalf("cancel left the focus on %d", id) | |
| 704 | + | } | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | // The finger latch outlives an axis change: a gesture that turned the | |
| 708 | + | // overview, then was turned back by a key, does not step again. | |
| 709 | + | func TestOverviewFingerLatchSurvivesAxisChange(t *testing.T) { | |
| 710 | + | c, m := cascadeCore(t) | |
| 711 | + | w := m.Current() | |
| 712 | + | m.ToggleOverview() | |
| 713 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |
| 714 | + | c.swipeUpdate(ports.SwipeUpdate{DX: 70}) | |
| 715 | + | if m.Current() != m.Workspaces[2] || m.overviewAxis() != verticalAxis { | |
| 716 | + | t.Fatalf("setup: workspace %d", indexOf(m.Workspaces, m.Current())) | |
| 717 | + | } | |
| 718 | + | // Back to the cascade workspace by a key, with the fingers still down. | |
| 719 | + | m.overviewKey(ports.KeyEvent{Keysym: "k", Pressed: true}) | |
| 720 | + | if m.Current() != w || m.overviewAxis() != horizontalAxis { | |
| 721 | + | t.Fatal("setup: the key did not turn the overview back") | |
| 722 | + | } | |
| 723 | + | for _, u := range []ports.SwipeUpdate{{DY: 70}, {DX: 70}, {DY: -70}, {DX: -70}} { | |
| 724 | + | c.swipeUpdate(u) | |
| 725 | + | } | |
| 726 | + | c.swipeEnd(ports.SwipeEnd{}) | |
| 727 | + | if m.Current() != w || w.Focus != 0 { | |
| 728 | + | t.Fatalf("the gesture stepped again: workspace %d, column %d", indexOf(m.Workspaces, m.Current()), w.Focus) | |
| 729 | + | } | |
| 730 | + | // The next gesture steps again. | |
| 731 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |
| 732 | + | c.swipeUpdate(ports.SwipeUpdate{DY: 70}) | |
| 733 | + | c.swipeEnd(ports.SwipeEnd{}) | |
| 734 | + | if w.band(w.Focus) != 1 { | |
| 735 | + | t.Fatalf("the next gesture did not step: band %d", w.band(w.Focus)) | |
| 736 | + | } | |
| 737 | + | } | |
| 738 | + | ||
| 739 | + | // A stash-only cascade workspace has no columns to select: right from its | |
| 740 | + | // pile goes straight to the next workspace. | |
| 741 | + | func TestOverviewCascadeStashOnlyRight(t *testing.T) { | |
| 742 | + | m := overviewMonitor() | |
| 743 | + | m.Focus(1) | |
| 744 | + | w := m.Current() | |
| 745 | + | w.Overflow = OverflowCascade | |
| 746 | + | w.ToggleWindowStash() | |
| 747 | + | m.Focus(2) | |
| 748 | + | m.AddWindow(30) | |
| 749 | + | m.Focus(1) | |
| 750 | + | m.ToggleOverview() | |
| 751 | + | if len(w.Columns) != 0 || m.cardAt(w) < 0 { | |
| 752 | + | t.Fatalf("setup: %d columns, card %d", len(w.Columns), m.cardAt(w)) | |
| 753 | + | } | |
| 754 | + | m.OverviewMove(1, 0) | |
| 755 | + | if m.Current() != m.Workspaces[2] { | |
| 756 | + | t.Fatalf("right from the pile: workspace %d", indexOf(m.Workspaces, m.Current())) | |
| 757 | + | } | |
| 758 | + | m.OverviewMove(0, -1) // the next workspace is a scroll one: up goes back | |
| 759 | + | if m.Current() != w || m.cardAt(w) < 0 { | |
| 760 | + | t.Fatal("setup: back on the stash-only workspace without its card") | |
| 761 | + | } | |
| 762 | + | m.OverviewMove(-1, 0) | |
| 763 | + | if m.Current() != m.Workspaces[0] { | |
| 764 | + | t.Fatalf("left from the pile: workspace %d", indexOf(m.Workspaces, m.Current())) | |
| 765 | + | } | |
| 766 | + | } | |
| 767 | + | ||
| 768 | + | // A cascade neighbor beside a cascade workspace draws its other lanes | |
| 769 | + | // dimmed, its selected lane level with the centre lane. | |
| 770 | + | func TestOverviewCascadeNeighborLanes(t *testing.T) { | |
| 771 | + | m := cascadeOverview() | |
| 772 | + | prev := m.Workspaces[0] | |
| 773 | + | prev.Overflow = OverflowCascade | |
| 774 | + | for id := WindowID(60); id <= 65; id++ { | |
| 775 | + | prev.AddWindow(id) // bands {1,2,3} {60,61,62} {63,64,65}, 65 selected | |
| 776 | + | } | |
| 777 | + | m.ToggleOverview() | |
| 778 | + | ps := m.Layout() | |
| 779 | + | centre := centreY(previewOf(t, ps, 8).Rect) | |
| 780 | + | sel := previewOf(t, ps, 65) | |
| 781 | + | if sel.Hidden || !sel.Peek || sel.Focused || !within1(centreY(sel.Rect), centre) { | |
| 782 | + | t.Fatalf("selected lane of the neighbor: %+v, centre lane %d", sel, centre) | |
| 783 | + | } | |
| 784 | + | above := previewOf(t, ps, 62) | |
| 785 | + | if above.Hidden || !above.Peek || above.Rect.Y+above.Rect.H > sel.Rect.Y { | |
| 786 | + | t.Fatalf("lane above in the neighbor: %+v, selected %v", above, sel.Rect) | |
| 787 | + | } | |
| 788 | + | if p := previewOf(t, ps, 3); !hiddenOff(p) { | |
| 789 | + | t.Fatalf("lane two away in the neighbor: %+v", p) | |
| 790 | + | } | |
| 791 | + | } | |
| 792 | + | ||
| 793 | + | // A scroll neighbor wider than its slot scrolls inside it: what leaves the | |
| 794 | + | // slot is hidden, so it never sits on the current slot. | |
| 795 | + | func TestOverviewCascadeWideScrollNeighborIsClipped(t *testing.T) { | |
| 796 | + | m := cascadeOverview() | |
| 797 | + | prev := m.Workspaces[0] | |
| 798 | + | for id := WindowID(60); id <= 67; id++ { | |
| 799 | + | prev.AddWindow(id) | |
| 800 | + | } | |
| 801 | + | m.ToggleOverview() | |
| 802 | + | rows, _ := m.overviewRows() | |
| 803 | + | cur, slot := rows[m.Current()], rows[prev] | |
| 804 | + | hidden, shown := 0, 0 | |
| 805 | + | for _, p := range m.Layout() { | |
| 806 | + | if !prev.has(p.ID) { | |
| 807 | + | continue | |
| 808 | + | } | |
| 809 | + | if p.Hidden { | |
| 810 | + | hidden++ | |
| 811 | + | continue | |
| 812 | + | } | |
| 813 | + | shown++ | |
| 814 | + | if p.Rect.X < slot.X || p.Rect.X+p.Rect.W > slot.X+slot.W || p.Rect.X+p.Rect.W > cur.X { | |
| 815 | + | t.Fatalf("tile %d leaves its slot %v or reaches the current slot %v: %v", p.ID, slot, cur, p.Rect) | |
| 816 | + | } | |
| 817 | + | } | |
| 818 | + | if hidden == 0 || shown == 0 { | |
| 819 | + | t.Fatalf("setup: %d tiles shown, %d hidden", shown, hidden) | |
| 820 | + | } | |
| 821 | + | } | |
| 822 | + | ||
| 823 | + | // A wheel and a continuous scroll in a cascade overview: vertical changes | |
| 824 | + | // workspace (and does not bounce back on a workspace with another axis), | |
| 825 | + | // horizontal moves along the band. | |
| 826 | + | func TestOverviewCascadeScroll(t *testing.T) { | |
| 827 | + | vertical := ports.ScrollAxis{Set: true, Value: overviewScrollStep, V120: 120} | |
| 828 | + | for _, source := range []ports.AxisSource{ports.AxisWheel, ports.AxisContinuous} { | |
| 829 | + | t.Run(fmt.Sprint(source), func(t *testing.T) { | |
| 830 | + | m := cascadeOverview() | |
| 831 | + | w := m.Current() | |
| 832 | + | m.ToggleOverview() | |
| 833 | + | m.overviewScroll(ports.PointerAxis{Source: source, Vertical: vertical}) | |
| 834 | + | if m.Current() != m.Workspaces[2] || w.band(w.Focus) != 1 || m.ov.scrollX != 0 || m.ov.scrollY != 0 { | |
| 835 | + | t.Fatalf("vertical: workspace %d, band %d, scroll %v,%v", indexOf(m.Workspaces, m.Current()), w.band(w.Focus), m.ov.scrollX, m.ov.scrollY) | |
| 836 | + | } | |
| 837 | + | ||
| 838 | + | m = cascadeOverview() | |
| 839 | + | w = m.Current() | |
| 840 | + | w.FocusID(7) | |
| 841 | + | m.ToggleOverview() | |
| 842 | + | m.overviewScroll(ports.PointerAxis{Source: source, Horizontal: vertical}) | |
| 843 | + | if m.Current() != w || w.Columns[w.Focus].Windows[0] != 8 { | |
| 844 | + | t.Fatalf("horizontal: column %d", w.Columns[w.Focus].Windows[0]) | |
| 845 | + | } | |
| 846 | + | ||
| 847 | + | // Onto a workspace with another axis: one step, no bounce. | |
| 848 | + | m = overviewMonitor() | |
| 849 | + | m.SetOverflow(OverflowFixed) | |
| 850 | + | m.Workspaces[1].Overflow = OverflowCascade | |
| 851 | + | m.ToggleOverview() | |
| 852 | + | m.overviewScroll(ports.PointerAxis{Source: source, Vertical: vertical}) | |
| 853 | + | if m.Current() != m.Workspaces[1] || m.ov.scrollX != 0 || m.ov.scrollY != 0 { | |
| 854 | + | t.Fatalf("mixed: workspace %d, scroll %v,%v", indexOf(m.Workspaces, m.Current()), m.ov.scrollX, m.ov.scrollY) | |
| 855 | + | } | |
| 856 | + | }) | |
| 857 | + | } | |
| 858 | + | } | |
| @@ -0,0 +1,858 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "slices" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // cascadeOverview is overviewMonitor with workspace 2 (current) a cascade of | |||
| 12 | + | // seven columns, 4..10, in three bands of three (the last holds 10 alone), | |||
| 13 | + | // the focus on column 8 (band 2, middle). Workspace 1 (columns 1..3) is a | |||
| 14 | + | // scroll workspace on its left, workspace 3 (column 30) on its right. | |||
| 15 | + | func cascadeOverview() *Monitor { | |||
| 16 | + | m := overviewMonitor() | |||
| 17 | + | m.SetBorder(2) | |||
| 18 | + | m.Focus(1) | |||
| 19 | + | w := m.Current() | |||
| 20 | + | w.Overflow = OverflowCascade | |||
| 21 | + | for id := WindowID(5); id <= 10; id++ { | |||
| 22 | + | w.AddWindow(id) | |||
| 23 | + | } | |||
| 24 | + | m.Focus(2) | |||
| 25 | + | m.AddWindow(30) | |||
| 26 | + | m.Focus(1) | |||
| 27 | + | w.FocusID(8) | |||
| 28 | + | return m | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // withStash stashes windows 40..40+n-1 of the current workspace, with the | |||
| 32 | + | // focus back on column 8. | |||
| 33 | + | func withStash(m *Monitor, n int) { | |||
| 34 | + | w := m.Current() | |||
| 35 | + | for id := WindowID(40); id < WindowID(40+n); id++ { | |||
| 36 | + | w.AddWindow(id) | |||
| 37 | + | w.FocusID(id) | |||
| 38 | + | w.ToggleWindowStash() | |||
| 39 | + | } | |||
| 40 | + | w.FocusID(8) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // cascadeCore is a Core on one output with workspace 0 holding window 1, | |||
| 44 | + | // workspace 1 (current) a cascade of windows 2..6 in bands of two ({2,3} | |||
| 45 | + | // {4,5} {6}), workspace 2 window 30, and window 2 focused. | |||
| 46 | + | func cascadeCore(t *testing.T) (*Core, *Monitor) { | |||
| 47 | + | t.Helper() | |||
| 48 | + | c, _ := indicatorCore(t) | |||
| 49 | + | m := c.cur().mon | |||
| 50 | + | m.AddWindow(1) | |||
| 51 | + | m.Focus(1) | |||
| 52 | + | w := m.Current() | |||
| 53 | + | w.Overflow = OverflowCascade | |||
| 54 | + | for id := WindowID(2); id <= 6; id++ { | |||
| 55 | + | w.AddWindow(id) | |||
| 56 | + | } | |||
| 57 | + | m.normalize() | |||
| 58 | + | m.Focus(2) | |||
| 59 | + | m.AddWindow(30) | |||
| 60 | + | m.Focus(1) | |||
| 61 | + | w.FocusID(2) | |||
| 62 | + | return c, m | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func centreY(r Rect) int { return r.Y + r.H/2 } | |||
| 66 | + | ||||
| 67 | + | func within1(a, b int) bool { return a-b <= 1 && b-a <= 1 } | |||
| 68 | + | ||||
| 69 | + | func hiddenOff(p Placement) bool { return p.Hidden && p.Preview == 0 && !p.Focused } | |||
| 70 | + | ||||
| 71 | + | // T1: the zoom does not depend on the number of bands. | |||
| 72 | + | func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) { | |||
| 73 | + | if one, three := cascadeWorkspace(3).overviewZoom(), cascadeWorkspace(7).overviewZoom(); one != three || one != overviewMaxZoom { | |||
| 74 | + | t.Fatalf("zoom with one band %v, with three %v", one, three) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // T2: the selected band is the centre lane: undimmed, centred, selected tile | |||
| 79 | + | // focused. | |||
| 80 | + | func TestOverviewCascadeCentreLane(t *testing.T) { | |||
| 81 | + | m := cascadeOverview() | |||
| 82 | + | m.ToggleOverview() | |||
| 83 | + | ps := m.Layout() | |||
| 84 | + | u := m.Current().overviewArea() | |||
| 85 | + | for _, id := range []WindowID{7, 8, 9} { | |||
| 86 | + | p := previewOf(t, ps, id) | |||
| 87 | + | if p.Hidden || p.Peek || p.Preview != overviewMaxZoom || p.Focused != (id == 8) { | |||
| 88 | + | t.Fatalf("band tile %d: %+v", id, p) | |||
| 89 | + | } | |||
| 90 | + | if !within1(centreY(p.Rect), u.Y+u.H/2) { | |||
| 91 | + | t.Fatalf("band tile %d centre %d, area centre %d", id, centreY(p.Rect), u.Y+u.H/2) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // T3: the other bands are dimmed lanes a screen (and a gap) away; those | |||
| 97 | + | // outside the area are hidden. | |||
| 98 | + | func TestOverviewCascadeOtherLanes(t *testing.T) { | |||
| 99 | + | m := cascadeOverview() | |||
| 100 | + | m.ToggleOverview() | |||
| 101 | + | ps := m.Layout() | |||
| 102 | + | sel := previewOf(t, ps, 8).Rect | |||
| 103 | + | above, below := previewOf(t, ps, 5), previewOf(t, ps, 10) | |||
| 104 | + | if above.Hidden || !above.Peek || above.Focused || below.Hidden || !below.Peek || below.Focused { | |||
| 105 | + | t.Fatalf("adjacent bands: above %+v, below %+v", above, below) | |||
| 106 | + | } | |||
| 107 | + | // The lanes sit evenly around the selected one, apart from it (a gap), | |||
| 108 | + | // and the area still shows part of each. | |||
| 109 | + | up, down := sel.Y-above.Rect.Y, below.Rect.Y-sel.Y | |||
| 110 | + | if !within1(up, down) { | |||
| 111 | + | t.Fatalf("lanes not evenly spaced: %d above, %d below", up, down) | |||
| 112 | + | } | |||
| 113 | + | if gap := up - sel.H; gap <= 0 { | |||
| 114 | + | t.Fatalf("lane above overlaps the selected one: %v over %v", above.Rect, sel) | |||
| 115 | + | } | |||
| 116 | + | u := m.Current().overviewArea() | |||
| 117 | + | if above.Rect.Y+above.Rect.H <= u.Y || below.Rect.Y >= u.Y+u.H { | |||
| 118 | + | t.Fatalf("adjacent lanes not visible in %v: %v, %v", u, above.Rect, below.Rect) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // Five bands (13 columns): the ones two lanes away are off the area. | |||
| 122 | + | m = cascadeOverview() | |||
| 123 | + | w := m.Current() | |||
| 124 | + | for id := WindowID(11); id <= 16; id++ { | |||
| 125 | + | w.AddWindow(id) | |||
| 126 | + | } | |||
| 127 | + | w.FocusID(8) | |||
| 128 | + | m.ToggleOverview() | |||
| 129 | + | // Columns 4..16: bands {4,5,6} {7,8,9} {10,11,12} {13,14,15} {16}. | |||
| 130 | + | w.FocusID(11) | |||
| 131 | + | ps = m.Layout() | |||
| 132 | + | for _, id := range []WindowID{4, 5, 6, 16} { | |||
| 133 | + | if p := previewOf(t, ps, id); !hiddenOff(p) { | |||
| 134 | + | t.Fatalf("band two lanes away %d not hidden: %+v", id, p) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | for _, id := range []WindowID{7, 13} { | |||
| 138 | + | if p := previewOf(t, ps, id); p.Hidden || !p.Peek { | |||
| 139 | + | t.Fatalf("adjacent band %d: %+v", id, p) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // T4: neighbor workspaces sit left and right of the current slot, dimmed, | |||
| 145 | + | // their selected lane level with the centre lane. | |||
| 146 | + | func TestOverviewCascadeNeighbors(t *testing.T) { | |||
| 147 | + | m := cascadeOverview() | |||
| 148 | + | m.ToggleOverview() | |||
| 149 | + | rows, _ := m.overviewRows() | |||
| 150 | + | cur, left, right := rows[m.Workspaces[1]], rows[m.Workspaces[0]], rows[m.Workspaces[2]] | |||
| 151 | + | if left.X+left.W > cur.X || right.X < cur.X+cur.W { | |||
| 152 | + | t.Fatalf("slots overlap: %v %v %v", left, cur, right) | |||
| 153 | + | } | |||
| 154 | + | ps := m.Layout() | |||
| 155 | + | centre := centreY(previewOf(t, ps, 8).Rect) | |||
| 156 | + | shown := 0 | |||
| 157 | + | for _, n := range []struct { | |||
| 158 | + | id WindowID | |||
| 159 | + | side int | |||
| 160 | + | }{{1, -1}, {2, -1}, {3, -1}, {30, 1}} { | |||
| 161 | + | id, side := n.id, n.side | |||
| 162 | + | p := previewOf(t, ps, id) | |||
| 163 | + | if p.Hidden { | |||
| 164 | + | continue | |||
| 165 | + | } | |||
| 166 | + | shown++ | |||
| 167 | + | if !p.Peek || p.Focused || p.Preview == 0 { | |||
| 168 | + | t.Fatalf("neighbor %d not dimmed: %+v", id, p) | |||
| 169 | + | } | |||
| 170 | + | if side < 0 && p.Rect.X+p.Rect.W > cur.X || side > 0 && p.Rect.X < cur.X+cur.W { | |||
| 171 | + | t.Fatalf("neighbor %d overlaps the current slot %v: %+v", id, cur, p.Rect) | |||
| 172 | + | } | |||
| 173 | + | if !within1(centreY(p.Rect), centre) { | |||
| 174 | + | t.Fatalf("neighbor %d centre %d, centre lane %d", id, centreY(p.Rect), centre) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | if shown < 2 { | |||
| 178 | + | t.Fatalf("only %d neighbor tiles drawn", shown) | |||
| 179 | + | } | |||
| 180 | + | for _, p := range ps { | |||
| 181 | + | if p.Hidden || p.Peek { | |||
| 182 | + | continue | |||
| 183 | + | } | |||
| 184 | + | if p.Rect.X < cur.X || p.Rect.X+p.Rect.W > cur.X+cur.W { | |||
| 185 | + | t.Fatalf("current tile %d outside its slot %v: %+v", p.ID, cur, p.Rect) | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // T5: lanes along the overview axis would overlap the other workspaces: a | |||
| 191 | + | // cascade neighbor of a scroll workspace shows its selected band only. | |||
| 192 | + | func TestOverviewCascadeNeighborBelowScrollShowsOneBand(t *testing.T) { | |||
| 193 | + | m := cascadeOverview() | |||
| 194 | + | m.Focus(0) | |||
| 195 | + | m.ToggleOverview() | |||
| 196 | + | if m.overviewAxis() != verticalAxis { | |||
| 197 | + | t.Fatal("setup: scroll overview is not vertical") | |||
| 198 | + | } | |||
| 199 | + | ps := m.Layout() | |||
| 200 | + | for _, id := range []WindowID{4, 5, 6} { | |||
| 201 | + | if p := previewOf(t, ps, id); !hiddenOff(p) { | |||
| 202 | + | t.Fatalf("band %d of the neighbor drawn: %+v", id, p) | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | if p := previewOf(t, ps, 8); p.Hidden { | |||
| 206 | + | // The neighbor's selected column is 8. | |||
| 207 | + | t.Fatalf("selected band hidden: %+v", p) | |||
| 208 | + | } | |||
| 209 | + | for _, id := range []WindowID{7, 9} { | |||
| 210 | + | if p := previewOf(t, ps, id); p.Hidden || !p.Peek { | |||
| 211 | + | t.Fatalf("selected band tile %d: %+v", id, p) | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | if p := previewOf(t, ps, 10); !hiddenOff(p) { | |||
| 215 | + | t.Fatalf("band 3 drawn: %+v", p) | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // T6: the stash pile is inside the current slot, left of the band; the | |||
| 220 | + | // previous workspace stays clear of it. | |||
| 221 | + | func TestOverviewCascadeStashInSlot(t *testing.T) { | |||
| 222 | + | m := cascadeOverview() | |||
| 223 | + | withStash(m, 2) | |||
| 224 | + | m.ToggleOverview() | |||
| 225 | + | rows, _ := m.overviewRows() | |||
| 226 | + | cur, prev := rows[m.Current()], rows[m.Workspaces[0]] | |||
| 227 | + | ps := m.Layout() | |||
| 228 | + | left := cur.X + cur.W | |||
| 229 | + | for _, id := range []WindowID{7, 8, 9} { | |||
| 230 | + | left = min(left, previewOf(t, ps, id).Rect.X) | |||
| 231 | + | } | |||
| 232 | + | for _, id := range []WindowID{40, 41} { | |||
| 233 | + | p := previewOf(t, ps, id) | |||
| 234 | + | if p.Hidden || p.Preview != overviewCardZoom { | |||
| 235 | + | t.Fatalf("pile card %d: %+v", id, p) | |||
| 236 | + | } | |||
| 237 | + | if p.Rect.X < cur.X || p.Rect.X+p.Rect.W > left { | |||
| 238 | + | t.Fatalf("pile card %d not inside the slot, left of the band (%d..%d): %+v", id, cur.X, left, p.Rect) | |||
| 239 | + | } | |||
| 240 | + | // 41 is the front card of the pile. | |||
| 241 | + | if id == 41 && !within1(centreY(p.Rect), centreY(previewOf(t, ps, 8).Rect)) { | |||
| 242 | + | t.Fatalf("pile not level with the selected lane: %+v", p.Rect) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | // The pile does not shift the band: its row stays centred on the area. | |||
| 246 | + | u := m.Current().overviewArea() | |||
| 247 | + | lo, hi := previewOf(t, ps, 7).Rect, previewOf(t, ps, 9).Rect | |||
| 248 | + | if mid := (lo.X + hi.X + hi.W) / 2; !within1(mid, u.X+u.W/2) { | |||
| 249 | + | t.Fatalf("band centre %d with a stash, area centre %d", mid, u.X+u.W/2) | |||
| 250 | + | } | |||
| 251 | + | for _, id := range []WindowID{1, 2, 3} { | |||
| 252 | + | if p := previewOf(t, ps, id); !p.Hidden && p.Rect.X+p.Rect.W > cur.X { | |||
| 253 | + | t.Fatalf("previous workspace overlaps the slot with the pile: %+v", p.Rect) | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | if prev.X+prev.W > cur.X { | |||
| 257 | + | t.Fatalf("slots overlap: %v %v", prev, cur) | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | type cascadeDriver struct { | |||
| 262 | + | name string | |||
| 263 | + | move func(m *Monitor, dx, dy int) | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func keyDriver(left, right, up, down string) func(m *Monitor, dx, dy int) { | |||
| 267 | + | return func(m *Monitor, dx, dy int) { | |||
| 268 | + | name := map[[2]int]string{{-1, 0}: left, {1, 0}: right, {0, -1}: up, {0, 1}: down}[[2]int{dx, dy}] | |||
| 269 | + | m.overviewKey(ports.KeyEvent{Keysym: name, Pressed: true}) | |||
| 270 | + | } | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | var cascadeDrivers = []cascadeDriver{ | |||
| 274 | + | {"move", func(m *Monitor, dx, dy int) { m.OverviewMove(dx, dy) }}, | |||
| 275 | + | {"hjkl", keyDriver("h", "l", "k", "j")}, | |||
| 276 | + | {"arrows", keyDriver("Left", "Right", "Up", "Down")}, | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | func focusedID(m *Monitor) WindowID { | |||
| 280 | + | id, _ := m.Focused() | |||
| 281 | + | return id | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | // T7: ↑/↓ change band and stop at the ends; ←/→ walk the band, then leave it | |||
| 285 | + | // for the stash or the neighbor workspaces; Escape restores everything. | |||
| 286 | + | func TestOverviewCascadeKeys(t *testing.T) { | |||
| 287 | + | for _, d := range cascadeDrivers { | |||
| 288 | + | t.Run(d.name, func(t *testing.T) { | |||
| 289 | + | m := cascadeOverview() | |||
| 290 | + | w, prev, next := m.Current(), m.Workspaces[0], m.Workspaces[2] | |||
| 291 | + | w.FocusID(5) | |||
| 292 | + | m.ToggleOverview() | |||
| 293 | + | sel := func() WindowID { return w.Columns[w.Focus].Windows[0] } | |||
| 294 | + | d.move(m, 0, 1) | |||
| 295 | + | if sel() != 8 { | |||
| 296 | + | t.Fatalf("down: %d, want 8", sel()) | |||
| 297 | + | } | |||
| 298 | + | d.move(m, 0, 1) | |||
| 299 | + | d.move(m, 0, 1) | |||
| 300 | + | if m.Current() != w || sel() != 10 { | |||
| 301 | + | t.Fatalf("down at the last band: %d on the current workspace %v", sel(), m.Current() == w) | |||
| 302 | + | } | |||
| 303 | + | d.move(m, 0, -1) | |||
| 304 | + | if sel() != 7 { | |||
| 305 | + | t.Fatalf("up: %d, want 7", sel()) | |||
| 306 | + | } | |||
| 307 | + | d.move(m, 0, -1) | |||
| 308 | + | d.move(m, 0, -1) | |||
| 309 | + | if m.Current() != w || sel() != 4 { | |||
| 310 | + | t.Fatalf("up at the first band: %d", sel()) | |||
| 311 | + | } | |||
| 312 | + | d.move(m, 1, 0) | |||
| 313 | + | d.move(m, 1, 0) | |||
| 314 | + | if sel() != 6 || m.Current() != w { | |||
| 315 | + | t.Fatalf("right in the band: %d", sel()) | |||
| 316 | + | } | |||
| 317 | + | d.move(m, 1, 0) | |||
| 318 | + | if m.Current() != next { | |||
| 319 | + | t.Fatal("right past the last column did not reach the next workspace") | |||
| 320 | + | } | |||
| 321 | + | m.CancelOverview() | |||
| 322 | + | if m.Current() != w || focusedID(m) != 5 { | |||
| 323 | + | t.Fatalf("cancel left %d", focusedID(m)) | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | m.ToggleOverview() | |||
| 327 | + | d.move(m, -1, 0) | |||
| 328 | + | if sel() != 4 || m.Current() != w { | |||
| 329 | + | t.Fatal("left within the band") | |||
| 330 | + | } | |||
| 331 | + | d.move(m, -1, 0) | |||
| 332 | + | if m.Current() != prev { | |||
| 333 | + | t.Fatal("left from the first column did not reach the previous workspace") | |||
| 334 | + | } | |||
| 335 | + | m.CancelOverview() | |||
| 336 | + | ||||
| 337 | + | // With a stash: left from the first column enters it, right | |||
| 338 | + | // comes back to the band's first column, left twice goes to | |||
| 339 | + | // the previous workspace. | |||
| 340 | + | withStash(m, 2) | |||
| 341 | + | w.FocusID(7) | |||
| 342 | + | m.ToggleOverview() | |||
| 343 | + | d.move(m, -1, 0) | |||
| 344 | + | if m.cardAt(w) < 0 { | |||
| 345 | + | t.Fatal("left from the first column did not select the stash") | |||
| 346 | + | } | |||
| 347 | + | d.move(m, 1, 0) | |||
| 348 | + | if m.cardAt(w) >= 0 || sel() != 7 { | |||
| 349 | + | t.Fatalf("right from the stash: card %d, column %d", m.cardAt(w), sel()) | |||
| 350 | + | } | |||
| 351 | + | d.move(m, -1, 0) | |||
| 352 | + | d.move(m, -1, 0) | |||
| 353 | + | if m.Current() != prev { | |||
| 354 | + | t.Fatal("left from the stash did not reach the previous workspace") | |||
| 355 | + | } | |||
| 356 | + | m.CancelOverview() | |||
| 357 | + | if m.Current() != w || focusedID(m) != 7 { | |||
| 358 | + | t.Fatalf("cancel left %d", focusedID(m)) | |||
| 359 | + | } | |||
| 360 | + | }) | |||
| 361 | + | } | |||
| 362 | + | } | |||
| 363 | + | ||||
| 364 | + | // T7b: with a stash card selected, up/down and the three-finger vertical | |||
| 365 | + | // swipe cycle the pile as on main; confirming shows the picked window. | |||
| 366 | + | func TestOverviewCascadeStashPile(t *testing.T) { | |||
| 367 | + | for _, d := range cascadeDrivers { | |||
| 368 | + | t.Run(d.name, func(t *testing.T) { | |||
| 369 | + | m := cascadeOverview() | |||
| 370 | + | withStash(m, 3) | |||
| 371 | + | w := m.Current() | |||
| 372 | + | w.FocusID(7) | |||
| 373 | + | m.ToggleOverview() | |||
| 374 | + | d.move(m, -1, 0) | |||
| 375 | + | if m.cardAt(w) != w.stashAt { | |||
| 376 | + | t.Fatalf("entered at %d", m.cardAt(w)) | |||
| 377 | + | } | |||
| 378 | + | at := m.cardAt(w) | |||
| 379 | + | d.move(m, 0, -1) | |||
| 380 | + | if m.Current() != w || m.cardAt(w) != at-1 { | |||
| 381 | + | t.Fatalf("up: card %d, was %d", m.cardAt(w), at) | |||
| 382 | + | } | |||
| 383 | + | d.move(m, 0, 1) | |||
| 384 | + | d.move(m, 0, 1) | |||
| 385 | + | d.move(m, 0, 1) | |||
| 386 | + | if m.Current() != w || m.cardAt(w) != len(w.Stash)-1 { | |||
| 387 | + | t.Fatalf("down: card %d", m.cardAt(w)) | |||
| 388 | + | } | |||
| 389 | + | d.move(m, 0, -1) | |||
| 390 | + | picked := w.Stash[m.cardAt(w)].ID | |||
| 391 | + | m.ToggleOverview() | |||
| 392 | + | if focusedID(m) != picked || w.stashHidden { | |||
| 393 | + | t.Fatalf("confirm on %d, want %d (hidden %v)", focusedID(m), picked, w.stashHidden) | |||
| 394 | + | } | |||
| 395 | + | }) | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | c, _, sc, _ := stashRig(t, true) | |||
| 399 | + | m := sc.mon | |||
| 400 | + | w := m.Current() | |||
| 401 | + | w.Overflow = OverflowCascade | |||
| 402 | + | m.Focus(1) | |||
| 403 | + | m.AddWindow(50) | |||
| 404 | + | m.Focus(0) | |||
| 405 | + | m.ToggleOverview() | |||
| 406 | + | if m.cardAt(w) < 0 { | |||
| 407 | + | t.Fatal("setup: no card selected") | |||
| 408 | + | } | |||
| 409 | + | at, before := m.cardAt(w), stashIDs(w) | |||
| 410 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |||
| 411 | + | for range 3 { | |||
| 412 | + | c.swipeUpdate(ports.SwipeUpdate{DY: -40}) | |||
| 413 | + | } | |||
| 414 | + | c.swipeEnd(ports.SwipeEnd{}) | |||
| 415 | + | if m.Current() != w || m.cardAt(w) != at-1 { | |||
| 416 | + | t.Fatalf("swipe up: card %d, was %d", m.cardAt(w), at) | |||
| 417 | + | } | |||
| 418 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |||
| 419 | + | for range 3 { | |||
| 420 | + | c.swipeUpdate(ports.SwipeUpdate{DX: 40}) | |||
| 421 | + | } | |||
| 422 | + | c.swipeEnd(ports.SwipeEnd{}) | |||
| 423 | + | // A horizontal three-finger swipe is a workspace step, even over a | |||
| 424 | + | // stash card. | |||
| 425 | + | if m.Current() != m.Workspaces[1] { | |||
| 426 | + | t.Fatalf("horizontal swipe over a stash card: workspace %d", indexOf(m.Workspaces, m.Current())) | |||
| 427 | + | } | |||
| 428 | + | if got := stashIDs(w); !slices.Equal(got, before) { | |||
| 429 | + | t.Fatal("stash reordered") | |||
| 430 | + | } | |||
| 431 | + | } | |||
| 432 | + | ||||
| 433 | + | // T7c: three fingers follow the physical axes, once per gesture. | |||
| 434 | + | func TestOverviewCascadeThreeFinger(t *testing.T) { | |||
| 435 | + | c, m := cascadeCore(t) | |||
| 436 | + | w := m.Current() | |||
| 437 | + | m.ToggleOverview() | |||
| 438 | + | swipe := func(dx, dy float64) { | |||
| 439 | + | t.Helper() | |||
| 440 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |||
| 441 | + | for range 4 { | |||
| 442 | + | c.swipeUpdate(ports.SwipeUpdate{DX: dx, DY: dy}) | |||
| 443 | + | } | |||
| 444 | + | c.swipeEnd(ports.SwipeEnd{}) | |||
| 445 | + | } | |||
| 446 | + | swipe(0, 40) | |||
| 447 | + | if m.Current() != w || w.band(w.Focus) != 1 { | |||
| 448 | + | t.Fatalf("one swipe down: band %d", w.band(w.Focus)) | |||
| 449 | + | } | |||
| 450 | + | swipe(0, -40) | |||
| 451 | + | if w.band(w.Focus) != 0 { | |||
| 452 | + | t.Fatalf("one swipe up: band %d", w.band(w.Focus)) | |||
| 453 | + | } | |||
| 454 | + | swipe(40, 0) | |||
| 455 | + | if m.Current() != m.Workspaces[2] { | |||
| 456 | + | t.Fatalf("one swipe right: workspace %d", indexOf(m.Workspaces, m.Current())) | |||
| 457 | + | } | |||
| 458 | + | // Onto a scroll workspace and back: the vertical swipe is the scroll | |||
| 459 | + | // overview's workspace step; neither bounces during the gesture. | |||
| 460 | + | swipe(0, -40) | |||
| 461 | + | if m.Current() != w { | |||
| 462 | + | t.Fatalf("swipe up from the scroll workspace: workspace %d", indexOf(m.Workspaces, m.Current())) | |||
| 463 | + | } | |||
| 464 | + | swipe(-40, 0) | |||
| 465 | + | if m.Current() != m.Workspaces[0] { | |||
| 466 | + | t.Fatalf("one swipe left: workspace %d", indexOf(m.Workspaces, m.Current())) | |||
| 467 | + | } | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | // T8: a named boundary of a cascade overview is a vertical rule strictly | |||
| 471 | + | // between the two slots. | |||
| 472 | + | func TestOverviewCascadeNamedDivider(t *testing.T) { | |||
| 473 | + | m := namedOverviewMonitor() | |||
| 474 | + | m.Workspaces[1].Overflow = OverflowCascade | |||
| 475 | + | m.ToggleOverview() | |||
| 476 | + | rows, dividers := m.overviewRows() | |||
| 477 | + | if len(dividers) != 1 { | |||
| 478 | + | t.Fatalf("dividers: %+v", dividers) | |||
| 479 | + | } | |||
| 480 | + | d, cur, dev := dividers[0].Rect, rows[m.Workspaces[1]], rows[m.byName("dev")] | |||
| 481 | + | if d.W != 1 || d.H <= 1 || dividers[0].Active { | |||
| 482 | + | t.Fatalf("divider is not an inactive vertical rule: %+v", dividers[0]) | |||
| 483 | + | } | |||
| 484 | + | if d.X < cur.X+cur.W || d.X+d.W > dev.X { | |||
| 485 | + | t.Fatalf("divider %v not between %v and %v", d, cur, dev) | |||
| 486 | + | } | |||
| 487 | + | } | |||
| 488 | + | ||||
| 489 | + | // T10: covering floats fan along the horizontal axis: left goes deeper in the | |||
| 490 | + | // stack (the cards behind show on the left), right comes back to the front. | |||
| 491 | + | func TestOverviewCascadeCoveringFloat(t *testing.T) { | |||
| 492 | + | for _, d := range cascadeDrivers { | |||
| 493 | + | t.Run(d.name, func(t *testing.T) { | |||
| 494 | + | m := cascadeOverview() | |||
| 495 | + | m.Current().AddFloating(90, 300, 200) | |||
| 496 | + | w := m.Current() | |||
| 497 | + | w.FocusID(10) | |||
| 498 | + | m.ToggleOverview() | |||
| 499 | + | if m.stackFront(w).kind != stackColumns { | |||
| 500 | + | t.Fatal("setup: columns not in front") | |||
| 501 | + | } | |||
| 502 | + | d.move(m, 0, -1) // columns: up is a band | |||
| 503 | + | d.move(m, -1, 0) | |||
| 504 | + | if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 { | |||
| 505 | + | t.Fatalf("left from the first column: front %+v", f) | |||
| 506 | + | } | |||
| 507 | + | if p := previewOf(t, m.Layout(), 90); p.Peek || !p.Focused { | |||
| 508 | + | t.Fatalf("float not in front: %+v", p) | |||
| 509 | + | } | |||
| 510 | + | d.move(m, 0, 1) // a float front takes no band move | |||
| 511 | + | if f := m.stackFront(w); f.kind != stackFloat { | |||
| 512 | + | t.Fatalf("down left the float: %+v", f) | |||
| 513 | + | } | |||
| 514 | + | d.move(m, 1, 0) | |||
| 515 | + | if m.stackFront(w).kind != stackColumns || m.Current() != w { | |||
| 516 | + | t.Fatalf("right from the float: front %+v", m.stackFront(w)) | |||
| 517 | + | } | |||
| 518 | + | ||||
| 519 | + | // The float in front of the columns: they fan to its left. | |||
| 520 | + | m.CancelOverview() | |||
| 521 | + | w.FocusID(90) | |||
| 522 | + | m.ToggleOverview() | |||
| 523 | + | if f := m.stackFront(w); f.kind != stackFloat { | |||
| 524 | + | t.Fatalf("setup: front %+v", f) | |||
| 525 | + | } | |||
| 526 | + | behind := previewOf(t, m.Layout(), 10) | |||
| 527 | + | if !behind.Peek { | |||
| 528 | + | t.Fatalf("columns not behind the float: %+v", behind) | |||
| 529 | + | } | |||
| 530 | + | d.move(m, -1, 0) | |||
| 531 | + | if m.stackFront(w).kind != stackColumns || m.Current() != w { | |||
| 532 | + | t.Fatalf("left from the front float: front %+v", m.stackFront(w)) | |||
| 533 | + | } | |||
| 534 | + | // The cards behind fan to the left: coming to the front, the | |||
| 535 | + | // columns move right by one peek step. | |||
| 536 | + | front := previewOf(t, m.Layout(), 10) | |||
| 537 | + | if front.Peek || front.Rect.X-behind.Rect.X != w.peekStep() { | |||
| 538 | + | t.Fatalf("columns behind %v, in front %v, peek step %d", behind.Rect, front.Rect, w.peekStep()) | |||
| 539 | + | } | |||
| 540 | + | d.move(m, 1, 0) | |||
| 541 | + | if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 { | |||
| 542 | + | t.Fatalf("right from the columns behind the float: front %+v", f) | |||
| 543 | + | } | |||
| 544 | + | }) | |||
| 545 | + | } | |||
| 546 | + | } | |||
| 547 | + | ||||
| 548 | + | // T10b: a pinned fullscreen column keeps its Fullscreen tile in the band. | |||
| 549 | + | func TestOverviewCascadePinnedFullscreenTile(t *testing.T) { | |||
| 550 | + | m := cascadeOverview() | |||
| 551 | + | w := m.Current() | |||
| 552 | + | w.FocusID(8) | |||
| 553 | + | m.ToggleFullscreen() | |||
| 554 | + | if !w.pinned() { | |||
| 555 | + | t.Fatal("setup: not pinned") | |||
| 556 | + | } | |||
| 557 | + | m.ToggleOverview() | |||
| 558 | + | ps := m.Layout() | |||
| 559 | + | fs := previewOf(t, ps, 8) | |||
| 560 | + | if !fs.Fullscreen || fs.Hidden { | |||
| 561 | + | t.Fatalf("fullscreen tile: %+v", fs) | |||
| 562 | + | } | |||
| 563 | + | for _, id := range []WindowID{7, 9} { | |||
| 564 | + | if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen || p.Peek != (fs.Peek) { | |||
| 565 | + | t.Fatalf("band tile %d: %+v", id, p) | |||
| 566 | + | } | |||
| 567 | + | } | |||
| 568 | + | u := w.overviewArea() | |||
| 569 | + | if !within1(centreY(fs.Rect), u.Y+u.H/2) { | |||
| 570 | + | t.Fatalf("fullscreen tile not on the centre lane: %+v", fs.Rect) | |||
| 571 | + | } | |||
| 572 | + | } | |||
| 573 | + | ||||
| 574 | + | // T11: the focus binds keep their meaning in a cascade overview. | |||
| 575 | + | func TestOverviewCascadeFocusBinds(t *testing.T) { | |||
| 576 | + | m := cascadeOverview() | |||
| 577 | + | w := m.Current() | |||
| 578 | + | m.ToggleOverview() | |||
| 579 | + | if !m.overviewFocus(ActionFocusWindowDown) || w.band(w.Focus) != 2 || m.Current() != w { | |||
| 580 | + | t.Fatalf("focus-window-down: band %d", w.band(w.Focus)) | |||
| 581 | + | } | |||
| 582 | + | if !m.overviewFocus(ActionFocusWindowUp) || w.band(w.Focus) != 1 { | |||
| 583 | + | t.Fatalf("focus-window-up: band %d", w.band(w.Focus)) | |||
| 584 | + | } | |||
| 585 | + | m.overviewFocus(ActionFocusWorkspaceNext) | |||
| 586 | + | if m.Current() != m.Workspaces[2] { | |||
| 587 | + | t.Fatal("focus-workspace-next did not change workspace") | |||
| 588 | + | } | |||
| 589 | + | m.overviewFocus(ActionFocusWorkspacePrev) | |||
| 590 | + | if m.Current() != w { | |||
| 591 | + | t.Fatal("focus-workspace-prev did not come back") | |||
| 592 | + | } | |||
| 593 | + | m.overviewFocus(ActionFocusWorkspacePrev) | |||
| 594 | + | if m.Current() != m.Workspaces[0] { | |||
| 595 | + | t.Fatal("focus-workspace-prev did not change workspace") | |||
| 596 | + | } | |||
| 597 | + | m.CancelOverview() | |||
| 598 | + | ||||
| 599 | + | // A selected stash card keeps the pile cycle. | |||
| 600 | + | withStash(m, 3) | |||
| 601 | + | w.FocusID(7) | |||
| 602 | + | m.ToggleOverview() | |||
| 603 | + | m.OverviewMove(-1, 0) | |||
| 604 | + | at := m.cardAt(w) | |||
| 605 | + | m.overviewFocus(ActionFocusWorkspacePrev) | |||
| 606 | + | if m.Current() != w || m.cardAt(w) != at-1 { | |||
| 607 | + | t.Fatalf("focus-workspace-prev over a card: card %d, was %d", m.cardAt(w), at) | |||
| 608 | + | } | |||
| 609 | + | m.overviewFocus(ActionFocusWorkspaceNext) | |||
| 610 | + | if m.Current() != w || m.cardAt(w) != at { | |||
| 611 | + | t.Fatalf("focus-workspace-next over a card: card %d", m.cardAt(w)) | |||
| 612 | + | } | |||
| 613 | + | } | |||
| 614 | + | ||||
| 615 | + | func TestOverviewMixedLayoutGestureAndCancel(t *testing.T) { | |||
| 616 | + | m := overviewMonitor() | |||
| 617 | + | m.SetOverflow(OverflowFixed) | |||
| 618 | + | m.Current().Overflow = OverflowCascade | |||
| 619 | + | from := m.Current() | |||
| 620 | + | m.ToggleOverview() | |||
| 621 | + | rows, _ := m.overviewRows() | |||
| 622 | + | if rows[m.Workspaces[1]].X <= rows[from].X { | |||
| 623 | + | t.Fatal("cascade neighbor not horizontal") | |||
| 624 | + | } | |||
| 625 | + | m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: overviewScrollStep}}) | |||
| 626 | + | if m.Current() == from { | |||
| 627 | + | t.Fatal("horizontal gesture did not change workspace") | |||
| 628 | + | } | |||
| 629 | + | if m.Current().Overflow != OverflowFixed { | |||
| 630 | + | t.Fatal("destination lost fixed policy") | |||
| 631 | + | } | |||
| 632 | + | if m.ov.scrollX != 0 || m.ov.scrollY != 0 || !m.ov.scrolled { | |||
| 633 | + | t.Fatalf("scroll state after the axis change: %+v", m.ov) | |||
| 634 | + | } | |||
| 635 | + | m.CancelOverview() | |||
| 636 | + | if m.Current() != from { | |||
| 637 | + | t.Fatal("cancel lost source workspace") | |||
| 638 | + | } | |||
| 639 | + | } | |||
| 640 | + | ||||
| 641 | + | // Showing a workspace with another overview axis drops the scroll leftovers | |||
| 642 | + | // of the old one. | |||
| 643 | + | func TestOverviewAxisChangeResetsScroll(t *testing.T) { | |||
| 644 | + | m := cascadeOverview() | |||
| 645 | + | m.ToggleOverview() | |||
| 646 | + | m.ov.scrollX, m.ov.scrollY, m.ov.sideways, m.ov.scrolled = 20, 20, true, true | |||
| 647 | + | m.showOverview(m.Workspaces[2]) | |||
| 648 | + | if m.ov.scrollX != 0 || m.ov.scrollY != 0 || m.ov.sideways || !m.ov.scrolled { | |||
| 649 | + | t.Fatalf("scroll kept or latch dropped: %+v", m.ov) | |||
| 650 | + | } | |||
| 651 | + | m.ov.scrollX = 20 | |||
| 652 | + | m.showOverview(m.Workspaces[0]) | |||
| 653 | + | if m.ov.scrollX != 20 { | |||
| 654 | + | t.Fatal("scroll reset between workspaces of the same axis") | |||
| 655 | + | } | |||
| 656 | + | } | |||
| 657 | + | ||||
| 658 | + | // A reload that changes the current workspace's axes drops the scroll state; | |||
| 659 | + | // one that does not, keeps it. | |||
| 660 | + | func TestOverviewReloadResetsScrollOnAxisChange(t *testing.T) { | |||
| 661 | + | c, _ := indicatorCore(t) | |||
| 662 | + | m := c.cur().mon | |||
| 663 | + | m.AddWindow(1) | |||
| 664 | + | m.ToggleOverview() | |||
| 665 | + | w := m.Current() | |||
| 666 | + | set := func() { m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 20, 10, true, true } | |||
| 667 | + | set() | |||
| 668 | + | cfg := c.cfg | |||
| 669 | + | cfg.Layout.Gaps++ | |||
| 670 | + | if err := c.apply(cfg); err != nil { | |||
| 671 | + | t.Fatal(err) | |||
| 672 | + | } | |||
| 673 | + | if m.ov.scrollX != 20 || !m.ov.scrolled { | |||
| 674 | + | t.Fatalf("unrelated reload reset the scroll: %+v", m.ov) | |||
| 675 | + | } | |||
| 676 | + | cfg.Layout.Overflow = string(OverflowCascade) | |||
| 677 | + | if err := c.apply(cfg); err != nil { | |||
| 678 | + | t.Fatal(err) | |||
| 679 | + | } | |||
| 680 | + | if m.Current().policy().workspace != horizontalAxis { | |||
| 681 | + | t.Fatal("setup: not cascade") | |||
| 682 | + | } | |||
| 683 | + | // The reload keeps the overview open on its selection, and cancelling | |||
| 684 | + | // still gives the original focus back. | |||
| 685 | + | if id, _ := w.Focused(); !m.ov.open || id != 1 { | |||
| 686 | + | t.Fatalf("reload lost the overview selection: open %v, focus %d", m.ov.open, id) | |||
| 687 | + | } | |||
| 688 | + | // The scroll is dropped, but not the latch that waits for the fingers | |||
| 689 | + | // to lift. | |||
| 690 | + | if m.ov.scrollX != 0 || m.ov.scrollY != 0 || !m.ov.scrolled || m.ov.sideways { | |||
| 691 | + | t.Fatalf("axis change kept the scroll or dropped the latch: %+v", m.ov) | |||
| 692 | + | } | |||
| 693 | + | set() | |||
| 694 | + | cfg.Layout.Overflow = string(OverflowFixed) | |||
| 695 | + | if err := c.apply(cfg); err != nil { | |||
| 696 | + | t.Fatal(err) | |||
| 697 | + | } | |||
| 698 | + | if m.ov.scrollX != 0 || !m.ov.scrolled { | |||
| 699 | + | t.Fatalf("workspace axis change kept the scroll or dropped the latch: %+v", m.ov) | |||
| 700 | + | } | |||
| 701 | + | m.CancelOverview() | |||
| 702 | + | if id, _ := w.Focused(); id != 1 { | |||
| 703 | + | t.Fatalf("cancel left the focus on %d", id) | |||
| 704 | + | } | |||
| 705 | + | } | |||
| 706 | + | ||||
| 707 | + | // The finger latch outlives an axis change: a gesture that turned the | |||
| 708 | + | // overview, then was turned back by a key, does not step again. | |||
| 709 | + | func TestOverviewFingerLatchSurvivesAxisChange(t *testing.T) { | |||
| 710 | + | c, m := cascadeCore(t) | |||
| 711 | + | w := m.Current() | |||
| 712 | + | m.ToggleOverview() | |||
| 713 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |||
| 714 | + | c.swipeUpdate(ports.SwipeUpdate{DX: 70}) | |||
| 715 | + | if m.Current() != m.Workspaces[2] || m.overviewAxis() != verticalAxis { | |||
| 716 | + | t.Fatalf("setup: workspace %d", indexOf(m.Workspaces, m.Current())) | |||
| 717 | + | } | |||
| 718 | + | // Back to the cascade workspace by a key, with the fingers still down. | |||
| 719 | + | m.overviewKey(ports.KeyEvent{Keysym: "k", Pressed: true}) | |||
| 720 | + | if m.Current() != w || m.overviewAxis() != horizontalAxis { | |||
| 721 | + | t.Fatal("setup: the key did not turn the overview back") | |||
| 722 | + | } | |||
| 723 | + | for _, u := range []ports.SwipeUpdate{{DY: 70}, {DX: 70}, {DY: -70}, {DX: -70}} { | |||
| 724 | + | c.swipeUpdate(u) | |||
| 725 | + | } | |||
| 726 | + | c.swipeEnd(ports.SwipeEnd{}) | |||
| 727 | + | if m.Current() != w || w.Focus != 0 { | |||
| 728 | + | t.Fatalf("the gesture stepped again: workspace %d, column %d", indexOf(m.Workspaces, m.Current()), w.Focus) | |||
| 729 | + | } | |||
| 730 | + | // The next gesture steps again. | |||
| 731 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |||
| 732 | + | c.swipeUpdate(ports.SwipeUpdate{DY: 70}) | |||
| 733 | + | c.swipeEnd(ports.SwipeEnd{}) | |||
| 734 | + | if w.band(w.Focus) != 1 { | |||
| 735 | + | t.Fatalf("the next gesture did not step: band %d", w.band(w.Focus)) | |||
| 736 | + | } | |||
| 737 | + | } | |||
| 738 | + | ||||
| 739 | + | // A stash-only cascade workspace has no columns to select: right from its | |||
| 740 | + | // pile goes straight to the next workspace. | |||
| 741 | + | func TestOverviewCascadeStashOnlyRight(t *testing.T) { | |||
| 742 | + | m := overviewMonitor() | |||
| 743 | + | m.Focus(1) | |||
| 744 | + | w := m.Current() | |||
| 745 | + | w.Overflow = OverflowCascade | |||
| 746 | + | w.ToggleWindowStash() | |||
| 747 | + | m.Focus(2) | |||
| 748 | + | m.AddWindow(30) | |||
| 749 | + | m.Focus(1) | |||
| 750 | + | m.ToggleOverview() | |||
| 751 | + | if len(w.Columns) != 0 || m.cardAt(w) < 0 { | |||
| 752 | + | t.Fatalf("setup: %d columns, card %d", len(w.Columns), m.cardAt(w)) | |||
| 753 | + | } | |||
| 754 | + | m.OverviewMove(1, 0) | |||
| 755 | + | if m.Current() != m.Workspaces[2] { | |||
| 756 | + | t.Fatalf("right from the pile: workspace %d", indexOf(m.Workspaces, m.Current())) | |||
| 757 | + | } | |||
| 758 | + | m.OverviewMove(0, -1) // the next workspace is a scroll one: up goes back | |||
| 759 | + | if m.Current() != w || m.cardAt(w) < 0 { | |||
| 760 | + | t.Fatal("setup: back on the stash-only workspace without its card") | |||
| 761 | + | } | |||
| 762 | + | m.OverviewMove(-1, 0) | |||
| 763 | + | if m.Current() != m.Workspaces[0] { | |||
| 764 | + | t.Fatalf("left from the pile: workspace %d", indexOf(m.Workspaces, m.Current())) | |||
| 765 | + | } | |||
| 766 | + | } | |||
| 767 | + | ||||
| 768 | + | // A cascade neighbor beside a cascade workspace draws its other lanes | |||
| 769 | + | // dimmed, its selected lane level with the centre lane. | |||
| 770 | + | func TestOverviewCascadeNeighborLanes(t *testing.T) { | |||
| 771 | + | m := cascadeOverview() | |||
| 772 | + | prev := m.Workspaces[0] | |||
| 773 | + | prev.Overflow = OverflowCascade | |||
| 774 | + | for id := WindowID(60); id <= 65; id++ { | |||
| 775 | + | prev.AddWindow(id) // bands {1,2,3} {60,61,62} {63,64,65}, 65 selected | |||
| 776 | + | } | |||
| 777 | + | m.ToggleOverview() | |||
| 778 | + | ps := m.Layout() | |||
| 779 | + | centre := centreY(previewOf(t, ps, 8).Rect) | |||
| 780 | + | sel := previewOf(t, ps, 65) | |||
| 781 | + | if sel.Hidden || !sel.Peek || sel.Focused || !within1(centreY(sel.Rect), centre) { | |||
| 782 | + | t.Fatalf("selected lane of the neighbor: %+v, centre lane %d", sel, centre) | |||
| 783 | + | } | |||
| 784 | + | above := previewOf(t, ps, 62) | |||
| 785 | + | if above.Hidden || !above.Peek || above.Rect.Y+above.Rect.H > sel.Rect.Y { | |||
| 786 | + | t.Fatalf("lane above in the neighbor: %+v, selected %v", above, sel.Rect) | |||
| 787 | + | } | |||
| 788 | + | if p := previewOf(t, ps, 3); !hiddenOff(p) { | |||
| 789 | + | t.Fatalf("lane two away in the neighbor: %+v", p) | |||
| 790 | + | } | |||
| 791 | + | } | |||
| 792 | + | ||||
| 793 | + | // A scroll neighbor wider than its slot scrolls inside it: what leaves the | |||
| 794 | + | // slot is hidden, so it never sits on the current slot. | |||
| 795 | + | func TestOverviewCascadeWideScrollNeighborIsClipped(t *testing.T) { | |||
| 796 | + | m := cascadeOverview() | |||
| 797 | + | prev := m.Workspaces[0] | |||
| 798 | + | for id := WindowID(60); id <= 67; id++ { | |||
| 799 | + | prev.AddWindow(id) | |||
| 800 | + | } | |||
| 801 | + | m.ToggleOverview() | |||
| 802 | + | rows, _ := m.overviewRows() | |||
| 803 | + | cur, slot := rows[m.Current()], rows[prev] | |||
| 804 | + | hidden, shown := 0, 0 | |||
| 805 | + | for _, p := range m.Layout() { | |||
| 806 | + | if !prev.has(p.ID) { | |||
| 807 | + | continue | |||
| 808 | + | } | |||
| 809 | + | if p.Hidden { | |||
| 810 | + | hidden++ | |||
| 811 | + | continue | |||
| 812 | + | } | |||
| 813 | + | shown++ | |||
| 814 | + | if p.Rect.X < slot.X || p.Rect.X+p.Rect.W > slot.X+slot.W || p.Rect.X+p.Rect.W > cur.X { | |||
| 815 | + | t.Fatalf("tile %d leaves its slot %v or reaches the current slot %v: %v", p.ID, slot, cur, p.Rect) | |||
| 816 | + | } | |||
| 817 | + | } | |||
| 818 | + | if hidden == 0 || shown == 0 { | |||
| 819 | + | t.Fatalf("setup: %d tiles shown, %d hidden", shown, hidden) | |||
| 820 | + | } | |||
| 821 | + | } | |||
| 822 | + | ||||
| 823 | + | // A wheel and a continuous scroll in a cascade overview: vertical changes | |||
| 824 | + | // workspace (and does not bounce back on a workspace with another axis), | |||
| 825 | + | // horizontal moves along the band. | |||
| 826 | + | func TestOverviewCascadeScroll(t *testing.T) { | |||
| 827 | + | vertical := ports.ScrollAxis{Set: true, Value: overviewScrollStep, V120: 120} | |||
| 828 | + | for _, source := range []ports.AxisSource{ports.AxisWheel, ports.AxisContinuous} { | |||
| 829 | + | t.Run(fmt.Sprint(source), func(t *testing.T) { | |||
| 830 | + | m := cascadeOverview() | |||
| 831 | + | w := m.Current() | |||
| 832 | + | m.ToggleOverview() | |||
| 833 | + | m.overviewScroll(ports.PointerAxis{Source: source, Vertical: vertical}) | |||
| 834 | + | if m.Current() != m.Workspaces[2] || w.band(w.Focus) != 1 || m.ov.scrollX != 0 || m.ov.scrollY != 0 { | |||
| 835 | + | t.Fatalf("vertical: workspace %d, band %d, scroll %v,%v", indexOf(m.Workspaces, m.Current()), w.band(w.Focus), m.ov.scrollX, m.ov.scrollY) | |||
| 836 | + | } | |||
| 837 | + | ||||
| 838 | + | m = cascadeOverview() | |||
| 839 | + | w = m.Current() | |||
| 840 | + | w.FocusID(7) | |||
| 841 | + | m.ToggleOverview() | |||
| 842 | + | m.overviewScroll(ports.PointerAxis{Source: source, Horizontal: vertical}) | |||
| 843 | + | if m.Current() != w || w.Columns[w.Focus].Windows[0] != 8 { | |||
| 844 | + | t.Fatalf("horizontal: column %d", w.Columns[w.Focus].Windows[0]) | |||
| 845 | + | } | |||
| 846 | + | ||||
| 847 | + | // Onto a workspace with another axis: one step, no bounce. | |||
| 848 | + | m = overviewMonitor() | |||
| 849 | + | m.SetOverflow(OverflowFixed) | |||
| 850 | + | m.Workspaces[1].Overflow = OverflowCascade | |||
| 851 | + | m.ToggleOverview() | |||
| 852 | + | m.overviewScroll(ports.PointerAxis{Source: source, Vertical: vertical}) | |||
| 853 | + | if m.Current() != m.Workspaces[1] || m.ov.scrollX != 0 || m.ov.scrollY != 0 { | |||
| 854 | + | t.Fatalf("mixed: workspace %d, scroll %v,%v", indexOf(m.Workspaces, m.Current()), m.ov.scrollX, m.ov.scrollY) | |||
| 855 | + | } | |||
| 856 | + | }) | |||
| 857 | + | } | |||
| 858 | + | } | |||
M internal/core/overview_stack.go +59 -24
| @@ -42,6 +42,35 @@ func (w *Workspace) columnOf(id WindowID) int { | |||
| 42 | 42 | // stashed one is its card in the pile. | |
| 43 | 43 | func (w *Workspace) stack() []stackItem { | |
| 44 | 44 | var items []stackItem | |
| 45 | + | w.walkStack(func(item stackItem) bool { | |
| 46 | + | items = append(items, item) | |
| 47 | + | return true | |
| 48 | + | }) | |
| 49 | + | return items | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // stacked reports whether the stack has several cards, without building it: | |
| 53 | + | // the render path asks for every workspace of every frame. | |
| 54 | + | func (w *Workspace) stacked() bool { | |
| 55 | + | _, n := w.stackHead() | |
| 56 | + | return n > 1 | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // stackHead is the first item of the stack and the number of items, counted | |
| 60 | + | // up to 2, without building the stack. | |
| 61 | + | func (w *Workspace) stackHead() (first stackItem, n int) { | |
| 62 | + | w.walkStack(func(item stackItem) bool { | |
| 63 | + | if n == 0 { | |
| 64 | + | first = item | |
| 65 | + | } | |
| 66 | + | n++ | |
| 67 | + | return n < 2 | |
| 68 | + | }) | |
| 69 | + | return first, n | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // walkStack yields the items of stack in order until yield returns false. | |
| 73 | + | func (w *Workspace) walkStack(yield func(stackItem) bool) { | |
| 45 | 74 | c, columns := WindowID(0), len(w.Columns) > 0 | |
| 46 | 75 | if w.pinned() { | |
| 47 | 76 | c = w.cover() | |
| @@ -49,33 +78,38 @@ func (w *Workspace) stack() []stackItem { | |||
| 49 | 78 | switch { | |
| 50 | 79 | case c == 0, w.stashIndex(c) >= 0: | |
| 51 | 80 | case w.floatIndex(c) >= 0: | |
| 52 | - | items = append(items, stackItem{stackFloat, c}) | |
| 81 | + | if !yield(stackItem{stackFloat, c}) { | |
| 82 | + | return | |
| 83 | + | } | |
| 53 | 84 | case w.overviewMaximized(): | |
| 54 | - | items = append(items, stackItem{stackColumn, c}) | |
| 85 | + | if !yield(stackItem{stackColumn, c}) { | |
| 86 | + | return | |
| 87 | + | } | |
| 55 | 88 | default: | |
| 56 | 89 | // One card of all columns, its own marked fullscreen. | |
| 57 | - | items = append(items, stackItem{kind: stackColumns}) | |
| 90 | + | if !yield(stackItem{kind: stackColumns}) { | |
| 91 | + | return | |
| 92 | + | } | |
| 58 | 93 | columns = false | |
| 59 | 94 | } | |
| 60 | 95 | for i := len(w.Floats) - 1; i >= 0; i-- { | |
| 61 | 96 | f := w.Floats[i] | |
| 62 | - | if !f.below && w.coversFloat(f) { | |
| 63 | - | items = append(items, stackItem{stackFloat, f.ID}) | |
| 97 | + | if !f.below && w.coversFloat(f) && !yield(stackItem{stackFloat, f.ID}) { | |
| 98 | + | return | |
| 64 | 99 | } | |
| 65 | 100 | } | |
| 66 | - | if (c == 0 || w.isFloat(c)) && w.overviewMaximized() { | |
| 67 | - | items = append(items, stackItem{stackColumn, w.Columns[w.Focus].Windows[0]}) | |
| 101 | + | if (c == 0 || w.isFloat(c)) && w.overviewMaximized() && !yield(stackItem{stackColumn, w.Columns[w.Focus].Windows[0]}) { | |
| 102 | + | return | |
| 68 | 103 | } | |
| 69 | - | if columns { | |
| 70 | - | items = append(items, stackItem{kind: stackColumns}) | |
| 104 | + | if columns && !yield(stackItem{kind: stackColumns}) { | |
| 105 | + | return | |
| 71 | 106 | } | |
| 72 | 107 | for i := len(w.Floats) - 1; i >= 0; i-- { | |
| 73 | 108 | f := w.Floats[i] | |
| 74 | - | if f.below && w.coversFloat(f) { | |
| 75 | - | items = append(items, stackItem{stackFloat, f.ID}) | |
| 109 | + | if f.below && w.coversFloat(f) && !yield(stackItem{stackFloat, f.ID}) { | |
| 110 | + | return | |
| 76 | 111 | } | |
| 77 | 112 | } | |
| 78 | - | return items | |
| 79 | 113 | } | |
| 80 | 114 | ||
| 81 | 115 | // itemOf resolves a clicked tile to the card currently containing it. | |
| @@ -234,10 +268,10 @@ func (w *Workspace) apply(item stackItem, selected WindowID) { | |||
| 234 | 268 | ||
| 235 | 269 | // previewItem draws a group using screen geometry and a single card at its | |
| 236 | 270 | // configured size. A prior maximized column retains its full-width buffer. | |
| 237 | - | func (m *Monitor) previewItem(w *Workspace, item stackItem, slot Rect, dim, lit bool) []Placement { | |
| 271 | + | func (m *Monitor) previewItem(w *Workspace, item stackItem, axis layoutAxis, slot Rect, dim, lit bool) []Placement { | |
| 238 | 272 | if item.kind == stackColumns { | |
| 239 | 273 | if !w.overviewMaximized() { | |
| 240 | - | return w.previewRow(slot, dim, lit) | |
| 274 | + | return w.previewRow(axis, slot, dim, lit) | |
| 241 | 275 | } | |
| 242 | 276 | // The hidden columns fill the screen as if the maximized column, | |
| 243 | 277 | // shown on its own card, were not there. In particular, an earlier | |
| @@ -260,20 +294,20 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, slot Rect, dim, lit | |||
| 260 | 294 | } | |
| 261 | 295 | sel := rects[selected] | |
| 262 | 296 | sel.X -= w.Usable.X | |
| 263 | - | return w.previewRowTiles(slot, dim, lit, tiles, w.Usable.W, sel) | |
| 297 | + | return w.previewRowTiles(axis, slot, dim, lit, false, tiles, w.Usable.W, sel) | |
| 264 | 298 | } | |
| 265 | 299 | g := w.gap() | |
| 266 | 300 | if item.kind == stackFloat && item.id == w.cover() { | |
| 267 | 301 | // A fullscreen float shows at the size of a fullscreen column. | |
| 268 | 302 | r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)} | |
| 269 | - | return w.previewRowTiles(slot, dim, lit, []Placement{{ID: item.id, Rect: r, Focused: true, Fullscreen: true}}, w.Usable.W, r) | |
| 303 | + | return w.previewRowTiles(axis, slot, dim, lit, false, []Placement{{ID: item.id, Rect: r, Focused: true, Fullscreen: true}}, w.Usable.W, r) | |
| 270 | 304 | } | |
| 271 | 305 | if item.kind == stackFloat { | |
| 272 | 306 | f := w.Floats[w.floatIndex(item.id)] | |
| 273 | 307 | r := w.floatRect(f) | |
| 274 | 308 | r.X -= w.Usable.X | |
| 275 | 309 | r.Y -= w.Usable.Y | |
| 276 | - | return w.previewRowTiles(slot, dim, lit, []Placement{{ID: item.id, Rect: r, Floating: true, Focused: true}}, w.Usable.W, r) | |
| 310 | + | return w.previewRowTiles(axis, slot, dim, lit, false, []Placement{{ID: item.id, Rect: r, Floating: true, Focused: true}}, w.Usable.W, r) | |
| 277 | 311 | } | |
| 278 | 312 | i := w.columnOf(item.id) | |
| 279 | 313 | if i < 0 { | |
| @@ -288,7 +322,7 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, slot Rect, dim, lit | |||
| 288 | 322 | for j, t := range w.rowBuf { | |
| 289 | 323 | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()}) | |
| 290 | 324 | } | |
| 291 | - | return w.previewRowTiles(slot, dim, lit, tiles, w.Usable.W, r) | |
| 325 | + | return w.previewRowTiles(axis, slot, dim, lit, false, tiles, w.Usable.W, r) | |
| 292 | 326 | } | |
| 293 | 327 | ||
| 294 | 328 | // hiddenColumnRects lays out the columns behind the maximized one with the | |
| @@ -304,13 +338,14 @@ func (w *Workspace) hiddenColumnRects() []Rect { | |||
| 304 | 338 | // stackRow draws up to two cards on each side of the provisional front, | |
| 305 | 339 | // fanned along axis. | |
| 306 | 340 | func (m *Monitor) stackRow(w *Workspace, axis layoutAxis, slot Rect, dim, lit bool) []Placement { | |
| 307 | - | items := w.stack() | |
| 308 | - | if len(items) == 0 { | |
| 309 | - | return w.previewRow(slot, dim, lit) | |
| 341 | + | first, n := w.stackHead() | |
| 342 | + | if n == 0 { | |
| 343 | + | return w.previewRow(axis, slot, dim, lit) | |
| 310 | 344 | } | |
| 311 | - | if len(items) == 1 { | |
| 312 | - | return m.previewItem(w, items[0], slot, dim, lit) | |
| 345 | + | if n == 1 { | |
| 346 | + | return m.previewItem(w, first, axis, slot, dim, lit) | |
| 313 | 347 | } | |
| 348 | + | items := w.stack() | |
| 314 | 349 | at := 0 | |
| 315 | 350 | if w == m.Current() { | |
| 316 | 351 | at = slices.Index(items, m.stackFront(w)) | |
| @@ -324,7 +359,7 @@ func (m *Monitor) stackRow(w *Workspace, axis layoutAxis, slot Rect, dim, lit bo | |||
| 324 | 359 | return r.X, r.Y | |
| 325 | 360 | } | |
| 326 | 361 | for i, item := range items { | |
| 327 | - | cards[i] = m.previewItem(w, item, w.frontSlot(axis, slot), dim, lit) | |
| 362 | + | cards[i] = m.previewItem(w, item, axis, w.frontSlot(axis, slot), dim, lit) | |
| 328 | 363 | } | |
| 329 | 364 | ||
| 330 | 365 | // fan's behind side advances through the linear stack. | |
| @@ -42,6 +42,35 @@ func (w *Workspace) columnOf(id WindowID) int { | |||||
| 42 | // stashed one is its card in the pile. | 42 | // stashed one is its card in the pile. | ||
| 43 | func (w *Workspace) stack() []stackItem { | 43 | func (w *Workspace) stack() []stackItem { | ||
| 44 | var items []stackItem | 44 | var items []stackItem | ||
| 45 | + | w.walkStack(func(item stackItem) bool { | |||
| 46 | + | items = append(items, item) | |||
| 47 | + | return true | |||
| 48 | + | }) | |||
| 49 | + | return items | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // stacked reports whether the stack has several cards, without building it: | |||
| 53 | + | // the render path asks for every workspace of every frame. | |||
| 54 | + | func (w *Workspace) stacked() bool { | |||
| 55 | + | _, n := w.stackHead() | |||
| 56 | + | return n > 1 | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // stackHead is the first item of the stack and the number of items, counted | |||
| 60 | + | // up to 2, without building the stack. | |||
| 61 | + | func (w *Workspace) stackHead() (first stackItem, n int) { | |||
| 62 | + | w.walkStack(func(item stackItem) bool { | |||
| 63 | + | if n == 0 { | |||
| 64 | + | first = item | |||
| 65 | + | } | |||
| 66 | + | n++ | |||
| 67 | + | return n < 2 | |||
| 68 | + | }) | |||
| 69 | + | return first, n | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // walkStack yields the items of stack in order until yield returns false. | |||
| 73 | + | func (w *Workspace) walkStack(yield func(stackItem) bool) { | |||
| 45 | c, columns := WindowID(0), len(w.Columns) > 0 | 74 | c, columns := WindowID(0), len(w.Columns) > 0 | ||
| 46 | if w.pinned() { | 75 | if w.pinned() { | ||
| 47 | c = w.cover() | 76 | c = w.cover() | ||
| @@ -49,33 +78,38 @@ func (w *Workspace) stack() []stackItem { | |||||
| 49 | switch { | 78 | switch { | ||
| 50 | case c == 0, w.stashIndex(c) >= 0: | 79 | case c == 0, w.stashIndex(c) >= 0: | ||
| 51 | case w.floatIndex(c) >= 0: | 80 | case w.floatIndex(c) >= 0: | ||
| 52 | - | items = append(items, stackItem{stackFloat, c}) | 81 | + | if !yield(stackItem{stackFloat, c}) { |
| 82 | + | return | |||
| 83 | + | } | |||
| 53 | case w.overviewMaximized(): | 84 | case w.overviewMaximized(): | ||
| 54 | - | items = append(items, stackItem{stackColumn, c}) | 85 | + | if !yield(stackItem{stackColumn, c}) { |
| 86 | + | return | |||
| 87 | + | } | |||
| 55 | default: | 88 | default: | ||
| 56 | // One card of all columns, its own marked fullscreen. | 89 | // One card of all columns, its own marked fullscreen. | ||
| 57 | - | items = append(items, stackItem{kind: stackColumns}) | 90 | + | if !yield(stackItem{kind: stackColumns}) { |
| 91 | + | return | |||
| 92 | + | } | |||
| 58 | columns = false | 93 | columns = false | ||
| 59 | } | 94 | } | ||
| 60 | for i := len(w.Floats) - 1; i >= 0; i-- { | 95 | for i := len(w.Floats) - 1; i >= 0; i-- { | ||
| 61 | f := w.Floats[i] | 96 | f := w.Floats[i] | ||
| 62 | - | if !f.below && w.coversFloat(f) { | 97 | + | if !f.below && w.coversFloat(f) && !yield(stackItem{stackFloat, f.ID}) { |
| 63 | - | items = append(items, stackItem{stackFloat, f.ID}) | 98 | + | return |
| 64 | } | 99 | } | ||
| 65 | } | 100 | } | ||
| 66 | - | if (c == 0 || w.isFloat(c)) && w.overviewMaximized() { | 101 | + | if (c == 0 || w.isFloat(c)) && w.overviewMaximized() && !yield(stackItem{stackColumn, w.Columns[w.Focus].Windows[0]}) { |
| 67 | - | items = append(items, stackItem{stackColumn, w.Columns[w.Focus].Windows[0]}) | 102 | + | return |
| 68 | } | 103 | } | ||
| 69 | - | if columns { | 104 | + | if columns && !yield(stackItem{kind: stackColumns}) { |
| 70 | - | items = append(items, stackItem{kind: stackColumns}) | 105 | + | return |
| 71 | } | 106 | } | ||
| 72 | for i := len(w.Floats) - 1; i >= 0; i-- { | 107 | for i := len(w.Floats) - 1; i >= 0; i-- { | ||
| 73 | f := w.Floats[i] | 108 | f := w.Floats[i] | ||
| 74 | - | if f.below && w.coversFloat(f) { | 109 | + | if f.below && w.coversFloat(f) && !yield(stackItem{stackFloat, f.ID}) { |
| 75 | - | items = append(items, stackItem{stackFloat, f.ID}) | 110 | + | return |
| 76 | } | 111 | } | ||
| 77 | } | 112 | } | ||
| 78 | - | return items | |||
| 79 | } | 113 | } | ||
| 80 | 114 | ||||
| 81 | // itemOf resolves a clicked tile to the card currently containing it. | 115 | // itemOf resolves a clicked tile to the card currently containing it. | ||
| @@ -234,10 +268,10 @@ func (w *Workspace) apply(item stackItem, selected WindowID) { | |||||
| 234 | 268 | ||||
| 235 | // previewItem draws a group using screen geometry and a single card at its | 269 | // previewItem draws a group using screen geometry and a single card at its | ||
| 236 | // configured size. A prior maximized column retains its full-width buffer. | 270 | // configured size. A prior maximized column retains its full-width buffer. | ||
| 237 | - | func (m *Monitor) previewItem(w *Workspace, item stackItem, slot Rect, dim, lit bool) []Placement { | 271 | + | func (m *Monitor) previewItem(w *Workspace, item stackItem, axis layoutAxis, slot Rect, dim, lit bool) []Placement { |
| 238 | if item.kind == stackColumns { | 272 | if item.kind == stackColumns { | ||
| 239 | if !w.overviewMaximized() { | 273 | if !w.overviewMaximized() { | ||
| 240 | - | return w.previewRow(slot, dim, lit) | 274 | + | return w.previewRow(axis, slot, dim, lit) |
| 241 | } | 275 | } | ||
| 242 | // The hidden columns fill the screen as if the maximized column, | 276 | // The hidden columns fill the screen as if the maximized column, | ||
| 243 | // shown on its own card, were not there. In particular, an earlier | 277 | // shown on its own card, were not there. In particular, an earlier | ||
| @@ -260,20 +294,20 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, slot Rect, dim, lit | |||||
| 260 | } | 294 | } | ||
| 261 | sel := rects[selected] | 295 | sel := rects[selected] | ||
| 262 | sel.X -= w.Usable.X | 296 | sel.X -= w.Usable.X | ||
| 263 | - | return w.previewRowTiles(slot, dim, lit, tiles, w.Usable.W, sel) | 297 | + | return w.previewRowTiles(axis, slot, dim, lit, false, tiles, w.Usable.W, sel) |
| 264 | } | 298 | } | ||
| 265 | g := w.gap() | 299 | g := w.gap() | ||
| 266 | if item.kind == stackFloat && item.id == w.cover() { | 300 | if item.kind == stackFloat && item.id == w.cover() { | ||
| 267 | // A fullscreen float shows at the size of a fullscreen column. | 301 | // A fullscreen float shows at the size of a fullscreen column. | ||
| 268 | r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)} | 302 | r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)} | ||
| 269 | - | return w.previewRowTiles(slot, dim, lit, []Placement{{ID: item.id, Rect: r, Focused: true, Fullscreen: true}}, w.Usable.W, r) | 303 | + | return w.previewRowTiles(axis, slot, dim, lit, false, []Placement{{ID: item.id, Rect: r, Focused: true, Fullscreen: true}}, w.Usable.W, r) |
| 270 | } | 304 | } | ||
| 271 | if item.kind == stackFloat { | 305 | if item.kind == stackFloat { | ||
| 272 | f := w.Floats[w.floatIndex(item.id)] | 306 | f := w.Floats[w.floatIndex(item.id)] | ||
| 273 | r := w.floatRect(f) | 307 | r := w.floatRect(f) | ||
| 274 | r.X -= w.Usable.X | 308 | r.X -= w.Usable.X | ||
| 275 | r.Y -= w.Usable.Y | 309 | r.Y -= w.Usable.Y | ||
| 276 | - | return w.previewRowTiles(slot, dim, lit, []Placement{{ID: item.id, Rect: r, Floating: true, Focused: true}}, w.Usable.W, r) | 310 | + | return w.previewRowTiles(axis, slot, dim, lit, false, []Placement{{ID: item.id, Rect: r, Floating: true, Focused: true}}, w.Usable.W, r) |
| 277 | } | 311 | } | ||
| 278 | i := w.columnOf(item.id) | 312 | i := w.columnOf(item.id) | ||
| 279 | if i < 0 { | 313 | if i < 0 { | ||
| @@ -288,7 +322,7 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, slot Rect, dim, lit | |||||
| 288 | for j, t := range w.rowBuf { | 322 | for j, t := range w.rowBuf { | ||
| 289 | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()}) | 323 | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()}) | ||
| 290 | } | 324 | } | ||
| 291 | - | return w.previewRowTiles(slot, dim, lit, tiles, w.Usable.W, r) | 325 | + | return w.previewRowTiles(axis, slot, dim, lit, false, tiles, w.Usable.W, r) |
| 292 | } | 326 | } | ||
| 293 | 327 | ||||
| 294 | // hiddenColumnRects lays out the columns behind the maximized one with the | 328 | // hiddenColumnRects lays out the columns behind the maximized one with the | ||
| @@ -304,13 +338,14 @@ func (w *Workspace) hiddenColumnRects() []Rect { | |||||
| 304 | // stackRow draws up to two cards on each side of the provisional front, | 338 | // stackRow draws up to two cards on each side of the provisional front, | ||
| 305 | // fanned along axis. | 339 | // fanned along axis. | ||
| 306 | func (m *Monitor) stackRow(w *Workspace, axis layoutAxis, slot Rect, dim, lit bool) []Placement { | 340 | func (m *Monitor) stackRow(w *Workspace, axis layoutAxis, slot Rect, dim, lit bool) []Placement { | ||
| 307 | - | items := w.stack() | 341 | + | first, n := w.stackHead() |
| 308 | - | if len(items) == 0 { | 342 | + | if n == 0 { |
| 309 | - | return w.previewRow(slot, dim, lit) | 343 | + | return w.previewRow(axis, slot, dim, lit) |
| 310 | } | 344 | } | ||
| 311 | - | if len(items) == 1 { | 345 | + | if n == 1 { |
| 312 | - | return m.previewItem(w, items[0], slot, dim, lit) | 346 | + | return m.previewItem(w, first, axis, slot, dim, lit) |
| 313 | } | 347 | } | ||
| 348 | + | items := w.stack() | |||
| 314 | at := 0 | 349 | at := 0 | ||
| 315 | if w == m.Current() { | 350 | if w == m.Current() { | ||
| 316 | at = slices.Index(items, m.stackFront(w)) | 351 | at = slices.Index(items, m.stackFront(w)) | ||
| @@ -324,7 +359,7 @@ func (m *Monitor) stackRow(w *Workspace, axis layoutAxis, slot Rect, dim, lit bo | |||||
| 324 | return r.X, r.Y | 359 | return r.X, r.Y | ||
| 325 | } | 360 | } | ||
| 326 | for i, item := range items { | 361 | for i, item := range items { | ||
| 327 | - | cards[i] = m.previewItem(w, item, w.frontSlot(axis, slot), dim, lit) | 362 | + | cards[i] = m.previewItem(w, item, axis, w.frontSlot(axis, slot), dim, lit) |
| 328 | } | 363 | } | ||
| 329 | 364 | ||||
| 330 | // fan's behind side advances through the linear stack. | 365 | // fan's behind side advances through the linear stack. | ||
M internal/core/publish_alloc_test.go +55 -19
| @@ -187,26 +187,62 @@ func TestPublishStashSlideAllocations(t *testing.T) { | |||
| 187 | 187 | ||
| 188 | 188 | // TestPublishOverviewAllocations pins the cost of publishing while the | |
| 189 | 189 | // overview opens: its cards fly from the full rects (scale and fade motions | |
| 190 | - | // on every card), which guards the reuse of Core.overviewReal. | |
| 190 | + | // on every card), which guards the reuse of Core.overviewReal. The current | |
| 191 | + | // workspace is scroll or cascade, alone or between two workspaces with | |
| 192 | + | // windows (their slots and stacks are laid out every frame too). | |
| 191 | 193 | func TestPublishOverviewAllocations(t *testing.T) { | |
| 192 | - | c := publishRig(t, 1) | |
| 193 | - | sc := c.cur() | |
| 194 | - | now := time.Now() | |
| 195 | - | shots := c.snapshot(now) | |
| 196 | - | sc.mon.ToggleOverview() | |
| 197 | - | c.transition(shots, now) | |
| 198 | - | step := publishStep(t, c, nil) | |
| 199 | - | step() | |
| 200 | - | if !sc.mon.ov.open || len(c.overviewReal) != 6 || len(sc.rects) == 0 || !c.animating() { | |
| 201 | - | t.Fatalf("setup: overview %v, real layouts %d, rects %d, animating %v", sc.mon.ov.open, len(c.overviewReal), len(sc.rects), c.animating()) | |
| 202 | - | } | |
| 203 | - | n := testing.AllocsPerRun(50, step) | |
| 204 | - | t.Logf("overview allocs per step = %v", n) | |
| 205 | - | if len(sc.rects) == 0 { | |
| 206 | - | t.Fatal("card motions settled during the measure") | |
| 207 | - | } | |
| 208 | - | if n > publishOverviewAllocBudget { | |
| 209 | - | t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget) | |
| 194 | + | for _, overflow := range []Overflow{OverflowScroll, OverflowCascade} { | |
| 195 | + | for _, neighbors := range []bool{false, true} { | |
| 196 | + | name := string(overflow) + "/alone" | |
| 197 | + | if neighbors { | |
| 198 | + | name = string(overflow) + "/neighbors" | |
| 199 | + | } | |
| 200 | + | t.Run(name, func(t *testing.T) { | |
| 201 | + | c := publishRig(t, 1) | |
| 202 | + | sc := c.cur() | |
| 203 | + | m := sc.mon | |
| 204 | + | if neighbors { | |
| 205 | + | // Windows 1..6 are on workspace 0: move to 1 with a | |
| 206 | + | // workspace on each side. | |
| 207 | + | m.Focus(1) | |
| 208 | + | for id := WindowID(10); id < 16; id++ { | |
| 209 | + | m.AddWindow(id) | |
| 210 | + | } | |
| 211 | + | m.Focus(2) | |
| 212 | + | for id := WindowID(20); id < 26; id++ { | |
| 213 | + | m.AddWindow(id) | |
| 214 | + | } | |
| 215 | + | m.Focus(1) | |
| 216 | + | } | |
| 217 | + | m.Current().Overflow = overflow | |
| 218 | + | sc.arrange() | |
| 219 | + | c.refreshShown() | |
| 220 | + | now := time.Now() | |
| 221 | + | shots := c.snapshot(now) | |
| 222 | + | m.ToggleOverview() | |
| 223 | + | c.transition(shots, now) | |
| 224 | + | step := publishStep(t, c, nil) | |
| 225 | + | step() | |
| 226 | + | want, slots := 6, 1 | |
| 227 | + | if neighbors { | |
| 228 | + | want, slots = 18, 3 | |
| 229 | + | } | |
| 230 | + | if rows, _ := m.overviewRows(); len(rows) != slots { | |
| 231 | + | t.Fatalf("setup: %d overview slots, want %d", len(rows), slots) | |
| 232 | + | } | |
| 233 | + | if !m.ov.open || m.overviewAxis() != m.Current().policy().workspace || len(c.overviewReal) != want || len(sc.rects) == 0 || !c.animating() { | |
| 234 | + | t.Fatalf("setup: overview %v, real layouts %d (want %d), rects %d, animating %v", m.ov.open, len(c.overviewReal), want, len(sc.rects), c.animating()) | |
| 235 | + | } | |
| 236 | + | n := testing.AllocsPerRun(50, step) | |
| 237 | + | t.Logf("%s overview allocs per step = %v", name, n) | |
| 238 | + | if len(sc.rects) == 0 { | |
| 239 | + | t.Fatal("card motions settled during the measure") | |
| 240 | + | } | |
| 241 | + | if n > publishOverviewAllocBudget { | |
| 242 | + | t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget) | |
| 243 | + | } | |
| 244 | + | }) | |
| 245 | + | } | |
| 210 | 246 | } | |
| 211 | 247 | } | |
| 212 | 248 | ||
| @@ -187,26 +187,62 @@ func TestPublishStashSlideAllocations(t *testing.T) { | |||||
| 187 | 187 | ||||
| 188 | // TestPublishOverviewAllocations pins the cost of publishing while the | 188 | // TestPublishOverviewAllocations pins the cost of publishing while the | ||
| 189 | // overview opens: its cards fly from the full rects (scale and fade motions | 189 | // overview opens: its cards fly from the full rects (scale and fade motions | ||
| 190 | - | // on every card), which guards the reuse of Core.overviewReal. | 190 | + | // on every card), which guards the reuse of Core.overviewReal. The current |
| 191 | + | // workspace is scroll or cascade, alone or between two workspaces with | |||
| 192 | + | // windows (their slots and stacks are laid out every frame too). | |||
| 191 | func TestPublishOverviewAllocations(t *testing.T) { | 193 | func TestPublishOverviewAllocations(t *testing.T) { | ||
| 192 | - | c := publishRig(t, 1) | 194 | + | for _, overflow := range []Overflow{OverflowScroll, OverflowCascade} { |
| 193 | - | sc := c.cur() | 195 | + | for _, neighbors := range []bool{false, true} { |
| 194 | - | now := time.Now() | 196 | + | name := string(overflow) + "/alone" |
| 195 | - | shots := c.snapshot(now) | 197 | + | if neighbors { |
| 196 | - | sc.mon.ToggleOverview() | 198 | + | name = string(overflow) + "/neighbors" |
| 197 | - | c.transition(shots, now) | 199 | + | } |
| 198 | - | step := publishStep(t, c, nil) | 200 | + | t.Run(name, func(t *testing.T) { |
| 199 | - | step() | 201 | + | c := publishRig(t, 1) |
| 200 | - | if !sc.mon.ov.open || len(c.overviewReal) != 6 || len(sc.rects) == 0 || !c.animating() { | 202 | + | sc := c.cur() |
| 201 | - | t.Fatalf("setup: overview %v, real layouts %d, rects %d, animating %v", sc.mon.ov.open, len(c.overviewReal), len(sc.rects), c.animating()) | 203 | + | m := sc.mon |
| 202 | - | } | 204 | + | if neighbors { |
| 203 | - | n := testing.AllocsPerRun(50, step) | 205 | + | // Windows 1..6 are on workspace 0: move to 1 with a |
| 204 | - | t.Logf("overview allocs per step = %v", n) | 206 | + | // workspace on each side. |
| 205 | - | if len(sc.rects) == 0 { | 207 | + | m.Focus(1) |
| 206 | - | t.Fatal("card motions settled during the measure") | 208 | + | for id := WindowID(10); id < 16; id++ { |
| 207 | - | } | 209 | + | m.AddWindow(id) |
| 208 | - | if n > publishOverviewAllocBudget { | 210 | + | } |
| 209 | - | t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget) | 211 | + | m.Focus(2) |
| 212 | + | for id := WindowID(20); id < 26; id++ { | |||
| 213 | + | m.AddWindow(id) | |||
| 214 | + | } | |||
| 215 | + | m.Focus(1) | |||
| 216 | + | } | |||
| 217 | + | m.Current().Overflow = overflow | |||
| 218 | + | sc.arrange() | |||
| 219 | + | c.refreshShown() | |||
| 220 | + | now := time.Now() | |||
| 221 | + | shots := c.snapshot(now) | |||
| 222 | + | m.ToggleOverview() | |||
| 223 | + | c.transition(shots, now) | |||
| 224 | + | step := publishStep(t, c, nil) | |||
| 225 | + | step() | |||
| 226 | + | want, slots := 6, 1 | |||
| 227 | + | if neighbors { | |||
| 228 | + | want, slots = 18, 3 | |||
| 229 | + | } | |||
| 230 | + | if rows, _ := m.overviewRows(); len(rows) != slots { | |||
| 231 | + | t.Fatalf("setup: %d overview slots, want %d", len(rows), slots) | |||
| 232 | + | } | |||
| 233 | + | if !m.ov.open || m.overviewAxis() != m.Current().policy().workspace || len(c.overviewReal) != want || len(sc.rects) == 0 || !c.animating() { | |||
| 234 | + | t.Fatalf("setup: overview %v, real layouts %d (want %d), rects %d, animating %v", m.ov.open, len(c.overviewReal), want, len(sc.rects), c.animating()) | |||
| 235 | + | } | |||
| 236 | + | n := testing.AllocsPerRun(50, step) | |||
| 237 | + | t.Logf("%s overview allocs per step = %v", name, n) | |||
| 238 | + | if len(sc.rects) == 0 { | |||
| 239 | + | t.Fatal("card motions settled during the measure") | |||
| 240 | + | } | |||
| 241 | + | if n > publishOverviewAllocBudget { | |||
| 242 | + | t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget) | |||
| 243 | + | } | |||
| 244 | + | }) | |||
| 245 | + | } | |||
| 210 | } | 246 | } | ||
| 211 | } | 247 | } | ||
| 212 | 248 | ||||