feat(overview): introduce two-dimensional cascade previews
- commit
- d53d2a464bf2b38a73cc903705364fc146b04461
- author
- brice · · 2026-10-06 09:45 UTC
- parent
- c6b6c21
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +178 -26
M docs/config.md +2 -2
| @@ -196,7 +196,7 @@ Left/right focus follows column order across band boundaries. Up/down visits sta | |||
| 196 | 196 | ||
| 197 | 197 | Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-up/down` commands still mean previous/next workspace, even when the transition is horizontal. Four-finger and stash gestures keep their existing behaviour. | |
| 198 | 198 | ||
| 199 | - | The overview presents bands side by side in one workspace card. Selecting a window reveals its band. Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag. | |
| 199 | + | The overview presents bands vertically in one workspace card, with workspace neighbors arranged horizontally. Long previews center on the selected band. The stash keeps its smaller, independently scaled pile on the left. Selecting a window reveals its band. Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag. | |
| 200 | 200 | ||
| 201 | 201 | ### Actions | |
| 202 | 202 | ||
| @@ -257,7 +257,7 @@ The drop can be on another monitor; focus follows the window. There, the middle | |||
| 257 | 257 | ||
| 258 | 258 | ### Overview | |
| 259 | 259 | ||
| 260 | - | `toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows, over a background darkened by `floating.dim`. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Each monitor has one vertical overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with horizontal rules separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear above and below; rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal workspace up/down navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen. | |
| 260 | + | `toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows, over a background darkened by `floating.dim`. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Each monitor has one overview; workspace neighbors are vertical in scroll/fixed and horizontal when the current workspace uses Cascade. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with horizontal rules separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors follow its workspace-navigation axis; stash cards retain their own scale. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal workspace up/down navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen. | |
| 261 | 261 | ||
| 262 | 262 | | Key | Action | | |
| 263 | 263 | | --- | --- | | |
| @@ -196,7 +196,7 @@ Left/right focus follows column order across band boundaries. Up/down visits sta | |||||
| 196 | 196 | ||||
| 197 | Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-up/down` commands still mean previous/next workspace, even when the transition is horizontal. Four-finger and stash gestures keep their existing behaviour. | 197 | Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-up/down` commands still mean previous/next workspace, even when the transition is horizontal. Four-finger and stash gestures keep their existing behaviour. | ||
| 198 | 198 | ||||
| 199 | - | The overview presents bands side by side in one workspace card. Selecting a window reveals its band. Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag. | 199 | + | The overview presents bands vertically in one workspace card, with workspace neighbors arranged horizontally. Long previews center on the selected band. The stash keeps its smaller, independently scaled pile on the left. Selecting a window reveals its band. Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag. |
| 200 | 200 | ||||
| 201 | ### Actions | 201 | ### Actions | ||
| 202 | 202 | ||||
| @@ -257,7 +257,7 @@ The drop can be on another monitor; focus follows the window. There, the middle | |||||
| 257 | 257 | ||||
| 258 | ### Overview | 258 | ### Overview | ||
| 259 | 259 | ||||
| 260 | - | `toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows, over a background darkened by `floating.dim`. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Each monitor has one vertical overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with horizontal rules separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear above and below; rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal workspace up/down navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen. | 260 | + | `toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows, over a background darkened by `floating.dim`. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Each monitor has one overview; workspace neighbors are vertical in scroll/fixed and horizontal when the current workspace uses Cascade. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with horizontal rules separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors follow its workspace-navigation axis; stash cards retain their own scale. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal workspace up/down navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen. |
| 261 | 261 | ||||
| 262 | | Key | Action | | 262 | | Key | Action | | ||
| 263 | | --- | --- | | 263 | | --- | --- | | ||
M internal/core/core.go +6 -0
| @@ -287,6 +287,12 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 287 | 287 | if err != nil { | |
| 288 | 288 | return err | |
| 289 | 289 | } | |
| 290 | + | // A successful configuration update starts a fresh overview gesture. | |
| 291 | + | // Selection and opening snapshots remain identity-based across layouts. | |
| 292 | + | for _, s := range c.screens { | |
| 293 | + | s.mon.ov.scrollX, s.mon.ov.scrollY = 0, 0 | |
| 294 | + | s.mon.ov.scrolled, s.mon.ov.sideways = false, false | |
| 295 | + | } | |
| 290 | 296 | c.cfg = cfg | |
| 291 | 297 | c.binds = binds | |
| 292 | 298 | c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey] | |
| @@ -287,6 +287,12 @@ 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 | + | // A successful configuration update starts a fresh overview gesture. | |||
| 291 | + | // Selection and opening snapshots remain identity-based across layouts. | |||
| 292 | + | for _, s := range c.screens { | |||
| 293 | + | s.mon.ov.scrollX, s.mon.ov.scrollY = 0, 0 | |||
| 294 | + | s.mon.ov.scrolled, s.mon.ov.sideways = false, false | |||
| 295 | + | } | |||
| 290 | c.cfg = cfg | 296 | c.cfg = cfg | ||
| 291 | c.binds = binds | 297 | c.binds = binds | ||
| 292 | c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey] | 298 | c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey] | ||
M internal/core/overview.go +90 -21
| @@ -353,6 +353,9 @@ func (m *Monitor) overviewWorkspaces() []*Workspace { | |||
| 353 | 353 | // a named group uses its invocation anchor as the toggle return target, not | |
| 354 | 354 | // rows merely browsed in the overview. | |
| 355 | 355 | func (m *Monitor) showOverview(w *Workspace) { | |
| 356 | + | if w.policy().workspace != m.Current().policy().workspace { | |
| 357 | + | m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 0, 0, false, false | |
| 358 | + | } | |
| 356 | 359 | if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) { | |
| 357 | 360 | m.back = m.Current() | |
| 358 | 361 | if indexOf(m.Workspaces, w.overviewAfter) >= 0 { | |
| @@ -490,13 +493,13 @@ func (m *Monitor) OverviewPick(id WindowID) { | |||
| 490 | 493 | // overviewRows centers the selected row with its neighbors above and below. | |
| 491 | 494 | // The numbered/named boundary gets extra spacing and an inactive horizontal | |
| 492 | 495 | // rule. Row widths, stash piles and preview scales remain unchanged. | |
| 493 | - | func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) { | |
| 496 | + | func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) { | |
| 494 | 497 | cur := m.Current() | |
| 495 | 498 | u := cur.overviewArea() | |
| 496 | 499 | gap := max(1, u.H*3/100) | |
| 497 | 500 | hc := cur.rowHeight() | |
| 498 | 501 | y := u.Y + (u.H-hc)/2 | |
| 499 | - | rows := map[*Workspace]int{cur: y} | |
| 502 | + | rows := map[*Workspace]Rect{cur: {Y: y}} | |
| 500 | 503 | var dividers []ports.Separator | |
| 501 | 504 | list := m.overviewWorkspaces() | |
| 502 | 505 | i := indexOf(list, cur) | |
| @@ -512,14 +515,29 @@ func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) { | |||
| 512 | 515 | } | |
| 513 | 516 | top := y + hc | |
| 514 | 517 | if next < i { | |
| 515 | - | rows[w] = y - spacing - w.rowHeight() | |
| 518 | + | rows[w] = Rect{Y: y - spacing - w.rowHeight()} | |
| 516 | 519 | top = y - spacing | |
| 517 | 520 | } else { | |
| 518 | - | rows[w] = y + hc + spacing | |
| 521 | + | rows[w] = Rect{Y: y + hc + spacing} | |
| 522 | + | } | |
| 523 | + | if cur.policy().workspace == horizontalAxis { | |
| 524 | + | dx := u.W + spacing | |
| 525 | + | if next < i { | |
| 526 | + | dx = -dx | |
| 527 | + | } | |
| 528 | + | rows[w] = Rect{X: dx, Y: u.Y + (u.H-w.rowHeight())/2} | |
| 519 | 529 | } | |
| 520 | 530 | if boundary { | |
| 521 | 531 | margin := u.W * 4 / 100 | |
| 522 | - | dividers = append(dividers, ports.Separator{Rect: Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1}}) | |
| 532 | + | r := Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1} | |
| 533 | + | if cur.policy().workspace == horizontalAxis { | |
| 534 | + | x := u.X + u.W + spacing/2 | |
| 535 | + | if next < i { | |
| 536 | + | x = u.X - spacing/2 | |
| 537 | + | } | |
| 538 | + | r = Rect{X: x, Y: u.Y + u.H*4/100, W: 1, H: u.H * 92 / 100} | |
| 539 | + | } | |
| 540 | + | dividers = append(dividers, ports.Separator{Rect: r}) | |
| 523 | 541 | } | |
| 524 | 542 | } | |
| 525 | 543 | return rows, dividers | |
| @@ -535,8 +553,14 @@ func (m *Monitor) overviewLayout(result []Placement) []Placement { | |||
| 535 | 553 | ry, shown := rows[w] | |
| 536 | 554 | if shown { | |
| 537 | 555 | at := m.cardAt(w) | |
| 538 | - | result = append(result, m.stackRow(w, ry, w != cur, at < 0)...) | |
| 539 | - | result = append(result, w.pile(ry, w != cur, at)...) | |
| 556 | + | start := len(result) | |
| 557 | + | result = append(result, m.stackRow(w, ry.Y, w != cur, at < 0)...) | |
| 558 | + | result = append(result, w.pile(ry.Y, w != cur, at)...) | |
| 559 | + | for i := start; i < len(result); i++ { | |
| 560 | + | if !result[i].Hidden { | |
| 561 | + | result[i].Rect.X += ry.X | |
| 562 | + | } | |
| 563 | + | } | |
| 540 | 564 | } | |
| 541 | 565 | for _, f := range w.Floats { | |
| 542 | 566 | if _, drawn := w.itemOf(f.ID); !shown || !drawn { | |
| @@ -656,6 +680,12 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement { | |||
| 656 | 680 | cw := int(math.Round(float64(r.W) * overviewCardZoom)) | |
| 657 | 681 | ch := int(math.Round(float64(r.H) * overviewCardZoom)) | |
| 658 | 682 | rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom())) | |
| 683 | + | if w.policy().wraps { | |
| 684 | + | rowH = w.rowHeight() - 4*w.peekStep() | |
| 685 | + | if len(w.stack()) <= 1 { | |
| 686 | + | rowH = w.rowHeight() | |
| 687 | + | } | |
| 688 | + | } | |
| 659 | 689 | // Stack rows reserve two peek steps above the front card. Align the | |
| 660 | 690 | // stash with that front band, not with the top of its envelope. | |
| 661 | 691 | x0, y0 := u.X+u.W/100, w.frontRowY(y)+(rowH-ch)/2 | |
| @@ -673,13 +703,18 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement { | |||
| 673 | 703 | // width, within the overview bounds. | |
| 674 | 704 | func (w *Workspace) overviewZoom() float64 { | |
| 675 | 705 | // Only the span is read: the tiles go in the zoom scratch, dead on return. | |
| 676 | - | var span int | |
| 677 | - | w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0]) | |
| 706 | + | var preview overviewPreview | |
| 707 | + | w.zoomBuf, preview = w.previewTilesInto(w.zoomBuf[:0]) | |
| 708 | + | span := preview.bounds.W | |
| 678 | 709 | if span <= 0 { | |
| 679 | 710 | return overviewMaxZoom | |
| 680 | 711 | } | |
| 681 | 712 | room := float64(w.overviewArea().W-w.pileWidth()) * 0.96 | |
| 682 | - | z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom) | |
| 713 | + | fit := room / float64(span) | |
| 714 | + | if w.policy().wraps && preview.bounds.H > 0 { | |
| 715 | + | fit = min(fit, float64(w.overviewArea().H)*0.8/float64(preview.bounds.H)) | |
| 716 | + | } | |
| 717 | + | z := min(max(fit, overviewMinZoom), overviewMaxZoom) | |
| 683 | 718 | if len(w.stack()) > 1 { | |
| 684 | 719 | // Reserve two clear peeks on either side of the front row. | |
| 685 | 720 | z = min(z, overviewMaxZoom/(1+4*overviewPeekStep)) | |
| @@ -704,7 +739,15 @@ func (w *Workspace) frontRowY(y int) int { | |||
| 704 | 739 | // rowHeight includes the largest possible stack envelope (two peeks above | |
| 705 | 740 | // and two below). Neighbor rows use the same measure as the current row. | |
| 706 | 741 | func (w *Workspace) rowHeight() int { | |
| 707 | - | h := int(math.Round(float64(w.Usable.H) * w.overviewZoom())) | |
| 742 | + | height := w.Usable.H | |
| 743 | + | if w.policy().wraps { | |
| 744 | + | n, _ := w.cascadeBands() | |
| 745 | + | height *= n | |
| 746 | + | } | |
| 747 | + | h := int(math.Round(float64(height) * w.overviewZoom())) | |
| 748 | + | if w.policy().wraps { | |
| 749 | + | h = min(h, w.overviewArea().H*8/10) | |
| 750 | + | } | |
| 708 | 751 | if len(w.stack()) > 1 { | |
| 709 | 752 | h += 4 * w.peekStep() | |
| 710 | 753 | } | |
| @@ -714,28 +757,33 @@ func (w *Workspace) rowHeight() int { | |||
| 714 | 757 | // previewTiles lays tiles out unscrolled, relative to the usable area's | |
| 715 | 758 | // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses | |
| 716 | 759 | // an unscrolled row. span is the row's width; sel is the focused column. | |
| 717 | - | func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) { | |
| 760 | + | // overviewPreview describes unscaled content independently of its presentation. | |
| 761 | + | type overviewPreview struct { | |
| 762 | + | bounds Rect | |
| 763 | + | selected Rect | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | func (w *Workspace) previewTiles() ([]Placement, overviewPreview) { | |
| 718 | 767 | return w.previewTilesInto(make([]Placement, 0, w.columnWindows())) | |
| 719 | 768 | } | |
| 720 | 769 | ||
| 721 | 770 | // previewTilesInto is previewTiles appending to tiles (dst[:0] to reuse its | |
| 722 | 771 | // storage: the result then aliases it). The column rects use w.colBuf, dead | |
| 723 | 772 | // when it returns. | |
| 724 | - | func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) { | |
| 725 | - | var span int | |
| 726 | - | var sel Rect | |
| 773 | + | func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, overviewPreview) { | |
| 774 | + | span, sel := 0, Rect{} | |
| 727 | 775 | if w.pinned() { | |
| 728 | 776 | c := w.cover() | |
| 729 | 777 | // The row shows the columns a pinned fullscreen window hides, | |
| 730 | 778 | // laid out as without it; its own tile stays marked fullscreen. | |
| 731 | 779 | v := *w | |
| 732 | 780 | v.fullscreen = 0 | |
| 733 | - | tiles, span, sel = v.previewTilesInto(tiles) | |
| 781 | + | tiles, preview := v.previewTilesInto(tiles) | |
| 734 | 782 | w.colBuf, w.rowBuf = v.colBuf, v.rowBuf | |
| 735 | 783 | for i := range tiles { | |
| 736 | 784 | tiles[i].Fullscreen = tiles[i].ID == c | |
| 737 | 785 | } | |
| 738 | - | return tiles, span, sel | |
| 786 | + | return tiles, preview | |
| 739 | 787 | } | |
| 740 | 788 | g := w.gap() | |
| 741 | 789 | h := max(w.Usable.H-2*g, 0) | |
| @@ -750,7 +798,8 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||
| 750 | 798 | r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h} | |
| 751 | 799 | if w.policy().wraps { | |
| 752 | 800 | width := w.cellWidth() | |
| 753 | - | r.X = w.band(i)*w.Usable.W + w.cellX(i) | |
| 801 | + | r.X = w.cellX(i) | |
| 802 | + | r.Y = g + w.band(i)*w.Usable.H | |
| 754 | 803 | r.W = width | |
| 755 | 804 | } else if w.Overflow == OverflowFixed { | |
| 756 | 805 | r = rects[i] | |
| @@ -779,7 +828,12 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||
| 779 | 828 | sel = r | |
| 780 | 829 | } | |
| 781 | 830 | } | |
| 782 | - | return tiles, span, sel | |
| 831 | + | bounds := Rect{W: span, H: w.Usable.H} | |
| 832 | + | if w.policy().wraps { | |
| 833 | + | n, _ := w.cascadeBands() | |
| 834 | + | bounds.W, bounds.H = w.Usable.W, n*w.Usable.H | |
| 835 | + | } | |
| 836 | + | return tiles, overviewPreview{bounds: bounds, selected: sel} | |
| 783 | 837 | } | |
| 784 | 838 | ||
| 785 | 839 | // previewRow scales the tiles of w into a row whose top is at y, centred | |
| @@ -788,11 +842,16 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||
| 788 | 842 | // workspaces) have no focus; neither has a row whose pile has it (lit | |
| 789 | 843 | // false). | |
| 790 | 844 | func (w *Workspace) previewRow(y int, dim, lit bool) []Placement { | |
| 791 | - | tiles, span, sel := w.previewTiles() | |
| 792 | - | return w.previewRowTiles(y, dim, lit, tiles, span, sel) | |
| 845 | + | tiles, preview := w.previewTiles() | |
| 846 | + | return w.previewGeometry(y, dim, lit, tiles, preview) | |
| 793 | 847 | } | |
| 794 | 848 | ||
| 795 | 849 | func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement { | |
| 850 | + | return w.previewGeometry(y, dim, lit, tiles, overviewPreview{bounds: Rect{W: span, H: w.Usable.H}, selected: sel}) | |
| 851 | + | } | |
| 852 | + | ||
| 853 | + | func (w *Workspace) previewGeometry(y int, dim, lit bool, tiles []Placement, preview overviewPreview) []Placement { | |
| 854 | + | span, sel := preview.bounds.W, preview.selected | |
| 796 | 855 | u := w.overviewArea() | |
| 797 | 856 | z := w.overviewZoom() | |
| 798 | 857 | scale := func(v int) int { return int(math.Round(float64(v) * z)) } | |
| @@ -806,6 +865,11 @@ func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, spa | |||
| 806 | 865 | x = u.X + u.W/2 - scale(sel.X+sel.W/2) | |
| 807 | 866 | x = min(max(x, u.X+u.W-width), u.X) | |
| 808 | 867 | } | |
| 868 | + | if w.policy().wraps && preview.bounds.H > w.Usable.H { | |
| 869 | + | room := min(scale(preview.bounds.H), u.H*8/10) | |
| 870 | + | offset := room/2 - scale(sel.Y+sel.H/2) | |
| 871 | + | y += min(0, max(room-scale(preview.bounds.H), offset)) | |
| 872 | + | } | |
| 809 | 873 | for i := range tiles { | |
| 810 | 874 | if tiles[i].Hidden { | |
| 811 | 875 | continue | |
| @@ -954,6 +1018,11 @@ const ( | |||
| 954 | 1018 | // (touchpad.* or mouse.*) flips both axes as libinput reports. The end of | |
| 955 | 1019 | // a scroll resets it (scrollStop). | |
| 956 | 1020 | func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |
| 1021 | + | // The accumulator uses logical content/workspace axes, independent of | |
| 1022 | + | // their physical orientation. Stash selection retains its own axes. | |
| 1023 | + | if m.Current().policy().wraps && m.cardAt(m.Current()) < 0 { | |
| 1024 | + | a.Vertical, a.Horizontal = a.Horizontal, a.Vertical | |
| 1025 | + | } | |
| 957 | 1026 | if a.Vertical.Stop || a.Horizontal.Stop { | |
| 958 | 1027 | return false | |
| 959 | 1028 | } | |
| @@ -353,6 +353,9 @@ func (m *Monitor) overviewWorkspaces() []*Workspace { | |||||
| 353 | // a named group uses its invocation anchor as the toggle return target, not | 353 | // a named group uses its invocation anchor as the toggle return target, not | ||
| 354 | // rows merely browsed in the overview. | 354 | // rows merely browsed in the overview. | ||
| 355 | func (m *Monitor) showOverview(w *Workspace) { | 355 | func (m *Monitor) showOverview(w *Workspace) { | ||
| 356 | + | if w.policy().workspace != m.Current().policy().workspace { | |||
| 357 | + | m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 0, 0, false, false | |||
| 358 | + | } | |||
| 356 | if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) { | 359 | if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) { | ||
| 357 | m.back = m.Current() | 360 | m.back = m.Current() | ||
| 358 | if indexOf(m.Workspaces, w.overviewAfter) >= 0 { | 361 | if indexOf(m.Workspaces, w.overviewAfter) >= 0 { | ||
| @@ -490,13 +493,13 @@ func (m *Monitor) OverviewPick(id WindowID) { | |||||
| 490 | // overviewRows centers the selected row with its neighbors above and below. | 493 | // overviewRows centers the selected row with its neighbors above and below. | ||
| 491 | // The numbered/named boundary gets extra spacing and an inactive horizontal | 494 | // The numbered/named boundary gets extra spacing and an inactive horizontal | ||
| 492 | // rule. Row widths, stash piles and preview scales remain unchanged. | 495 | // rule. Row widths, stash piles and preview scales remain unchanged. | ||
| 493 | - | func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) { | 496 | + | func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) { |
| 494 | cur := m.Current() | 497 | cur := m.Current() | ||
| 495 | u := cur.overviewArea() | 498 | u := cur.overviewArea() | ||
| 496 | gap := max(1, u.H*3/100) | 499 | gap := max(1, u.H*3/100) | ||
| 497 | hc := cur.rowHeight() | 500 | hc := cur.rowHeight() | ||
| 498 | y := u.Y + (u.H-hc)/2 | 501 | y := u.Y + (u.H-hc)/2 | ||
| 499 | - | rows := map[*Workspace]int{cur: y} | 502 | + | rows := map[*Workspace]Rect{cur: {Y: y}} |
| 500 | var dividers []ports.Separator | 503 | var dividers []ports.Separator | ||
| 501 | list := m.overviewWorkspaces() | 504 | list := m.overviewWorkspaces() | ||
| 502 | i := indexOf(list, cur) | 505 | i := indexOf(list, cur) | ||
| @@ -512,14 +515,29 @@ func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) { | |||||
| 512 | } | 515 | } | ||
| 513 | top := y + hc | 516 | top := y + hc | ||
| 514 | if next < i { | 517 | if next < i { | ||
| 515 | - | rows[w] = y - spacing - w.rowHeight() | 518 | + | rows[w] = Rect{Y: y - spacing - w.rowHeight()} |
| 516 | top = y - spacing | 519 | top = y - spacing | ||
| 517 | } else { | 520 | } else { | ||
| 518 | - | rows[w] = y + hc + spacing | 521 | + | rows[w] = Rect{Y: y + hc + spacing} |
| 522 | + | } | |||
| 523 | + | if cur.policy().workspace == horizontalAxis { | |||
| 524 | + | dx := u.W + spacing | |||
| 525 | + | if next < i { | |||
| 526 | + | dx = -dx | |||
| 527 | + | } | |||
| 528 | + | rows[w] = Rect{X: dx, Y: u.Y + (u.H-w.rowHeight())/2} | |||
| 519 | } | 529 | } | ||
| 520 | if boundary { | 530 | if boundary { | ||
| 521 | margin := u.W * 4 / 100 | 531 | margin := u.W * 4 / 100 | ||
| 522 | - | dividers = append(dividers, ports.Separator{Rect: Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1}}) | 532 | + | r := Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1} |
| 533 | + | if cur.policy().workspace == horizontalAxis { | |||
| 534 | + | x := u.X + u.W + spacing/2 | |||
| 535 | + | if next < i { | |||
| 536 | + | x = u.X - spacing/2 | |||
| 537 | + | } | |||
| 538 | + | r = Rect{X: x, Y: u.Y + u.H*4/100, W: 1, H: u.H * 92 / 100} | |||
| 539 | + | } | |||
| 540 | + | dividers = append(dividers, ports.Separator{Rect: r}) | |||
| 523 | } | 541 | } | ||
| 524 | } | 542 | } | ||
| 525 | return rows, dividers | 543 | return rows, dividers | ||
| @@ -535,8 +553,14 @@ func (m *Monitor) overviewLayout(result []Placement) []Placement { | |||||
| 535 | ry, shown := rows[w] | 553 | ry, shown := rows[w] | ||
| 536 | if shown { | 554 | if shown { | ||
| 537 | at := m.cardAt(w) | 555 | at := m.cardAt(w) | ||
| 538 | - | result = append(result, m.stackRow(w, ry, w != cur, at < 0)...) | 556 | + | start := len(result) |
| 539 | - | result = append(result, w.pile(ry, w != cur, at)...) | 557 | + | result = append(result, m.stackRow(w, ry.Y, w != cur, at < 0)...) |
| 558 | + | result = append(result, w.pile(ry.Y, w != cur, at)...) | |||
| 559 | + | for i := start; i < len(result); i++ { | |||
| 560 | + | if !result[i].Hidden { | |||
| 561 | + | result[i].Rect.X += ry.X | |||
| 562 | + | } | |||
| 563 | + | } | |||
| 540 | } | 564 | } | ||
| 541 | for _, f := range w.Floats { | 565 | for _, f := range w.Floats { | ||
| 542 | if _, drawn := w.itemOf(f.ID); !shown || !drawn { | 566 | if _, drawn := w.itemOf(f.ID); !shown || !drawn { | ||
| @@ -656,6 +680,12 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement { | |||||
| 656 | cw := int(math.Round(float64(r.W) * overviewCardZoom)) | 680 | cw := int(math.Round(float64(r.W) * overviewCardZoom)) | ||
| 657 | ch := int(math.Round(float64(r.H) * overviewCardZoom)) | 681 | ch := int(math.Round(float64(r.H) * overviewCardZoom)) | ||
| 658 | rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom())) | 682 | rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom())) | ||
| 683 | + | if w.policy().wraps { | |||
| 684 | + | rowH = w.rowHeight() - 4*w.peekStep() | |||
| 685 | + | if len(w.stack()) <= 1 { | |||
| 686 | + | rowH = w.rowHeight() | |||
| 687 | + | } | |||
| 688 | + | } | |||
| 659 | // Stack rows reserve two peek steps above the front card. Align the | 689 | // Stack rows reserve two peek steps above the front card. Align the | ||
| 660 | // stash with that front band, not with the top of its envelope. | 690 | // stash with that front band, not with the top of its envelope. | ||
| 661 | x0, y0 := u.X+u.W/100, w.frontRowY(y)+(rowH-ch)/2 | 691 | x0, y0 := u.X+u.W/100, w.frontRowY(y)+(rowH-ch)/2 | ||
| @@ -673,13 +703,18 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement { | |||||
| 673 | // width, within the overview bounds. | 703 | // width, within the overview bounds. | ||
| 674 | func (w *Workspace) overviewZoom() float64 { | 704 | func (w *Workspace) overviewZoom() float64 { | ||
| 675 | // Only the span is read: the tiles go in the zoom scratch, dead on return. | 705 | // Only the span is read: the tiles go in the zoom scratch, dead on return. | ||
| 676 | - | var span int | 706 | + | var preview overviewPreview |
| 677 | - | w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0]) | 707 | + | w.zoomBuf, preview = w.previewTilesInto(w.zoomBuf[:0]) |
| 708 | + | span := preview.bounds.W | |||
| 678 | if span <= 0 { | 709 | if span <= 0 { | ||
| 679 | return overviewMaxZoom | 710 | return overviewMaxZoom | ||
| 680 | } | 711 | } | ||
| 681 | room := float64(w.overviewArea().W-w.pileWidth()) * 0.96 | 712 | room := float64(w.overviewArea().W-w.pileWidth()) * 0.96 | ||
| 682 | - | z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom) | 713 | + | fit := room / float64(span) |
| 714 | + | if w.policy().wraps && preview.bounds.H > 0 { | |||
| 715 | + | fit = min(fit, float64(w.overviewArea().H)*0.8/float64(preview.bounds.H)) | |||
| 716 | + | } | |||
| 717 | + | z := min(max(fit, overviewMinZoom), overviewMaxZoom) | |||
| 683 | if len(w.stack()) > 1 { | 718 | if len(w.stack()) > 1 { | ||
| 684 | // Reserve two clear peeks on either side of the front row. | 719 | // Reserve two clear peeks on either side of the front row. | ||
| 685 | z = min(z, overviewMaxZoom/(1+4*overviewPeekStep)) | 720 | z = min(z, overviewMaxZoom/(1+4*overviewPeekStep)) | ||
| @@ -704,7 +739,15 @@ func (w *Workspace) frontRowY(y int) int { | |||||
| 704 | // rowHeight includes the largest possible stack envelope (two peeks above | 739 | // rowHeight includes the largest possible stack envelope (two peeks above | ||
| 705 | // and two below). Neighbor rows use the same measure as the current row. | 740 | // and two below). Neighbor rows use the same measure as the current row. | ||
| 706 | func (w *Workspace) rowHeight() int { | 741 | func (w *Workspace) rowHeight() int { | ||
| 707 | - | h := int(math.Round(float64(w.Usable.H) * w.overviewZoom())) | 742 | + | height := w.Usable.H |
| 743 | + | if w.policy().wraps { | |||
| 744 | + | n, _ := w.cascadeBands() | |||
| 745 | + | height *= n | |||
| 746 | + | } | |||
| 747 | + | h := int(math.Round(float64(height) * w.overviewZoom())) | |||
| 748 | + | if w.policy().wraps { | |||
| 749 | + | h = min(h, w.overviewArea().H*8/10) | |||
| 750 | + | } | |||
| 708 | if len(w.stack()) > 1 { | 751 | if len(w.stack()) > 1 { | ||
| 709 | h += 4 * w.peekStep() | 752 | h += 4 * w.peekStep() | ||
| 710 | } | 753 | } | ||
| @@ -714,28 +757,33 @@ func (w *Workspace) rowHeight() int { | |||||
| 714 | // previewTiles lays tiles out unscrolled, relative to the usable area's | 757 | // previewTiles lays tiles out unscrolled, relative to the usable area's | ||
| 715 | // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses | 758 | // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses | ||
| 716 | // an unscrolled row. span is the row's width; sel is the focused column. | 759 | // an unscrolled row. span is the row's width; sel is the focused column. | ||
| 717 | - | func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) { | 760 | + | // overviewPreview describes unscaled content independently of its presentation. |
| 761 | + | type overviewPreview struct { | |||
| 762 | + | bounds Rect | |||
| 763 | + | selected Rect | |||
| 764 | + | } | |||
| 765 | + | ||||
| 766 | + | func (w *Workspace) previewTiles() ([]Placement, overviewPreview) { | |||
| 718 | return w.previewTilesInto(make([]Placement, 0, w.columnWindows())) | 767 | return w.previewTilesInto(make([]Placement, 0, w.columnWindows())) | ||
| 719 | } | 768 | } | ||
| 720 | 769 | ||||
| 721 | // previewTilesInto is previewTiles appending to tiles (dst[:0] to reuse its | 770 | // previewTilesInto is previewTiles appending to tiles (dst[:0] to reuse its | ||
| 722 | // storage: the result then aliases it). The column rects use w.colBuf, dead | 771 | // storage: the result then aliases it). The column rects use w.colBuf, dead | ||
| 723 | // when it returns. | 772 | // when it returns. | ||
| 724 | - | func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) { | 773 | + | func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, overviewPreview) { |
| 725 | - | var span int | 774 | + | span, sel := 0, Rect{} |
| 726 | - | var sel Rect | |||
| 727 | if w.pinned() { | 775 | if w.pinned() { | ||
| 728 | c := w.cover() | 776 | c := w.cover() | ||
| 729 | // The row shows the columns a pinned fullscreen window hides, | 777 | // The row shows the columns a pinned fullscreen window hides, | ||
| 730 | // laid out as without it; its own tile stays marked fullscreen. | 778 | // laid out as without it; its own tile stays marked fullscreen. | ||
| 731 | v := *w | 779 | v := *w | ||
| 732 | v.fullscreen = 0 | 780 | v.fullscreen = 0 | ||
| 733 | - | tiles, span, sel = v.previewTilesInto(tiles) | 781 | + | tiles, preview := v.previewTilesInto(tiles) |
| 734 | w.colBuf, w.rowBuf = v.colBuf, v.rowBuf | 782 | w.colBuf, w.rowBuf = v.colBuf, v.rowBuf | ||
| 735 | for i := range tiles { | 783 | for i := range tiles { | ||
| 736 | tiles[i].Fullscreen = tiles[i].ID == c | 784 | tiles[i].Fullscreen = tiles[i].ID == c | ||
| 737 | } | 785 | } | ||
| 738 | - | return tiles, span, sel | 786 | + | return tiles, preview |
| 739 | } | 787 | } | ||
| 740 | g := w.gap() | 788 | g := w.gap() | ||
| 741 | h := max(w.Usable.H-2*g, 0) | 789 | h := max(w.Usable.H-2*g, 0) | ||
| @@ -750,7 +798,8 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||||
| 750 | r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h} | 798 | r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h} | ||
| 751 | if w.policy().wraps { | 799 | if w.policy().wraps { | ||
| 752 | width := w.cellWidth() | 800 | width := w.cellWidth() | ||
| 753 | - | r.X = w.band(i)*w.Usable.W + w.cellX(i) | 801 | + | r.X = w.cellX(i) |
| 802 | + | r.Y = g + w.band(i)*w.Usable.H | |||
| 754 | r.W = width | 803 | r.W = width | ||
| 755 | } else if w.Overflow == OverflowFixed { | 804 | } else if w.Overflow == OverflowFixed { | ||
| 756 | r = rects[i] | 805 | r = rects[i] | ||
| @@ -779,7 +828,12 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||||
| 779 | sel = r | 828 | sel = r | ||
| 780 | } | 829 | } | ||
| 781 | } | 830 | } | ||
| 782 | - | return tiles, span, sel | 831 | + | bounds := Rect{W: span, H: w.Usable.H} |
| 832 | + | if w.policy().wraps { | |||
| 833 | + | n, _ := w.cascadeBands() | |||
| 834 | + | bounds.W, bounds.H = w.Usable.W, n*w.Usable.H | |||
| 835 | + | } | |||
| 836 | + | return tiles, overviewPreview{bounds: bounds, selected: sel} | |||
| 783 | } | 837 | } | ||
| 784 | 838 | ||||
| 785 | // previewRow scales the tiles of w into a row whose top is at y, centred | 839 | // previewRow scales the tiles of w into a row whose top is at y, centred | ||
| @@ -788,11 +842,16 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) | |||||
| 788 | // workspaces) have no focus; neither has a row whose pile has it (lit | 842 | // workspaces) have no focus; neither has a row whose pile has it (lit | ||
| 789 | // false). | 843 | // false). | ||
| 790 | func (w *Workspace) previewRow(y int, dim, lit bool) []Placement { | 844 | func (w *Workspace) previewRow(y int, dim, lit bool) []Placement { | ||
| 791 | - | tiles, span, sel := w.previewTiles() | 845 | + | tiles, preview := w.previewTiles() |
| 792 | - | return w.previewRowTiles(y, dim, lit, tiles, span, sel) | 846 | + | return w.previewGeometry(y, dim, lit, tiles, preview) |
| 793 | } | 847 | } | ||
| 794 | 848 | ||||
| 795 | func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement { | 849 | func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement { | ||
| 850 | + | return w.previewGeometry(y, dim, lit, tiles, overviewPreview{bounds: Rect{W: span, H: w.Usable.H}, selected: sel}) | |||
| 851 | + | } | |||
| 852 | + | ||||
| 853 | + | func (w *Workspace) previewGeometry(y int, dim, lit bool, tiles []Placement, preview overviewPreview) []Placement { | |||
| 854 | + | span, sel := preview.bounds.W, preview.selected | |||
| 796 | u := w.overviewArea() | 855 | u := w.overviewArea() | ||
| 797 | z := w.overviewZoom() | 856 | z := w.overviewZoom() | ||
| 798 | scale := func(v int) int { return int(math.Round(float64(v) * z)) } | 857 | scale := func(v int) int { return int(math.Round(float64(v) * z)) } | ||
| @@ -806,6 +865,11 @@ func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, spa | |||||
| 806 | x = u.X + u.W/2 - scale(sel.X+sel.W/2) | 865 | x = u.X + u.W/2 - scale(sel.X+sel.W/2) | ||
| 807 | x = min(max(x, u.X+u.W-width), u.X) | 866 | x = min(max(x, u.X+u.W-width), u.X) | ||
| 808 | } | 867 | } | ||
| 868 | + | if w.policy().wraps && preview.bounds.H > w.Usable.H { | |||
| 869 | + | room := min(scale(preview.bounds.H), u.H*8/10) | |||
| 870 | + | offset := room/2 - scale(sel.Y+sel.H/2) | |||
| 871 | + | y += min(0, max(room-scale(preview.bounds.H), offset)) | |||
| 872 | + | } | |||
| 809 | for i := range tiles { | 873 | for i := range tiles { | ||
| 810 | if tiles[i].Hidden { | 874 | if tiles[i].Hidden { | ||
| 811 | continue | 875 | continue | ||
| @@ -954,6 +1018,11 @@ const ( | |||||
| 954 | // (touchpad.* or mouse.*) flips both axes as libinput reports. The end of | 1018 | // (touchpad.* or mouse.*) flips both axes as libinput reports. The end of | ||
| 955 | // a scroll resets it (scrollStop). | 1019 | // a scroll resets it (scrollStop). | ||
| 956 | func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | 1020 | func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | ||
| 1021 | + | // The accumulator uses logical content/workspace axes, independent of | |||
| 1022 | + | // their physical orientation. Stash selection retains its own axes. | |||
| 1023 | + | if m.Current().policy().wraps && m.cardAt(m.Current()) < 0 { | |||
| 1024 | + | a.Vertical, a.Horizontal = a.Horizontal, a.Vertical | |||
| 1025 | + | } | |||
| 957 | if a.Vertical.Stop || a.Horizontal.Stop { | 1026 | if a.Vertical.Stop || a.Horizontal.Stop { | ||
| 958 | return false | 1027 | return false | ||
| 959 | } | 1028 | } | ||
A internal/core/overview_cascade_test.go +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestOverviewCascadeBandGeometry(t *testing.T) { | |
| 9 | + | w := cascadeWorkspace(7) | |
| 10 | + | tiles, p := w.previewTiles() | |
| 11 | + | if p.bounds.W != w.Usable.W || p.bounds.H != 3*w.Usable.H { | |
| 12 | + | t.Fatalf("bounds=%v", p.bounds) | |
| 13 | + | } | |
| 14 | + | for i, tile := range tiles { | |
| 15 | + | if tile.Rect.X != w.cellX(i) || tile.Rect.Y != w.gap()+w.band(i)*w.Usable.H { | |
| 16 | + | t.Fatalf("tile %d=%v", i, tile.Rect) | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestOverviewCascadePreviewBufferAllocations(t *testing.T) { | |
| 22 | + | w := cascadeWorkspace(7) | |
| 23 | + | buf, _ := w.previewTiles() | |
| 24 | + | if n := testing.AllocsPerRun(100, func() { buf, _ = w.previewTilesInto(buf[:0]) }); n != 0 { | |
| 25 | + | t.Fatalf("allocations=%v", n) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestOverviewMixedLayoutGestureAndCancel(t *testing.T) { | |
| 30 | + | m := overviewMonitor() | |
| 31 | + | m.SetOverflow(OverflowFixed) | |
| 32 | + | m.Current().Overflow = OverflowCascade | |
| 33 | + | from := m.Current() | |
| 34 | + | m.ToggleOverview() | |
| 35 | + | rows, _ := m.overviewRows() | |
| 36 | + | if rows[m.Workspaces[1]].X <= 0 { | |
| 37 | + | t.Fatal("cascade neighbor not horizontal") | |
| 38 | + | } | |
| 39 | + | m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: overviewScrollStep}}) | |
| 40 | + | if m.Current() == from { | |
| 41 | + | t.Fatal("horizontal gesture did not change workspace") | |
| 42 | + | } | |
| 43 | + | if m.Current().Overflow != OverflowFixed { | |
| 44 | + | t.Fatal("destination lost fixed policy") | |
| 45 | + | } | |
| 46 | + | m.CancelOverview() | |
| 47 | + | if m.Current() != from { | |
| 48 | + | t.Fatal("cancel lost source workspace") | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestOverviewCascadeReloadRetainsSelection(t *testing.T) { | |
| 53 | + | m := overviewMonitor() | |
| 54 | + | m.ToggleOverview() | |
| 55 | + | w := m.Current() | |
| 56 | + | id, _ := w.Focused() | |
| 57 | + | m.SetOverflow(OverflowCascade) | |
| 58 | + | if got, _ := w.Focused(); !m.ov.open || got != id { | |
| 59 | + | t.Fatal("reload lost overview selection") | |
| 60 | + | } | |
| 61 | + | m.CancelOverview() | |
| 62 | + | if got, _ := w.Focused(); got != id { | |
| 63 | + | t.Fatal("cancel lost original focus") | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestOverviewCascadeStashScale(t *testing.T) { | |
| 68 | + | w := cascadeWorkspace(7) | |
| 69 | + | w.Stash = []Float{{ID: 20}} | |
| 70 | + | cards := w.pile(0, false, 0) | |
| 71 | + | if len(cards) != 1 || cards[0].Preview != overviewCardZoom { | |
| 72 | + | t.Fatalf("cards=%v", cards) | |
| 73 | + | } | |
| 74 | + | if w.pileWidth() > w.overviewArea().W/3 { | |
| 75 | + | t.Fatal("stash exceeds width budget") | |
| 76 | + | } | |
| 77 | + | } | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestOverviewCascadeBandGeometry(t *testing.T) { | |||
| 9 | + | w := cascadeWorkspace(7) | |||
| 10 | + | tiles, p := w.previewTiles() | |||
| 11 | + | if p.bounds.W != w.Usable.W || p.bounds.H != 3*w.Usable.H { | |||
| 12 | + | t.Fatalf("bounds=%v", p.bounds) | |||
| 13 | + | } | |||
| 14 | + | for i, tile := range tiles { | |||
| 15 | + | if tile.Rect.X != w.cellX(i) || tile.Rect.Y != w.gap()+w.band(i)*w.Usable.H { | |||
| 16 | + | t.Fatalf("tile %d=%v", i, tile.Rect) | |||
| 17 | + | } | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func TestOverviewCascadePreviewBufferAllocations(t *testing.T) { | |||
| 22 | + | w := cascadeWorkspace(7) | |||
| 23 | + | buf, _ := w.previewTiles() | |||
| 24 | + | if n := testing.AllocsPerRun(100, func() { buf, _ = w.previewTilesInto(buf[:0]) }); n != 0 { | |||
| 25 | + | t.Fatalf("allocations=%v", n) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestOverviewMixedLayoutGestureAndCancel(t *testing.T) { | |||
| 30 | + | m := overviewMonitor() | |||
| 31 | + | m.SetOverflow(OverflowFixed) | |||
| 32 | + | m.Current().Overflow = OverflowCascade | |||
| 33 | + | from := m.Current() | |||
| 34 | + | m.ToggleOverview() | |||
| 35 | + | rows, _ := m.overviewRows() | |||
| 36 | + | if rows[m.Workspaces[1]].X <= 0 { | |||
| 37 | + | t.Fatal("cascade neighbor not horizontal") | |||
| 38 | + | } | |||
| 39 | + | m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: overviewScrollStep}}) | |||
| 40 | + | if m.Current() == from { | |||
| 41 | + | t.Fatal("horizontal gesture did not change workspace") | |||
| 42 | + | } | |||
| 43 | + | if m.Current().Overflow != OverflowFixed { | |||
| 44 | + | t.Fatal("destination lost fixed policy") | |||
| 45 | + | } | |||
| 46 | + | m.CancelOverview() | |||
| 47 | + | if m.Current() != from { | |||
| 48 | + | t.Fatal("cancel lost source workspace") | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestOverviewCascadeReloadRetainsSelection(t *testing.T) { | |||
| 53 | + | m := overviewMonitor() | |||
| 54 | + | m.ToggleOverview() | |||
| 55 | + | w := m.Current() | |||
| 56 | + | id, _ := w.Focused() | |||
| 57 | + | m.SetOverflow(OverflowCascade) | |||
| 58 | + | if got, _ := w.Focused(); !m.ov.open || got != id { | |||
| 59 | + | t.Fatal("reload lost overview selection") | |||
| 60 | + | } | |||
| 61 | + | m.CancelOverview() | |||
| 62 | + | if got, _ := w.Focused(); got != id { | |||
| 63 | + | t.Fatal("cancel lost original focus") | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestOverviewCascadeStashScale(t *testing.T) { | |||
| 68 | + | w := cascadeWorkspace(7) | |||
| 69 | + | w.Stash = []Float{{ID: 20}} | |||
| 70 | + | cards := w.pile(0, false, 0) | |||
| 71 | + | if len(cards) != 1 || cards[0].Preview != overviewCardZoom { | |||
| 72 | + | t.Fatalf("cards=%v", cards) | |||
| 73 | + | } | |||
| 74 | + | if w.pileWidth() > w.overviewArea().W/3 { | |||
| 75 | + | t.Fatal("stash exceeds width budget") | |||
| 76 | + | } | |||
| 77 | + | } | |||
M internal/core/overview_test.go +2 -2
| @@ -485,8 +485,8 @@ func TestOverviewFixedOverflowGeometry(t *testing.T) { | |||
| 485 | 485 | w.FocusID(6) | |
| 486 | 486 | screen := w.Layout() | |
| 487 | 487 | m.ToggleOverview() | |
| 488 | - | if _, span, _ := w.previewTiles(); span != w.Usable.W { | |
| 489 | - | t.Fatalf("span %d, usable %d", span, w.Usable.W) | |
| 488 | + | if _, preview := w.previewTiles(); preview.bounds.W != w.Usable.W { | |
| 489 | + | t.Fatalf("span %d, usable %d", preview.bounds.W, w.Usable.W) | |
| 490 | 490 | } | |
| 491 | 491 | ps := m.Layout() | |
| 492 | 492 | z := previewOf(t, ps, 6).Preview | |
| @@ -485,8 +485,8 @@ func TestOverviewFixedOverflowGeometry(t *testing.T) { | |||||
| 485 | w.FocusID(6) | 485 | w.FocusID(6) | ||
| 486 | screen := w.Layout() | 486 | screen := w.Layout() | ||
| 487 | m.ToggleOverview() | 487 | m.ToggleOverview() | ||
| 488 | - | if _, span, _ := w.previewTiles(); span != w.Usable.W { | 488 | + | if _, preview := w.previewTiles(); preview.bounds.W != w.Usable.W { |
| 489 | - | t.Fatalf("span %d, usable %d", span, w.Usable.W) | 489 | + | t.Fatalf("span %d, usable %d", preview.bounds.W, w.Usable.W) |
| 490 | } | 490 | } | ||
| 491 | ps := m.Layout() | 491 | ps := m.Layout() | ||
| 492 | z := previewOf(t, ps, 6).Preview | 492 | z := previewOf(t, ps, 6).Preview | ||
M internal/core/row_shares_test.go +1 -1
| @@ -183,7 +183,7 @@ func TestOverviewPreviewUsesShares(t *testing.T) { | |||
| 183 | 183 | w := workspace() | |
| 184 | 184 | w.SetOutput(1000, 800) | |
| 185 | 185 | w.Columns = []Column{{Windows: []WindowID{1, 2}, Shares: []int{70, 30}}} | |
| 186 | - | tiles, _, _ := w.previewTiles() | |
| 186 | + | tiles, _ := w.previewTiles() | |
| 187 | 187 | h := map[WindowID]int{} | |
| 188 | 188 | for _, p := range tiles { | |
| 189 | 189 | h[p.ID] = p.Rect.H | |
| @@ -183,7 +183,7 @@ func TestOverviewPreviewUsesShares(t *testing.T) { | |||||
| 183 | w := workspace() | 183 | w := workspace() | ||
| 184 | w.SetOutput(1000, 800) | 184 | w.SetOutput(1000, 800) | ||
| 185 | w.Columns = []Column{{Windows: []WindowID{1, 2}, Shares: []int{70, 30}}} | 185 | w.Columns = []Column{{Windows: []WindowID{1, 2}, Shares: []int{70, 30}}} | ||
| 186 | - | tiles, _, _ := w.previewTiles() | 186 | + | tiles, _ := w.previewTiles() |
| 187 | h := map[WindowID]int{} | 187 | h := map[WindowID]int{} | ||
| 188 | for _, p := range tiles { | 188 | for _, p := range tiles { | ||
| 189 | h[p.ID] = p.Rect.H | 189 | h[p.ID] = p.Rect.H | ||