detached 18098d8

feat(core): animate the overview open, close and navigation

Rect motions now run in the overview: toggling it, moving its selection or
clicking a card slides every card drawn before and after from its shown
rect, content scaled through the derived Zoom (clamped to 1, emitted as 1
for a card at its size). Cards the other rows bring in fade in. The click
path hit-tests the shown layout while the cards move.
commit
18098d8a8a804ba5353f497d72fe20ce09d66dbe
author
brice · · 2026-10-04 23:04 UTC
committer
brice · · 2026-10-04 23:26 UTC
parent
f0176e6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +493 -60
M internal/core/core.go +4 -2
@@ -609,8 +609,10 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
609609 // Only the focused output has an activated window.
610610 focused := p.Focused && i == c.focusScreen
611611 sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade, Dim: max(0, p.Dim)}
612- if p.Zoom > 0 && p.Zoom < 1 {
613- // A scale motion: the content follows the drawn size.
612+ if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {
613+ // A scale motion: the content follows the drawn size. A
614+ // card in flight drawn at its size still needs Zoom 1:
615+ // without it the renderer would shrink it by Preview.
614616 sw.Zoom = p.Zoom
615617 }
616618 if p.Peek {
@@ -609,8 +609,10 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
609 // Only the focused output has an activated window.609 // Only the focused output has an activated window.
610 focused := p.Focused && i == c.focusScreen610 focused := p.Focused && i == c.focusScreen
611 sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade, Dim: max(0, p.Dim)}611 sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade, Dim: max(0, p.Dim)}
612- if p.Zoom > 0 && p.Zoom < 1 {612+ if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {
613- // A scale motion: the content follows the drawn size.613+ // A scale motion: the content follows the drawn size. A
614+ // card in flight drawn at its size still needs Zoom 1:
615+ // without it the renderer would shrink it by Preview.
614 sw.Zoom = p.Zoom616 sw.Zoom = p.Zoom
615 }617 }
616 if p.Peek {618 if p.Peek {
M internal/core/gesture.go +3 -1
@@ -255,9 +255,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
255255 return false
256256 }
257257 before := m.Current()
258+ shots := c.snapshot(now)
258259 m.ToggleOverview()
259260 c.focusScreen = c.screenIndex(g.screen.name())
260261 c.keyboard.takeBack()
262+ c.transition(shots, now)
261263 return m.Current() != before
262264 case swipeDiscrete:
263265 step := g.snap.step(e.Cancelled, e.Time)
@@ -285,8 +287,8 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
285287 mon.overviewFocus(a)
286288 } else {
287289 c.applyAction(a)
288- c.transition(shots, now)
289290 }
291+ c.transition(shots, now)
290292 return c.cur().mon.Current() != before
291293 }
292294 return shown
@@ -255,9 +255,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
255 return false255 return false
256 }256 }
257 before := m.Current()257 before := m.Current()
258+ shots := c.snapshot(now)
258 m.ToggleOverview()259 m.ToggleOverview()
259 c.focusScreen = c.screenIndex(g.screen.name())260 c.focusScreen = c.screenIndex(g.screen.name())
260 c.keyboard.takeBack()261 c.keyboard.takeBack()
262+ c.transition(shots, now)
261 return m.Current() != before263 return m.Current() != before
262 case swipeDiscrete:264 case swipeDiscrete:
263 step := g.snap.step(e.Cancelled, e.Time)265 step := g.snap.step(e.Cancelled, e.Time)
@@ -285,8 +287,8 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
285 mon.overviewFocus(a)287 mon.overviewFocus(a)
286 } else {288 } else {
287 c.applyAction(a)289 c.applyAction(a)
288- c.transition(shots, now)
289 }290 }
291+ c.transition(shots, now)
290 return c.cur().mon.Current() != before292 return c.cur().mon.Current() != before
291 }293 }
292 return shown294 return shown
M internal/core/input.go +6 -0
@@ -143,7 +143,10 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
143143 c.scrollStop(v)
144144 // In the overview, scrolling moves the selection.
145145 if c.cur().mon.ov.open && !c.overviewKeyboardTaken() {
146+ now := c.now()
147+ shots := c.snapshot(now)
146148 if c.cur().mon.overviewScroll(v) {
149+ c.transition(shots, now)
147150 if err := c.workspaceVisible(ctx, true); err != nil {
148151 return err
149152 }
@@ -192,8 +195,11 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
192195 // run binds, and are not forwarded.
193196 // A launcher or a menu holding the keyboard gets them first.
194197 if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() {
198+ now := c.now()
199+ shots := c.snapshot(now)
195200 if key.Pressed && mon.overviewKey(key) {
196201 c.pressed[heldKey(key)] = true
202+ c.transition(shots, now)
197203 if err := c.workspaceVisible(ctx, true); err != nil {
198204 return err
199205 }
@@ -143,7 +143,10 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
143 c.scrollStop(v)143 c.scrollStop(v)
144 // In the overview, scrolling moves the selection.144 // In the overview, scrolling moves the selection.
145 if c.cur().mon.ov.open && !c.overviewKeyboardTaken() {145 if c.cur().mon.ov.open && !c.overviewKeyboardTaken() {
146+ now := c.now()
147+ shots := c.snapshot(now)
146 if c.cur().mon.overviewScroll(v) {148 if c.cur().mon.overviewScroll(v) {
149+ c.transition(shots, now)
147 if err := c.workspaceVisible(ctx, true); err != nil {150 if err := c.workspaceVisible(ctx, true); err != nil {
148 return err151 return err
149 }152 }
@@ -192,8 +195,11 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
192 // run binds, and are not forwarded.195 // run binds, and are not forwarded.
193 // A launcher or a menu holding the keyboard gets them first.196 // A launcher or a menu holding the keyboard gets them first.
194 if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() {197 if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() {
198+ now := c.now()
199+ shots := c.snapshot(now)
195 if key.Pressed && mon.overviewKey(key) {200 if key.Pressed && mon.overviewKey(key) {
196 c.pressed[heldKey(key)] = true201 c.pressed[heldKey(key)] = true
202+ c.transition(shots, now)
197 if err := c.workspaceVisible(ctx, true); err != nil {203 if err := c.workspaceVisible(ctx, true); err != nil {
198 return err204 return err
199 }205 }
M internal/core/overview.go +12 -2
@@ -814,7 +814,12 @@ func overviewOutline(layout []Placement, b int) []ports.Separator {
814814 // cards take priority over peeks, including peeks appended after a front
815815 // tile in another row's layout.
816816 func (m *Monitor) overviewAt(x, y float64) WindowID {
817- layout := m.Layout()
817+ return overviewIn(m.Layout(), x, y)
818+}
819+
820+// overviewIn is overviewAt in a given layout: the click path passes the
821+// shown one, so a card is hit where it is drawn while it moves.
822+func overviewIn(layout []Placement, x, y float64) WindowID {
818823 for i := len(layout) - 1; i >= 0; i-- {
819824 p := layout[i]
820825 r := p.Rect
@@ -843,11 +848,16 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) {
843848 if !sc.mon.ov.open {
844849 return false, nil
845850 }
846- id := sc.mon.overviewAt(c.cursorX-float64(sc.x), c.cursorY-float64(sc.y))
851+ // The cards are hit where they are drawn: a click during the open or a
852+ // navigation animation picks what moves under the pointer.
853+ id := overviewIn(sc.shownLayout(), c.cursorX-float64(sc.x), c.cursorY-float64(sc.y))
847854 if id == 0 {
848855 return false, nil
849856 }
857+ now := c.now()
858+ shots := c.snapshot(now)
850859 sc.mon.OverviewPick(id)
860+ c.transition(shots, now)
851861 c.focusScreen = i
852862 c.keyboard.takeBack()
853863 if err := c.workspaceVisible(ctx, true); err != nil {
@@ -814,7 +814,12 @@ func overviewOutline(layout []Placement, b int) []ports.Separator {
814 // cards take priority over peeks, including peeks appended after a front814 // cards take priority over peeks, including peeks appended after a front
815 // tile in another row's layout.815 // tile in another row's layout.
816 func (m *Monitor) overviewAt(x, y float64) WindowID {816 func (m *Monitor) overviewAt(x, y float64) WindowID {
817- layout := m.Layout()817+ return overviewIn(m.Layout(), x, y)
818+}
819+
820+// overviewIn is overviewAt in a given layout: the click path passes the
821+// shown one, so a card is hit where it is drawn while it moves.
822+func overviewIn(layout []Placement, x, y float64) WindowID {
818 for i := len(layout) - 1; i >= 0; i-- {823 for i := len(layout) - 1; i >= 0; i-- {
819 p := layout[i]824 p := layout[i]
820 r := p.Rect825 r := p.Rect
@@ -843,11 +848,16 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) {
843 if !sc.mon.ov.open {848 if !sc.mon.ov.open {
844 return false, nil849 return false, nil
845 }850 }
846- id := sc.mon.overviewAt(c.cursorX-float64(sc.x), c.cursorY-float64(sc.y))851+ // The cards are hit where they are drawn: a click during the open or a
852+ // navigation animation picks what moves under the pointer.
853+ id := overviewIn(sc.shownLayout(), c.cursorX-float64(sc.x), c.cursorY-float64(sc.y))
847 if id == 0 {854 if id == 0 {
848 return false, nil855 return false, nil
849 }856 }
857+ now := c.now()
858+ shots := c.snapshot(now)
850 sc.mon.OverviewPick(id)859 sc.mon.OverviewPick(id)
860+ c.transition(shots, now)
851 c.focusScreen = i861 c.focusScreen = i
852 c.keyboard.takeBack()862 c.keyboard.takeBack()
853 if err := c.workspaceVisible(ctx, true); err != nil {863 if err := c.workspaceVisible(ctx, true); err != nil {
M internal/core/overview_core_test.go +9 -2
@@ -82,10 +82,17 @@ func TestOverviewCoreClickPicksOnPointerOutput(t *testing.T) {
8282 }
8383
8484 func TestOverviewCoreClickOutsidePreviewKeepsOverview(t *testing.T) {
85- r := startMulti(t, nil, right)
85+ both(t, func(t *testing.T, animated bool) {
86+ overviewClickOutsidePreview(t, startLanding(t, animated, nil, right))
87+ })
88+}
89+
90+func overviewClickOutsidePreview(t *testing.T, r *landRig) {
8691 r.mapWindow(t, 1)
8792 r.mapWindow(t, 2)
88- set := r.key(t, "o", ports.ModAlt)
93+ // The cards settle where the overview lays them out: the opening
94+ // animation starts them at the windows' own rects.
95+ set := r.keyLanded(t, "o", ports.ModAlt)
8996 for _, w := range set[0].Windows {
9097 if w.Preview > 0 && w.Rect.X <= 1 && w.Rect.Y <= 1 && w.Rect.X+w.Rect.W > 1 && w.Rect.Y+w.Rect.H > 1 {
9198 t.Fatalf("(1, 1) is inside preview %+v", w)
@@ -82,10 +82,17 @@ func TestOverviewCoreClickPicksOnPointerOutput(t *testing.T) {
82 }82 }
83 83
84 func TestOverviewCoreClickOutsidePreviewKeepsOverview(t *testing.T) {84 func TestOverviewCoreClickOutsidePreviewKeepsOverview(t *testing.T) {
85- r := startMulti(t, nil, right)85+ both(t, func(t *testing.T, animated bool) {
86+ overviewClickOutsidePreview(t, startLanding(t, animated, nil, right))
87+ })
88+}
89+
90+func overviewClickOutsidePreview(t *testing.T, r *landRig) {
86 r.mapWindow(t, 1)91 r.mapWindow(t, 1)
87 r.mapWindow(t, 2)92 r.mapWindow(t, 2)
88- set := r.key(t, "o", ports.ModAlt)93+ // The cards settle where the overview lays them out: the opening
94+ // animation starts them at the windows' own rects.
95+ set := r.keyLanded(t, "o", ports.ModAlt)
89 for _, w := range set[0].Windows {96 for _, w := range set[0].Windows {
90 if w.Preview > 0 && w.Rect.X <= 1 && w.Rect.Y <= 1 && w.Rect.X+w.Rect.W > 1 && w.Rect.Y+w.Rect.H > 1 {97 if w.Preview > 0 && w.Rect.X <= 1 && w.Rect.Y <= 1 && w.Rect.X+w.Rect.W > 1 && w.Rect.Y+w.Rect.H > 1 {
91 t.Fatalf("(1, 1) is inside preview %+v", w)98 t.Fatalf("(1, 1) is inside preview %+v", w)
M internal/core/screens.go +11 -1
@@ -41,7 +41,8 @@ type screen struct {
4141 // motions run (refreshShown), reused across publishes.
4242 shownBuf, settledBuf []Placement
4343 // rects are the running per-window rect motions (transition.go) of
44- // rectsWS, the workspace they began on; created lazily.
44+ // rectsWS, the workspace they began on (nil: the overview); created
45+ // lazily.
4546 rects map[WindowID]rectMotion
4647 rectsWS *Workspace
4748 }
@@ -56,6 +57,15 @@ func (s *screen) settled() bool { return s.mon.settled() && len(s.rects) == 0 }
5657 // stopRects drops the rect motions: the windows are drawn where they are.
5758 func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil }
5859
60+// rectsOwner is what the screen's rect motions belong to: its workspace on
61+// screen, or nil in the overview, where the rows change under the cards.
62+func (s *screen) rectsOwner() *Workspace {
63+ if s.mon.ov.open {
64+ return nil
65+ }
66+ return s.mon.Current()
67+}
68+
5969 // shownLayout is the layout as drawn, for hit-testing and popups; before
6070 // the first publish it is the monitor's.
6171 func (s *screen) shownLayout() []Placement {
@@ -41,7 +41,8 @@ type screen struct {
41 // motions run (refreshShown), reused across publishes.41 // motions run (refreshShown), reused across publishes.
42 shownBuf, settledBuf []Placement42 shownBuf, settledBuf []Placement
43 // rects are the running per-window rect motions (transition.go) of43 // rects are the running per-window rect motions (transition.go) of
44- // rectsWS, the workspace they began on; created lazily.44+ // rectsWS, the workspace they began on (nil: the overview); created
45+ // lazily.
45 rects map[WindowID]rectMotion46 rects map[WindowID]rectMotion
46 rectsWS *Workspace47 rectsWS *Workspace
47 }48 }
@@ -56,6 +57,15 @@ func (s *screen) settled() bool { return s.mon.settled() && len(s.rects) == 0 }
56 // stopRects drops the rect motions: the windows are drawn where they are.57 // stopRects drops the rect motions: the windows are drawn where they are.
57 func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil }58 func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil }
58 59
60+// rectsOwner is what the screen's rect motions belong to: its workspace on
61+// screen, or nil in the overview, where the rows change under the cards.
62+func (s *screen) rectsOwner() *Workspace {
63+ if s.mon.ov.open {
64+ return nil
65+ }
66+ return s.mon.Current()
67+}
68+
59 // shownLayout is the layout as drawn, for hit-testing and popups; before69 // shownLayout is the layout as drawn, for hit-testing and popups; before
60 // the first publish it is the monitor's.70 // the first publish it is the monitor's.
61 func (s *screen) shownLayout() []Placement {71 func (s *screen) shownLayout() []Placement {
M internal/core/swipe_test.go +22 -10
@@ -2,6 +2,7 @@ package core_test
22
33 import (
44 "context"
5+ "slices"
56 "testing"
67 "time"
78
@@ -692,17 +693,28 @@ func TestOverviewOpenedMidSwipeDropsIt(t *testing.T) {
692693 if !closeFirst {
693694 r.input <- ports.KeyEvent{Keysym: "Escape", Pressed: true}
694695 }
695- // Past any spring: the view shows where it settled.
696- r.advance(5 * time.Second)
697- r.frames <- ports.OutputFrame{Output: wide.Name}
698- s := sceneMatch(t, r.scenes, func(s ports.Scene) bool {
699- for _, w := range s.Windows {
700- if w.Preview > 0 {
701- return false
702- }
696+ // Past any spring: the view shows where it settled. The escape's
697+ // own springs (the cards zooming back) start when it is handled,
698+ // which may follow a first flip: flip until the scene is settled.
699+ var s ports.Scene
700+ for deadline := time.Now().Add(time.Second); ; {
701+ r.advance(5 * time.Second)
702+ r.frames <- ports.OutputFrame{Output: wide.Name}
703+ select {
704+ case set := <-r.scenes:
705+ s = set[0]
706+ case <-time.After(20 * time.Millisecond):
703707 }
704- return true
705- })
708+ if len(s.Windows) > 0 && !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool {
709+ // Neither a card nor a window still zooming back.
710+ return w.Preview > 0 || w.Zoom > 0 || w.Fade > 0
711+ }) {
712+ break
713+ }
714+ if time.Now().After(deadline) {
715+ t.Fatalf("closeFirst=%v: the scene never settled: %+v", closeFirst, s.Windows)
716+ }
717+ }
706718 for _, id := range []ports.WindowID{1, 2, 3} {
707719 before, _ := rectOf(settled, id)
708720 if after, _ := rectOf(s, id); before != after {
@@ -2,6 +2,7 @@ package core_test
2 2
3 import (3 import (
4 "context"4 "context"
5+ "slices"
5 "testing"6 "testing"
6 "time"7 "time"
7 8
@@ -692,17 +693,28 @@ func TestOverviewOpenedMidSwipeDropsIt(t *testing.T) {
692 if !closeFirst {693 if !closeFirst {
693 r.input <- ports.KeyEvent{Keysym: "Escape", Pressed: true}694 r.input <- ports.KeyEvent{Keysym: "Escape", Pressed: true}
694 }695 }
695- // Past any spring: the view shows where it settled.696+ // Past any spring: the view shows where it settled. The escape's
696- r.advance(5 * time.Second)697+ // own springs (the cards zooming back) start when it is handled,
697- r.frames <- ports.OutputFrame{Output: wide.Name}698+ // which may follow a first flip: flip until the scene is settled.
698- s := sceneMatch(t, r.scenes, func(s ports.Scene) bool {699+ var s ports.Scene
699- for _, w := range s.Windows {700+ for deadline := time.Now().Add(time.Second); ; {
700- if w.Preview > 0 {701+ r.advance(5 * time.Second)
701- return false702+ r.frames <- ports.OutputFrame{Output: wide.Name}
702- }703+ select {
704+ case set := <-r.scenes:
705+ s = set[0]
706+ case <-time.After(20 * time.Millisecond):
703 }707 }
704- return true708+ if len(s.Windows) > 0 && !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool {
705- })709+ // Neither a card nor a window still zooming back.
710+ return w.Preview > 0 || w.Zoom > 0 || w.Fade > 0
711+ }) {
712+ break
713+ }
714+ if time.Now().After(deadline) {
715+ t.Fatalf("closeFirst=%v: the scene never settled: %+v", closeFirst, s.Windows)
716+ }
717+ }
706 for _, id := range []ports.WindowID{1, 2, 3} {718 for _, id := range []ports.WindowID{1, 2, 3} {
707 before, _ := rectOf(settled, id)719 before, _ := rectOf(settled, id)
708 if after, _ := rectOf(s, id); before != after {720 if after, _ := rectOf(s, id); before != after {
M internal/core/transition.go +96 -39
@@ -25,6 +25,9 @@ import (
2525 type viewShot struct {
2626 sc *screen
2727 ws *Workspace
28+ // overview is set when the screen showed its overview: ws is then the
29+ // row on screen, the camera fields stay zero, and rects are the cards.
30+ overview bool
2831 // viewX is ws's settled view and view where its columns were on screen
2932 // (viewX plus shift).
3033 viewX int
@@ -40,8 +43,8 @@ type viewShot struct {
4043 list []*Workspace
4144 pos float64
4245 ok bool
43- // rects are the windows of ws the screen drew, hidden windows and
44- // previews left out. The slice is reused like list.
46+ // rects are the windows the screen drew (cards in the overview),
47+ // hidden windows left out. The slice is reused like list.
4548 rects []rectShot
4649 }
4750
@@ -67,7 +70,9 @@ type rectShot struct {
6770 // placement gets a Zoom of the settled zoom (its Preview, or 1) times
6871 // shown.W / settled.W (refreshShown), which the scene carries as Preview.
6972 // The walk shrinks content toward the content rect's top-left, so this
70-// suits aspect-preserving changes only.
73+// suits aspect-preserving changes only. An overview card's settled zoom is
74+// its Preview: a window that opens into a card goes from 1 to Preview with
75+// its width, and back.
7176 //
7277 // A leaving motion draws a window that closed or hid while it fades out:
7378 // left is its last settled placement, which refreshShown appends (Hidden
@@ -161,7 +166,9 @@ func (m *rectMotion) show(p *Placement) {
161166 }
162167 zoom *= float64(p.Rect.W) / float64(settled.W)
163168 if zoom >= 0.999 {
164- // Drawn at its size: no zoom (publish emits Preview 0).
169+ // Drawn at its size: content is never magnified. publish
170+ // emits no Zoom for a window, and Zoom 1 for a card (whose
171+ // Preview would shrink it).
165172 zoom = 1
166173 }
167174 p.Zoom = zoom
@@ -183,8 +190,8 @@ func (c *Core) retargetComponent(m *motion, cur *float64, off, v float64, now ti
183190 // refreshShown builds the layouts of every screen once per publish: the
184191 // settled one (what configures are sized from) and the shown one (the same
185192 // with the rect motions applied: what is drawn and hit-tested). Rect motions
186-// belong to the workspace they began on and to a closed overview: any other
187-// change drops them.
193+// belong to the workspace they began on, or to the open overview (rectsOwner):
194+// any other change drops them.
188195 //
189196 // Leaving motions add an entry for a window the layout no longer shows:
190197 // appended to both layouts in ID order, Hidden (no input, focus, popups or
@@ -193,7 +200,7 @@ func (c *Core) retargetComponent(m *motion, cur *float64, off, v float64, now ti
193200 func (c *Core) refreshShown() {
194201 for _, sc := range c.screens {
195202 m := sc.mon
196- if len(sc.rects) > 0 && (m.ov.open || sc.rectsWS != m.Current()) {
203+ if len(sc.rects) > 0 && sc.rectsWS != sc.rectsOwner() {
197204 sc.stopRects()
198205 }
199206 sc.settledLayout = m.Layout()
@@ -203,7 +210,7 @@ func (c *Core) refreshShown() {
203210 // is the opposite: it goes once the layout shows the window.
204211 for id, rm := range sc.rects {
205212 shown := slices.ContainsFunc(sc.settledLayout, func(p Placement) bool {
206- return p.ID == id && !p.Hidden && p.Preview == 0
213+ return p.ID == id && !p.Hidden
207214 })
208215 if shown == rm.leaving {
209216 delete(sc.rects, id)
@@ -450,37 +457,40 @@ func (c *Core) snapshot(now time.Time) []viewShot {
450457 s.rects = full[len(out)].rects[:0]
451458 }
452459 s.sc = sc
460+ w := m.Current()
461+ s.ws, s.overview = w, m.ov.open
453462 if !m.ov.open {
454- w := m.Current()
455- s.ws, s.viewX, s.view = w, w.ViewX, float64(w.ViewX)+w.shift
456- if w.motion.on {
457- _, s.viewV = w.motion.sampleAt(now)
463+ // The overview does not scroll or slide: no camera.
464+ s.viewX, s.view = w.ViewX, float64(w.ViewX)+w.shift
465+ }
466+ if w.motion.on {
467+ _, s.viewV = w.motion.sampleAt(now)
468+ }
469+ if m.switchMotion.on {
470+ _, s.switchV = m.switchMotion.sampleAt(now)
471+ }
472+ // settledLayout and shown are index-aligned and differ only by
473+ // the offsets of sc.rects: the shot keeps both unrounded. The
474+ // overview's cards are recorded like windows.
475+ for _, p := range sc.settledLayout {
476+ if p.Hidden {
477+ continue
458478 }
459- if m.switchMotion.on {
460- _, s.switchV = m.switchMotion.sampleAt(now)
479+ r := rectShot{id: p.ID, rect: p.Rect}
480+ if rm, ok := sc.rects[p.ID]; ok {
481+ r.off, r.vel = rm.sample(now)
461482 }
462- // settledLayout and shown are index-aligned and differ only by
463- // the offsets of sc.rects: the shot keeps both unrounded.
464- for _, p := range sc.settledLayout {
465- if p.Hidden || p.Preview != 0 {
466- continue
467- }
468- r := rectShot{id: p.ID, rect: p.Rect}
469- if rm, ok := sc.rects[p.ID]; ok {
470- r.off, r.vel = rm.sample(now)
471- }
472- s.rects = append(s.rects, r)
483+ s.rects = append(s.rects, r)
484+ }
485+ if m.shown == nil && !m.ov.open {
486+ // A landing slide measures in the list it began on.
487+ list := m.Workspaces
488+ if m.switchList != nil {
489+ list = m.switchList
473490 }
474- if m.shown == nil {
475- // A landing slide measures in the list it began on.
476- list := m.Workspaces
477- if m.switchList != nil {
478- list = m.switchList
479- }
480- if i := indexOf(list, w); i >= 0 {
481- s.list = append(s.list, list...)
482- s.pos, s.ok = float64(i)+m.switchOff, true
483- }
491+ if i := indexOf(list, w); i >= 0 {
492+ s.list = append(s.list, list...)
493+ s.pos, s.ok = float64(i)+m.switchOff, true
484494 }
485495 }
486496 out = append(out, s)
@@ -494,8 +504,9 @@ func (c *Core) snapshot(now time.Time) []viewShot {
494504
495505 // transition starts the springs that take each screen from what before
496506 // showed to what the action left, at now. It does nothing with animations
497-// off, with the overview open before or after, or while a swipe follows the
498-// fingers on that screen. Per-window rect motions join the camera here.
507+// off, or while a swipe follows the fingers on that screen. Per-window rect
508+// motions join the camera here; with the overview open before or after
509+// there is no camera, only the cards' (transitionOverview).
499510 //
500511 // A workspace moved to another monitor (move-workspace-to-monitor-*, or a
501512 // named workspace pulled to the focused one by a "workspace <name>" bind
@@ -510,7 +521,11 @@ func (c *Core) transition(before []viewShot, now time.Time) {
510521 }
511522 for i := range before {
512523 b := &before[i]
513- if b.ws == nil || !c.hasScreen(b.sc) || b.sc.mon.ov.open || c.following(b.sc) {
524+ if b.ws == nil || !c.hasScreen(b.sc) || c.following(b.sc) {
525+ continue
526+ }
527+ if b.overview || b.sc.mon.ov.open {
528+ c.transitionOverview(b, now)
514529 continue
515530 }
516531 c.transitionCamera(b, before, now)
@@ -616,12 +631,54 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) {
616631 }
617632 }
618633
634+// transitionOverview starts the motions of an action that opened or closed
635+// the overview, or moved inside it (the selection, the rows). The camera
636+// springs stay stopped (ToggleOverview): every window drawn before and
637+// after moves from its shown rect to its settled one, its content scaled
638+// with it (a card's Zoom, rectMotion.show). A window drawn after and not
639+// before (another row, a column the overview hid) fades in with no motion
640+// of its own; one drawn before and not after just goes. Motions of the
641+// other state are dropped first: their rects are not the ones snapshot
642+// measured from.
643+func (c *Core) transitionOverview(b *viewShot, now time.Time) {
644+ sc := b.sc
645+ if b.overview != sc.mon.ov.open {
646+ sc.stopRects()
647+ }
648+ for _, p := range sc.mon.Layout() {
649+ if p.Hidden {
650+ continue
651+ }
652+ rm := sc.rects[p.ID]
653+ i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID })
654+ if i < 0 {
655+ c.retargetComponent(&rm.fade, &rm.df, 1, 0, now)
656+ } else if old := &b.rects[i]; old.rect != p.Rect {
657+ rm.scale = true
658+ c.retargetComponent(&rm.x, &rm.dx, float64(old.rect.X-p.Rect.X)+old.off.x, old.vel.x, now)
659+ c.retargetComponent(&rm.y, &rm.dy, float64(old.rect.Y-p.Rect.Y)+old.off.y, old.vel.y, now)
660+ c.retargetComponent(&rm.w, &rm.dw, float64(old.rect.W-p.Rect.W)+old.off.w, old.vel.w, now)
661+ c.retargetComponent(&rm.h, &rm.dh, float64(old.rect.H-p.Rect.H)+old.off.h, old.vel.h, now)
662+ } else {
663+ continue
664+ }
665+ if !rm.on() {
666+ delete(sc.rects, p.ID)
667+ continue
668+ }
669+ if sc.rects == nil {
670+ sc.rects = make(map[WindowID]rectMotion)
671+ }
672+ sc.rects[p.ID], sc.rectsWS = rm, sc.rectsOwner()
673+ }
674+}
675+
619676 // movedFrom is the shot of the other screen that showed the workspace b's
620677 // screen shows now (moved to it by an action), or nil.
621678 func movedFrom(b *viewShot, before []viewShot) *viewShot {
622679 w := b.sc.mon.Current()
623680 for i := range before {
624- if o := &before[i]; o.ws == w && o.sc != b.sc {
681+ if o := &before[i]; o.ws == w && o.sc != b.sc && !o.overview {
625682 return o
626683 }
627684 }
@@ -25,6 +25,9 @@ import (
25 type viewShot struct {25 type viewShot struct {
26 sc *screen26 sc *screen
27 ws *Workspace27 ws *Workspace
28+ // overview is set when the screen showed its overview: ws is then the
29+ // row on screen, the camera fields stay zero, and rects are the cards.
30+ overview bool
28 // viewX is ws's settled view and view where its columns were on screen31 // viewX is ws's settled view and view where its columns were on screen
29 // (viewX plus shift).32 // (viewX plus shift).
30 viewX int33 viewX int
@@ -40,8 +43,8 @@ type viewShot struct {
40 list []*Workspace43 list []*Workspace
41 pos float6444 pos float64
42 ok bool45 ok bool
43- // rects are the windows of ws the screen drew, hidden windows and46+ // rects are the windows the screen drew (cards in the overview),
44- // previews left out. The slice is reused like list.47+ // hidden windows left out. The slice is reused like list.
45 rects []rectShot48 rects []rectShot
46 }49 }
47 50
@@ -67,7 +70,9 @@ type rectShot struct {
67 // placement gets a Zoom of the settled zoom (its Preview, or 1) times70 // placement gets a Zoom of the settled zoom (its Preview, or 1) times
68 // shown.W / settled.W (refreshShown), which the scene carries as Preview.71 // shown.W / settled.W (refreshShown), which the scene carries as Preview.
69 // The walk shrinks content toward the content rect's top-left, so this72 // The walk shrinks content toward the content rect's top-left, so this
70-// suits aspect-preserving changes only.73+// suits aspect-preserving changes only. An overview card's settled zoom is
74+// its Preview: a window that opens into a card goes from 1 to Preview with
75+// its width, and back.
71 //76 //
72 // A leaving motion draws a window that closed or hid while it fades out:77 // A leaving motion draws a window that closed or hid while it fades out:
73 // left is its last settled placement, which refreshShown appends (Hidden78 // left is its last settled placement, which refreshShown appends (Hidden
@@ -161,7 +166,9 @@ func (m *rectMotion) show(p *Placement) {
161 }166 }
162 zoom *= float64(p.Rect.W) / float64(settled.W)167 zoom *= float64(p.Rect.W) / float64(settled.W)
163 if zoom >= 0.999 {168 if zoom >= 0.999 {
164- // Drawn at its size: no zoom (publish emits Preview 0).169+ // Drawn at its size: content is never magnified. publish
170+ // emits no Zoom for a window, and Zoom 1 for a card (whose
171+ // Preview would shrink it).
165 zoom = 1172 zoom = 1
166 }173 }
167 p.Zoom = zoom174 p.Zoom = zoom
@@ -183,8 +190,8 @@ func (c *Core) retargetComponent(m *motion, cur *float64, off, v float64, now ti
183 // refreshShown builds the layouts of every screen once per publish: the190 // refreshShown builds the layouts of every screen once per publish: the
184 // settled one (what configures are sized from) and the shown one (the same191 // settled one (what configures are sized from) and the shown one (the same
185 // with the rect motions applied: what is drawn and hit-tested). Rect motions192 // with the rect motions applied: what is drawn and hit-tested). Rect motions
186-// belong to the workspace they began on and to a closed overview: any other193+// belong to the workspace they began on, or to the open overview (rectsOwner):
187-// change drops them.194+// any other change drops them.
188 //195 //
189 // Leaving motions add an entry for a window the layout no longer shows:196 // Leaving motions add an entry for a window the layout no longer shows:
190 // appended to both layouts in ID order, Hidden (no input, focus, popups or197 // appended to both layouts in ID order, Hidden (no input, focus, popups or
@@ -193,7 +200,7 @@ func (c *Core) retargetComponent(m *motion, cur *float64, off, v float64, now ti
193 func (c *Core) refreshShown() {200 func (c *Core) refreshShown() {
194 for _, sc := range c.screens {201 for _, sc := range c.screens {
195 m := sc.mon202 m := sc.mon
196- if len(sc.rects) > 0 && (m.ov.open || sc.rectsWS != m.Current()) {203+ if len(sc.rects) > 0 && sc.rectsWS != sc.rectsOwner() {
197 sc.stopRects()204 sc.stopRects()
198 }205 }
199 sc.settledLayout = m.Layout()206 sc.settledLayout = m.Layout()
@@ -203,7 +210,7 @@ func (c *Core) refreshShown() {
203 // is the opposite: it goes once the layout shows the window.210 // is the opposite: it goes once the layout shows the window.
204 for id, rm := range sc.rects {211 for id, rm := range sc.rects {
205 shown := slices.ContainsFunc(sc.settledLayout, func(p Placement) bool {212 shown := slices.ContainsFunc(sc.settledLayout, func(p Placement) bool {
206- return p.ID == id && !p.Hidden && p.Preview == 0213+ return p.ID == id && !p.Hidden
207 })214 })
208 if shown == rm.leaving {215 if shown == rm.leaving {
209 delete(sc.rects, id)216 delete(sc.rects, id)
@@ -450,37 +457,40 @@ func (c *Core) snapshot(now time.Time) []viewShot {
450 s.rects = full[len(out)].rects[:0]457 s.rects = full[len(out)].rects[:0]
451 }458 }
452 s.sc = sc459 s.sc = sc
460+ w := m.Current()
461+ s.ws, s.overview = w, m.ov.open
453 if !m.ov.open {462 if !m.ov.open {
454- w := m.Current()463+ // The overview does not scroll or slide: no camera.
455- s.ws, s.viewX, s.view = w, w.ViewX, float64(w.ViewX)+w.shift464+ s.viewX, s.view = w.ViewX, float64(w.ViewX)+w.shift
456- if w.motion.on {465+ }
457- _, s.viewV = w.motion.sampleAt(now)466+ if w.motion.on {
467+ _, s.viewV = w.motion.sampleAt(now)
468+ }
469+ if m.switchMotion.on {
470+ _, s.switchV = m.switchMotion.sampleAt(now)
471+ }
472+ // settledLayout and shown are index-aligned and differ only by
473+ // the offsets of sc.rects: the shot keeps both unrounded. The
474+ // overview's cards are recorded like windows.
475+ for _, p := range sc.settledLayout {
476+ if p.Hidden {
477+ continue
458 }478 }
459- if m.switchMotion.on {479+ r := rectShot{id: p.ID, rect: p.Rect}
460- _, s.switchV = m.switchMotion.sampleAt(now)480+ if rm, ok := sc.rects[p.ID]; ok {
481+ r.off, r.vel = rm.sample(now)
461 }482 }
462- // settledLayout and shown are index-aligned and differ only by483+ s.rects = append(s.rects, r)
463- // the offsets of sc.rects: the shot keeps both unrounded.484+ }
464- for _, p := range sc.settledLayout {485+ if m.shown == nil && !m.ov.open {
465- if p.Hidden || p.Preview != 0 {486+ // A landing slide measures in the list it began on.
466- continue487+ list := m.Workspaces
467- }488+ if m.switchList != nil {
468- r := rectShot{id: p.ID, rect: p.Rect}489+ list = m.switchList
469- if rm, ok := sc.rects[p.ID]; ok {
470- r.off, r.vel = rm.sample(now)
471- }
472- s.rects = append(s.rects, r)
473 }490 }
474- if m.shown == nil {491+ if i := indexOf(list, w); i >= 0 {
475- // A landing slide measures in the list it began on.492+ s.list = append(s.list, list...)
476- list := m.Workspaces493+ s.pos, s.ok = float64(i)+m.switchOff, true
477- if m.switchList != nil {
478- list = m.switchList
479- }
480- if i := indexOf(list, w); i >= 0 {
481- s.list = append(s.list, list...)
482- s.pos, s.ok = float64(i)+m.switchOff, true
483- }
484 }494 }
485 }495 }
486 out = append(out, s)496 out = append(out, s)
@@ -494,8 +504,9 @@ func (c *Core) snapshot(now time.Time) []viewShot {
494 504
495 // transition starts the springs that take each screen from what before505 // transition starts the springs that take each screen from what before
496 // showed to what the action left, at now. It does nothing with animations506 // showed to what the action left, at now. It does nothing with animations
497-// off, with the overview open before or after, or while a swipe follows the507+// off, or while a swipe follows the fingers on that screen. Per-window rect
498-// fingers on that screen. Per-window rect motions join the camera here.508+// motions join the camera here; with the overview open before or after
509+// there is no camera, only the cards' (transitionOverview).
499 //510 //
500 // A workspace moved to another monitor (move-workspace-to-monitor-*, or a511 // A workspace moved to another monitor (move-workspace-to-monitor-*, or a
501 // named workspace pulled to the focused one by a "workspace <name>" bind512 // named workspace pulled to the focused one by a "workspace <name>" bind
@@ -510,7 +521,11 @@ func (c *Core) transition(before []viewShot, now time.Time) {
510 }521 }
511 for i := range before {522 for i := range before {
512 b := &before[i]523 b := &before[i]
513- if b.ws == nil || !c.hasScreen(b.sc) || b.sc.mon.ov.open || c.following(b.sc) {524+ if b.ws == nil || !c.hasScreen(b.sc) || c.following(b.sc) {
525+ continue
526+ }
527+ if b.overview || b.sc.mon.ov.open {
528+ c.transitionOverview(b, now)
514 continue529 continue
515 }530 }
516 c.transitionCamera(b, before, now)531 c.transitionCamera(b, before, now)
@@ -616,12 +631,54 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) {
616 }631 }
617 }632 }
618 633
634+// transitionOverview starts the motions of an action that opened or closed
635+// the overview, or moved inside it (the selection, the rows). The camera
636+// springs stay stopped (ToggleOverview): every window drawn before and
637+// after moves from its shown rect to its settled one, its content scaled
638+// with it (a card's Zoom, rectMotion.show). A window drawn after and not
639+// before (another row, a column the overview hid) fades in with no motion
640+// of its own; one drawn before and not after just goes. Motions of the
641+// other state are dropped first: their rects are not the ones snapshot
642+// measured from.
643+func (c *Core) transitionOverview(b *viewShot, now time.Time) {
644+ sc := b.sc
645+ if b.overview != sc.mon.ov.open {
646+ sc.stopRects()
647+ }
648+ for _, p := range sc.mon.Layout() {
649+ if p.Hidden {
650+ continue
651+ }
652+ rm := sc.rects[p.ID]
653+ i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID })
654+ if i < 0 {
655+ c.retargetComponent(&rm.fade, &rm.df, 1, 0, now)
656+ } else if old := &b.rects[i]; old.rect != p.Rect {
657+ rm.scale = true
658+ c.retargetComponent(&rm.x, &rm.dx, float64(old.rect.X-p.Rect.X)+old.off.x, old.vel.x, now)
659+ c.retargetComponent(&rm.y, &rm.dy, float64(old.rect.Y-p.Rect.Y)+old.off.y, old.vel.y, now)
660+ c.retargetComponent(&rm.w, &rm.dw, float64(old.rect.W-p.Rect.W)+old.off.w, old.vel.w, now)
661+ c.retargetComponent(&rm.h, &rm.dh, float64(old.rect.H-p.Rect.H)+old.off.h, old.vel.h, now)
662+ } else {
663+ continue
664+ }
665+ if !rm.on() {
666+ delete(sc.rects, p.ID)
667+ continue
668+ }
669+ if sc.rects == nil {
670+ sc.rects = make(map[WindowID]rectMotion)
671+ }
672+ sc.rects[p.ID], sc.rectsWS = rm, sc.rectsOwner()
673+ }
674+}
675+
619 // movedFrom is the shot of the other screen that showed the workspace b's676 // movedFrom is the shot of the other screen that showed the workspace b's
620 // screen shows now (moved to it by an action), or nil.677 // screen shows now (moved to it by an action), or nil.
621 func movedFrom(b *viewShot, before []viewShot) *viewShot {678 func movedFrom(b *viewShot, before []viewShot) *viewShot {
622 w := b.sc.mon.Current()679 w := b.sc.mon.Current()
623 for i := range before {680 for i := range before {
624- if o := &before[i]; o.ws == w && o.sc != b.sc {681+ if o := &before[i]; o.ws == w && o.sc != b.sc && !o.overview {
625 return o682 return o
626 }683 }
627 }684 }
A internal/core/transition_overview_internal_test.go +323 -0
@@ -0,0 +1,323 @@
1+package core
2+
3+import (
4+ "context"
5+ "math"
6+ "slices"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// overviewRig is indicatorCore with animations on, windows 1 and 2 in two
14+// columns of the first workspace and window 3 alone on the second, settled,
15+// the first workspace on screen.
16+func overviewRig(t *testing.T) (*Core, *indicatorClock, *screen) {
17+ t.Helper()
18+ c, ic := indicatorCore(t)
19+ c.cfg.Animations.On = true
20+ sc := c.cur()
21+ m := sc.mon
22+ m.AddWindow(1)
23+ m.AddWindow(2)
24+ m.Focus(1)
25+ m.AddWindow(3)
26+ m.Focus(0)
27+ settleShown(t, c)
28+ return c, ic, sc
29+}
30+
31+// toggleOverview is what a bind does: snapshot, toggle, transition.
32+func toggleOverview(c *Core, sc *screen, now time.Time) {
33+ shots := c.snapshot(now)
34+ sc.mon.ToggleOverview()
35+ c.transition(shots, now)
36+}
37+
38+// frame moves the clock by d, steps the springs and returns the scene.
39+func frame(t *testing.T, c *Core, ic *indicatorClock, d time.Duration) ports.Scene {
40+ t.Helper()
41+ ic.now = ic.now.Add(d)
42+ c.animate(ic.now, nil)
43+ return indicatorScene(t, c)
44+}
45+
46+// contentScale is the scale the renderer draws the window's content at:
47+// Zoom, else Preview, else 1 (vulkan walk).
48+func contentScale(w ports.SceneWindow) float64 {
49+ if w.Zoom > 0 {
50+ return w.Zoom
51+ }
52+ if w.Preview > 0 {
53+ return w.Preview
54+ }
55+ return 1
56+}
57+
58+// checkContent fails unless the content scale the renderer will use draws
59+// the content at the frame's size (within rounding) and never magnifies it.
60+func checkContent(t *testing.T, w ports.SceneWindow, full ports.Rect, when string) {
61+ t.Helper()
62+ k := contentScale(w)
63+ if k <= 0 || k > 1 {
64+ t.Fatalf("%s: content scale %v out of (0, 1]: %+v", when, k, w)
65+ }
66+ if got := k * float64(full.W); math.Abs(got-float64(w.Rect.W)) > 1.5 {
67+ t.Fatalf("%s: content drawn %.1f wide in a %d wide frame (scale %v, full %d): %+v", when, got, w.Rect.W, k, full.W, w)
68+ }
69+ if got := k * float64(full.H); math.Abs(got-float64(w.Rect.H)) > 1.5 {
70+ t.Fatalf("%s: content drawn %.1f high in a %d high frame (scale %v, full %d): %+v", when, got, w.Rect.H, k, full.H, w)
71+ }
72+}
73+
74+// Opening: a card starts at its window's rect with its content at full
75+// size, shrinks to its overview card with the content following every frame,
76+// and settles on the exact overview layout; the other rows fade in. Closing
77+// mirrors it and animations off are instant.
78+func TestOverviewOpenCloseAnimatesCards(t *testing.T) {
79+ c, ic, sc := overviewRig(t)
80+ full := map[WindowID]ports.Rect{}
81+ for _, id := range []WindowID{1, 2} {
82+ full[id] = sceneWindow(t, indicatorScene(t, c), id).Rect
83+ }
84+
85+ toggleOverview(c, sc, ic.now)
86+ if !sc.mon.ov.open || !c.animating() {
87+ t.Fatalf("overview open %v, animating %v", sc.mon.ov.open, c.animating())
88+ }
89+ s := indicatorScene(t, c)
90+ card := map[WindowID]Placement{}
91+ for _, p := range sc.settledLayout {
92+ card[p.ID] = p
93+ }
94+ for _, id := range []WindowID{1, 2} {
95+ w := sceneWindow(t, s, id)
96+ settled := sc.mon.Layout()[slices.IndexFunc(sc.mon.Layout(), func(p Placement) bool { return p.ID == id })]
97+ if w.Rect != full[id] || w.Preview != settled.Preview || w.Zoom != 1 {
98+ t.Fatalf("window %d first frame %+v, want the full rect %+v at content scale 1 (card preview %v)", id, w, full[id], settled.Preview)
99+ }
100+ checkContent(t, w, full[id], "first frame")
101+ }
102+ // The other row: invisible, drawn as a card, no motion of its own.
103+ w3 := sceneWindow(t, s, 3)
104+ if w3.Hidden || w3.Fade != 1 || w3.Preview == 0 || w3.Zoom != 0 {
105+ t.Fatalf("other row first frame %+v, want invisible card without zoom", w3)
106+ }
107+
108+ for i := range 6 {
109+ s = frame(t, c, ic, 25*time.Millisecond)
110+ for _, id := range []WindowID{1, 2} {
111+ w := sceneWindow(t, s, id)
112+ checkContent(t, w, full[id], "opening")
113+ if i == 1 {
114+ p := sc.mon.Layout()[slices.IndexFunc(sc.mon.Layout(), func(p Placement) bool { return p.ID == id })]
115+ if !(w.Rect.W < full[id].W && w.Rect.W > p.Rect.W) || !(w.Zoom > p.Preview && w.Zoom < 1) {
116+ t.Fatalf("window %d mid-way %+v, want a frame between %+v and %+v and a zoom between %v and 1", id, w, full[id], p.Rect, p.Preview)
117+ }
118+ }
119+ }
120+ if i == 1 {
121+ if w := sceneWindow(t, s, 3); !(w.Fade > 0 && w.Fade < 1) {
122+ t.Fatalf("other row mid-way fade %v, want strictly between 0 and 1", w.Fade)
123+ }
124+ }
125+ }
126+ s = frame(t, c, ic, 5*time.Second)
127+ for _, p := range sc.mon.Layout() {
128+ if p.Hidden {
129+ continue
130+ }
131+ w := sceneWindow(t, s, p.ID)
132+ if w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 || w.Preview != p.Preview {
133+ t.Fatalf("settled window %d %+v, want the exact overview layout %+v", p.ID, w, p)
134+ }
135+ }
136+ if c.animating() || len(sc.rects) != 0 {
137+ t.Fatalf("still animating after the settle: %d rects", len(sc.rects))
138+ }
139+
140+ // Close: the cards grow back to the windows, content following.
141+ toggleOverview(c, sc, ic.now)
142+ if sc.mon.ov.open {
143+ t.Fatal("the overview did not close")
144+ }
145+ s = indicatorScene(t, c)
146+ for _, id := range []WindowID{1, 2} {
147+ w := sceneWindow(t, s, id)
148+ if w.Preview != 0 || w.Rect != card[id].Rect {
149+ t.Fatalf("window %d first closing frame %+v, want the card rect %+v as a window", id, w, card[id].Rect)
150+ }
151+ checkContent(t, w, full[id], "first closing frame")
152+ }
153+ for i := range 6 {
154+ s = frame(t, c, ic, 25*time.Millisecond)
155+ for _, id := range []WindowID{1, 2} {
156+ w := sceneWindow(t, s, id)
157+ checkContent(t, w, full[id], "closing")
158+ if i == 1 && !(w.Rect.W > card[id].Rect.W && w.Rect.W < full[id].W && w.Zoom > 0 && w.Zoom < 1) {
159+ t.Fatalf("window %d mid-way closing %+v", id, w)
160+ }
161+ }
162+ }
163+ s = frame(t, c, ic, 5*time.Second)
164+ for _, id := range []WindowID{1, 2} {
165+ if w := sceneWindow(t, s, id); w.Rect != full[id] || w.Zoom != 0 || w.Preview != 0 {
166+ t.Fatalf("window %d settled after closing %+v, want %+v", id, w, full[id])
167+ }
168+ }
169+ if w := sceneWindow(t, s, 3); !w.Hidden {
170+ t.Fatalf("window 3 %+v after closing, want hidden (no fade-out)", w)
171+ }
172+ if c.animating() {
173+ t.Fatal("still animating after closing")
174+ }
175+}
176+
177+// With animations off the overview opens and closes at once.
178+func TestOverviewOffIsInstant(t *testing.T) {
179+ c, ic, sc := overviewRig(t)
180+ c.cfg.Animations.On = false
181+ toggleOverview(c, sc, ic.now)
182+ s := indicatorScene(t, c)
183+ for _, p := range sc.mon.Layout() {
184+ if p.Hidden {
185+ continue
186+ }
187+ if w := sceneWindow(t, s, p.ID); w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 {
188+ t.Fatalf("window %d %+v with animations off, want %+v", p.ID, w, p)
189+ }
190+ }
191+ if c.animating() || len(sc.rects) != 0 {
192+ t.Fatal("a motion runs with animations off")
193+ }
194+ toggleOverview(c, sc, ic.now)
195+ if indicatorScene(t, c); c.animating() || len(sc.rects) != 0 {
196+ t.Fatal("a motion runs after closing with animations off")
197+ }
198+}
199+
200+// Moving the selection inside the open overview slides the cards (its rows
201+// scroll): same rect motions, contents scaled; the row that comes in fades.
202+func TestOverviewNavigationAnimates(t *testing.T) {
203+ c, ic, sc := overviewRig(t)
204+ toggleOverview(c, sc, ic.now)
205+ s := frame(t, c, ic, 5*time.Second)
206+ before := map[WindowID]ports.SceneWindow{}
207+ for _, w := range s.Windows {
208+ before[w.ID] = w
209+ }
210+ if !before[3].Hidden && before[3].Fade != 0 {
211+ t.Fatalf("setup: %+v", before[3])
212+ }
213+ // Down: the second row becomes the current one; rows scroll up.
214+ shots := c.snapshot(ic.now)
215+ sc.mon.OverviewMove(0, 1)
216+ c.transition(shots, ic.now)
217+ s = indicatorScene(t, c)
218+ moved := 0
219+ for _, w := range s.Windows {
220+ if w.Hidden {
221+ continue
222+ }
223+ b, ok := before[w.ID]
224+ if !ok || b.Hidden {
225+ continue
226+ }
227+ if w.Rect != b.Rect {
228+ t.Fatalf("window %d jumped on the first frame: %+v then %+v", w.ID, b.Rect, w.Rect)
229+ }
230+ moved++
231+ }
232+ if moved == 0 {
233+ t.Fatalf("no card was shown before and after: %+v", s.Windows)
234+ }
235+ s = frame(t, c, ic, 40*time.Millisecond)
236+ slid := false
237+ for _, w := range s.Windows {
238+ if b, ok := before[w.ID]; ok && !b.Hidden && !w.Hidden && w.Rect != b.Rect {
239+ slid = true
240+ // A card moving between cards keeps its content scale.
241+ if k := contentScale(w); k <= 0 || k > 1 {
242+ t.Fatalf("window %d scale %v", w.ID, k)
243+ }
244+ }
245+ }
246+ if !slid {
247+ t.Fatal("no card moved mid-way")
248+ }
249+ s = frame(t, c, ic, 5*time.Second)
250+ for _, p := range sc.mon.Layout() {
251+ if p.Hidden {
252+ continue
253+ }
254+ if w := sceneWindow(t, s, p.ID); w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 {
255+ t.Fatalf("window %d settled %+v, want %+v", p.ID, w, p)
256+ }
257+ }
258+}
259+
260+// A click while the cards move picks the card where it is drawn (the shown
261+// layout), and a click on a still overview is unchanged.
262+func TestOverviewClickDuringOpenUsesShownCards(t *testing.T) {
263+ c, ic, sc := overviewRig(t)
264+ toggleOverview(c, sc, ic.now)
265+ s := frame(t, c, ic, 30*time.Millisecond)
266+ // The pointer sits on the right half of the output: the shown card of
267+ // window 2 covers it; its settled card does not reach that far left.
268+ w2 := sceneWindow(t, s, 2)
269+ x, y := float64(w2.Rect.X+w2.Rect.W-1), float64(w2.Rect.Y+w2.Rect.H/2)
270+ settled := sc.mon.Layout()[slices.IndexFunc(sc.mon.Layout(), func(p Placement) bool { return p.ID == 2 })]
271+ if x < float64(settled.Rect.X+settled.Rect.W) {
272+ t.Fatalf("setup: x %v is inside the settled card %+v", x, settled.Rect)
273+ }
274+ if id := sc.mon.overviewAt(x, y); id == 2 {
275+ t.Fatalf("setup: the settled layout picks 2 at %v,%v", x, y)
276+ }
277+ c.cursorX, c.cursorY = x+float64(sc.x), y+float64(sc.y)
278+ picked, err := c.overviewClick(context.Background())
279+ if err != nil || !picked {
280+ t.Fatalf("picked %v err %v during the opening", picked, err)
281+ }
282+ if sc.mon.ov.open {
283+ t.Fatal("the overview stayed open")
284+ }
285+ if id, _ := sc.mon.Focused(); id != 2 {
286+ t.Fatalf("focused %d, want 2 (the card drawn there)", id)
287+ }
288+}
289+
290+// A scale motion on a card never magnifies content: a shown frame wider than
291+// the card's settled one gives at most scale 1, and a card in flight at its
292+// full size still carries Zoom 1 (Preview alone would shrink it).
293+func TestCardZoomClampedAndEmitted(t *testing.T) {
294+ var rm rectMotion
295+ rm.scale, rm.dw = true, 400
296+ p := Placement{ID: 1, Rect: Rect{W: 100, H: 60}, Preview: overviewCardZoom}
297+ rm.show(&p)
298+ if p.Zoom != 1 || p.Preview != overviewCardZoom {
299+ t.Fatalf("shown %+v, want zoom clamped to 1 and the card's preview", p)
300+ }
301+ c, ic, sc := overviewRig(t)
302+ toggleOverview(c, sc, ic.now)
303+ if w := sceneWindow(t, indicatorScene(t, c), 1); w.Zoom != 1 || w.Preview == 0 {
304+ t.Fatalf("card at full size emitted %+v, want Zoom 1 with its Preview", w)
305+ }
306+}
307+
308+// Rect motions belong to the overview state: a state change with no
309+// transition (animations off meanwhile, a session lock) drops them, and
310+// leaving the overview never keeps a card's motion on a window.
311+func TestOverviewRectsDroppedOnStateChange(t *testing.T) {
312+ c, ic, sc := overviewRig(t)
313+ toggleOverview(c, sc, ic.now)
314+ indicatorScene(t, c)
315+ if len(sc.rects) == 0 {
316+ t.Fatal("no motion for the opening")
317+ }
318+ sc.mon.ToggleOverview()
319+ indicatorScene(t, c)
320+ if len(sc.rects) != 0 {
321+ t.Fatalf("%d card motions survived the close without a transition", len(sc.rects))
322+ }
323+}
@@ -0,0 +1,323 @@
1+package core
2+
3+import (
4+ "context"
5+ "math"
6+ "slices"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// overviewRig is indicatorCore with animations on, windows 1 and 2 in two
14+// columns of the first workspace and window 3 alone on the second, settled,
15+// the first workspace on screen.
16+func overviewRig(t *testing.T) (*Core, *indicatorClock, *screen) {
17+ t.Helper()
18+ c, ic := indicatorCore(t)
19+ c.cfg.Animations.On = true
20+ sc := c.cur()
21+ m := sc.mon
22+ m.AddWindow(1)
23+ m.AddWindow(2)
24+ m.Focus(1)
25+ m.AddWindow(3)
26+ m.Focus(0)
27+ settleShown(t, c)
28+ return c, ic, sc
29+}
30+
31+// toggleOverview is what a bind does: snapshot, toggle, transition.
32+func toggleOverview(c *Core, sc *screen, now time.Time) {
33+ shots := c.snapshot(now)
34+ sc.mon.ToggleOverview()
35+ c.transition(shots, now)
36+}
37+
38+// frame moves the clock by d, steps the springs and returns the scene.
39+func frame(t *testing.T, c *Core, ic *indicatorClock, d time.Duration) ports.Scene {
40+ t.Helper()
41+ ic.now = ic.now.Add(d)
42+ c.animate(ic.now, nil)
43+ return indicatorScene(t, c)
44+}
45+
46+// contentScale is the scale the renderer draws the window's content at:
47+// Zoom, else Preview, else 1 (vulkan walk).
48+func contentScale(w ports.SceneWindow) float64 {
49+ if w.Zoom > 0 {
50+ return w.Zoom
51+ }
52+ if w.Preview > 0 {
53+ return w.Preview
54+ }
55+ return 1
56+}
57+
58+// checkContent fails unless the content scale the renderer will use draws
59+// the content at the frame's size (within rounding) and never magnifies it.
60+func checkContent(t *testing.T, w ports.SceneWindow, full ports.Rect, when string) {
61+ t.Helper()
62+ k := contentScale(w)
63+ if k <= 0 || k > 1 {
64+ t.Fatalf("%s: content scale %v out of (0, 1]: %+v", when, k, w)
65+ }
66+ if got := k * float64(full.W); math.Abs(got-float64(w.Rect.W)) > 1.5 {
67+ t.Fatalf("%s: content drawn %.1f wide in a %d wide frame (scale %v, full %d): %+v", when, got, w.Rect.W, k, full.W, w)
68+ }
69+ if got := k * float64(full.H); math.Abs(got-float64(w.Rect.H)) > 1.5 {
70+ t.Fatalf("%s: content drawn %.1f high in a %d high frame (scale %v, full %d): %+v", when, got, w.Rect.H, k, full.H, w)
71+ }
72+}
73+
74+// Opening: a card starts at its window's rect with its content at full
75+// size, shrinks to its overview card with the content following every frame,
76+// and settles on the exact overview layout; the other rows fade in. Closing
77+// mirrors it and animations off are instant.
78+func TestOverviewOpenCloseAnimatesCards(t *testing.T) {
79+ c, ic, sc := overviewRig(t)
80+ full := map[WindowID]ports.Rect{}
81+ for _, id := range []WindowID{1, 2} {
82+ full[id] = sceneWindow(t, indicatorScene(t, c), id).Rect
83+ }
84+
85+ toggleOverview(c, sc, ic.now)
86+ if !sc.mon.ov.open || !c.animating() {
87+ t.Fatalf("overview open %v, animating %v", sc.mon.ov.open, c.animating())
88+ }
89+ s := indicatorScene(t, c)
90+ card := map[WindowID]Placement{}
91+ for _, p := range sc.settledLayout {
92+ card[p.ID] = p
93+ }
94+ for _, id := range []WindowID{1, 2} {
95+ w := sceneWindow(t, s, id)
96+ settled := sc.mon.Layout()[slices.IndexFunc(sc.mon.Layout(), func(p Placement) bool { return p.ID == id })]
97+ if w.Rect != full[id] || w.Preview != settled.Preview || w.Zoom != 1 {
98+ t.Fatalf("window %d first frame %+v, want the full rect %+v at content scale 1 (card preview %v)", id, w, full[id], settled.Preview)
99+ }
100+ checkContent(t, w, full[id], "first frame")
101+ }
102+ // The other row: invisible, drawn as a card, no motion of its own.
103+ w3 := sceneWindow(t, s, 3)
104+ if w3.Hidden || w3.Fade != 1 || w3.Preview == 0 || w3.Zoom != 0 {
105+ t.Fatalf("other row first frame %+v, want invisible card without zoom", w3)
106+ }
107+
108+ for i := range 6 {
109+ s = frame(t, c, ic, 25*time.Millisecond)
110+ for _, id := range []WindowID{1, 2} {
111+ w := sceneWindow(t, s, id)
112+ checkContent(t, w, full[id], "opening")
113+ if i == 1 {
114+ p := sc.mon.Layout()[slices.IndexFunc(sc.mon.Layout(), func(p Placement) bool { return p.ID == id })]
115+ if !(w.Rect.W < full[id].W && w.Rect.W > p.Rect.W) || !(w.Zoom > p.Preview && w.Zoom < 1) {
116+ t.Fatalf("window %d mid-way %+v, want a frame between %+v and %+v and a zoom between %v and 1", id, w, full[id], p.Rect, p.Preview)
117+ }
118+ }
119+ }
120+ if i == 1 {
121+ if w := sceneWindow(t, s, 3); !(w.Fade > 0 && w.Fade < 1) {
122+ t.Fatalf("other row mid-way fade %v, want strictly between 0 and 1", w.Fade)
123+ }
124+ }
125+ }
126+ s = frame(t, c, ic, 5*time.Second)
127+ for _, p := range sc.mon.Layout() {
128+ if p.Hidden {
129+ continue
130+ }
131+ w := sceneWindow(t, s, p.ID)
132+ if w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 || w.Preview != p.Preview {
133+ t.Fatalf("settled window %d %+v, want the exact overview layout %+v", p.ID, w, p)
134+ }
135+ }
136+ if c.animating() || len(sc.rects) != 0 {
137+ t.Fatalf("still animating after the settle: %d rects", len(sc.rects))
138+ }
139+
140+ // Close: the cards grow back to the windows, content following.
141+ toggleOverview(c, sc, ic.now)
142+ if sc.mon.ov.open {
143+ t.Fatal("the overview did not close")
144+ }
145+ s = indicatorScene(t, c)
146+ for _, id := range []WindowID{1, 2} {
147+ w := sceneWindow(t, s, id)
148+ if w.Preview != 0 || w.Rect != card[id].Rect {
149+ t.Fatalf("window %d first closing frame %+v, want the card rect %+v as a window", id, w, card[id].Rect)
150+ }
151+ checkContent(t, w, full[id], "first closing frame")
152+ }
153+ for i := range 6 {
154+ s = frame(t, c, ic, 25*time.Millisecond)
155+ for _, id := range []WindowID{1, 2} {
156+ w := sceneWindow(t, s, id)
157+ checkContent(t, w, full[id], "closing")
158+ if i == 1 && !(w.Rect.W > card[id].Rect.W && w.Rect.W < full[id].W && w.Zoom > 0 && w.Zoom < 1) {
159+ t.Fatalf("window %d mid-way closing %+v", id, w)
160+ }
161+ }
162+ }
163+ s = frame(t, c, ic, 5*time.Second)
164+ for _, id := range []WindowID{1, 2} {
165+ if w := sceneWindow(t, s, id); w.Rect != full[id] || w.Zoom != 0 || w.Preview != 0 {
166+ t.Fatalf("window %d settled after closing %+v, want %+v", id, w, full[id])
167+ }
168+ }
169+ if w := sceneWindow(t, s, 3); !w.Hidden {
170+ t.Fatalf("window 3 %+v after closing, want hidden (no fade-out)", w)
171+ }
172+ if c.animating() {
173+ t.Fatal("still animating after closing")
174+ }
175+}
176+
177+// With animations off the overview opens and closes at once.
178+func TestOverviewOffIsInstant(t *testing.T) {
179+ c, ic, sc := overviewRig(t)
180+ c.cfg.Animations.On = false
181+ toggleOverview(c, sc, ic.now)
182+ s := indicatorScene(t, c)
183+ for _, p := range sc.mon.Layout() {
184+ if p.Hidden {
185+ continue
186+ }
187+ if w := sceneWindow(t, s, p.ID); w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 {
188+ t.Fatalf("window %d %+v with animations off, want %+v", p.ID, w, p)
189+ }
190+ }
191+ if c.animating() || len(sc.rects) != 0 {
192+ t.Fatal("a motion runs with animations off")
193+ }
194+ toggleOverview(c, sc, ic.now)
195+ if indicatorScene(t, c); c.animating() || len(sc.rects) != 0 {
196+ t.Fatal("a motion runs after closing with animations off")
197+ }
198+}
199+
200+// Moving the selection inside the open overview slides the cards (its rows
201+// scroll): same rect motions, contents scaled; the row that comes in fades.
202+func TestOverviewNavigationAnimates(t *testing.T) {
203+ c, ic, sc := overviewRig(t)
204+ toggleOverview(c, sc, ic.now)
205+ s := frame(t, c, ic, 5*time.Second)
206+ before := map[WindowID]ports.SceneWindow{}
207+ for _, w := range s.Windows {
208+ before[w.ID] = w
209+ }
210+ if !before[3].Hidden && before[3].Fade != 0 {
211+ t.Fatalf("setup: %+v", before[3])
212+ }
213+ // Down: the second row becomes the current one; rows scroll up.
214+ shots := c.snapshot(ic.now)
215+ sc.mon.OverviewMove(0, 1)
216+ c.transition(shots, ic.now)
217+ s = indicatorScene(t, c)
218+ moved := 0
219+ for _, w := range s.Windows {
220+ if w.Hidden {
221+ continue
222+ }
223+ b, ok := before[w.ID]
224+ if !ok || b.Hidden {
225+ continue
226+ }
227+ if w.Rect != b.Rect {
228+ t.Fatalf("window %d jumped on the first frame: %+v then %+v", w.ID, b.Rect, w.Rect)
229+ }
230+ moved++
231+ }
232+ if moved == 0 {
233+ t.Fatalf("no card was shown before and after: %+v", s.Windows)
234+ }
235+ s = frame(t, c, ic, 40*time.Millisecond)
236+ slid := false
237+ for _, w := range s.Windows {
238+ if b, ok := before[w.ID]; ok && !b.Hidden && !w.Hidden && w.Rect != b.Rect {
239+ slid = true
240+ // A card moving between cards keeps its content scale.
241+ if k := contentScale(w); k <= 0 || k > 1 {
242+ t.Fatalf("window %d scale %v", w.ID, k)
243+ }
244+ }
245+ }
246+ if !slid {
247+ t.Fatal("no card moved mid-way")
248+ }
249+ s = frame(t, c, ic, 5*time.Second)
250+ for _, p := range sc.mon.Layout() {
251+ if p.Hidden {
252+ continue
253+ }
254+ if w := sceneWindow(t, s, p.ID); w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 {
255+ t.Fatalf("window %d settled %+v, want %+v", p.ID, w, p)
256+ }
257+ }
258+}
259+
260+// A click while the cards move picks the card where it is drawn (the shown
261+// layout), and a click on a still overview is unchanged.
262+func TestOverviewClickDuringOpenUsesShownCards(t *testing.T) {
263+ c, ic, sc := overviewRig(t)
264+ toggleOverview(c, sc, ic.now)
265+ s := frame(t, c, ic, 30*time.Millisecond)
266+ // The pointer sits on the right half of the output: the shown card of
267+ // window 2 covers it; its settled card does not reach that far left.
268+ w2 := sceneWindow(t, s, 2)
269+ x, y := float64(w2.Rect.X+w2.Rect.W-1), float64(w2.Rect.Y+w2.Rect.H/2)
270+ settled := sc.mon.Layout()[slices.IndexFunc(sc.mon.Layout(), func(p Placement) bool { return p.ID == 2 })]
271+ if x < float64(settled.Rect.X+settled.Rect.W) {
272+ t.Fatalf("setup: x %v is inside the settled card %+v", x, settled.Rect)
273+ }
274+ if id := sc.mon.overviewAt(x, y); id == 2 {
275+ t.Fatalf("setup: the settled layout picks 2 at %v,%v", x, y)
276+ }
277+ c.cursorX, c.cursorY = x+float64(sc.x), y+float64(sc.y)
278+ picked, err := c.overviewClick(context.Background())
279+ if err != nil || !picked {
280+ t.Fatalf("picked %v err %v during the opening", picked, err)
281+ }
282+ if sc.mon.ov.open {
283+ t.Fatal("the overview stayed open")
284+ }
285+ if id, _ := sc.mon.Focused(); id != 2 {
286+ t.Fatalf("focused %d, want 2 (the card drawn there)", id)
287+ }
288+}
289+
290+// A scale motion on a card never magnifies content: a shown frame wider than
291+// the card's settled one gives at most scale 1, and a card in flight at its
292+// full size still carries Zoom 1 (Preview alone would shrink it).
293+func TestCardZoomClampedAndEmitted(t *testing.T) {
294+ var rm rectMotion
295+ rm.scale, rm.dw = true, 400
296+ p := Placement{ID: 1, Rect: Rect{W: 100, H: 60}, Preview: overviewCardZoom}
297+ rm.show(&p)
298+ if p.Zoom != 1 || p.Preview != overviewCardZoom {
299+ t.Fatalf("shown %+v, want zoom clamped to 1 and the card's preview", p)
300+ }
301+ c, ic, sc := overviewRig(t)
302+ toggleOverview(c, sc, ic.now)
303+ if w := sceneWindow(t, indicatorScene(t, c), 1); w.Zoom != 1 || w.Preview == 0 {
304+ t.Fatalf("card at full size emitted %+v, want Zoom 1 with its Preview", w)
305+ }
306+}
307+
308+// Rect motions belong to the overview state: a state change with no
309+// transition (animations off meanwhile, a session lock) drops them, and
310+// leaving the overview never keeps a card's motion on a window.
311+func TestOverviewRectsDroppedOnStateChange(t *testing.T) {
312+ c, ic, sc := overviewRig(t)
313+ toggleOverview(c, sc, ic.now)
314+ indicatorScene(t, c)
315+ if len(sc.rects) == 0 {
316+ t.Fatal("no motion for the opening")
317+ }
318+ sc.mon.ToggleOverview()
319+ indicatorScene(t, c)
320+ if len(sc.rects) != 0 {
321+ t.Fatalf("%d card motions survived the close without a transition", len(sc.rects))
322+ }
323+}
M internal/core/transition_test.go +7 -3
@@ -130,9 +130,9 @@ func TestAnimationsOffKeysLandAtOnce(t *testing.T) {
130130 r.noFrameScene(t)
131131 }
132132
133-// With the overview open before or after, nothing slides: opening it on a
134-// scrolled view starts no motion, and neither does a bind that moves the view
135-// while it is open.
133+// The overview has no camera: opening it on a scrolled view, a bind that
134+// moves the selection and closing it only move the cards (rect motions), which
135+// settle: no spring runs afterwards and nothing slides the view.
136136 func TestNoTransitionWithOverview(t *testing.T) {
137137 for _, tc := range []struct {
138138 name string
@@ -150,13 +150,17 @@ func TestNoTransitionWithOverview(t *testing.T) {
150150 r.key(t, "Right", ports.ModAlt) // 2 is on screen: no scroll
151151 r.keySettled(t, "Right") // the view scrolls away from 0
152152 r.keySettled(t, "Right")
153+ // settleAll: a bind that moves nothing starts no spring.
153154 r.key(t, "o", ports.ModAlt)
155+ r.settleAll(t, wide.Name)
154156 r.noFrameScene(t)
155157 if tc.sym != "" {
156158 r.key(t, tc.sym, tc.mods)
159+ r.settleAll(t, wide.Name)
157160 r.noFrameScene(t)
158161 }
159162 r.key(t, "o", ports.ModAlt)
163+ r.settleAll(t, wide.Name)
160164 r.noFrameScene(t)
161165 })
162166 }
@@ -130,9 +130,9 @@ func TestAnimationsOffKeysLandAtOnce(t *testing.T) {
130 r.noFrameScene(t)130 r.noFrameScene(t)
131 }131 }
132 132
133-// With the overview open before or after, nothing slides: opening it on a133+// The overview has no camera: opening it on a scrolled view, a bind that
134-// scrolled view starts no motion, and neither does a bind that moves the view134+// moves the selection and closing it only move the cards (rect motions), which
135-// while it is open.135+// settle: no spring runs afterwards and nothing slides the view.
136 func TestNoTransitionWithOverview(t *testing.T) {136 func TestNoTransitionWithOverview(t *testing.T) {
137 for _, tc := range []struct {137 for _, tc := range []struct {
138 name string138 name string
@@ -150,13 +150,17 @@ func TestNoTransitionWithOverview(t *testing.T) {
150 r.key(t, "Right", ports.ModAlt) // 2 is on screen: no scroll150 r.key(t, "Right", ports.ModAlt) // 2 is on screen: no scroll
151 r.keySettled(t, "Right") // the view scrolls away from 0151 r.keySettled(t, "Right") // the view scrolls away from 0
152 r.keySettled(t, "Right")152 r.keySettled(t, "Right")
153+ // settleAll: a bind that moves nothing starts no spring.
153 r.key(t, "o", ports.ModAlt)154 r.key(t, "o", ports.ModAlt)
155+ r.settleAll(t, wide.Name)
154 r.noFrameScene(t)156 r.noFrameScene(t)
155 if tc.sym != "" {157 if tc.sym != "" {
156 r.key(t, tc.sym, tc.mods)158 r.key(t, tc.sym, tc.mods)
159+ r.settleAll(t, wide.Name)
157 r.noFrameScene(t)160 r.noFrameScene(t)
158 }161 }
159 r.key(t, "o", ports.ModAlt)162 r.key(t, "o", ports.ModAlt)
163+ r.settleAll(t, wide.Name)
160 r.noFrameScene(t)164 r.noFrameScene(t)
161 })165 })
162 }166 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close