detached 4e1c07b

fix(core): review of the overview animation: faded cards, carried fades, min-ratio zoom, filtered snapshots

Cards with Fade >= 0.5 take no click, a running fade carries over an
overview state change, a scale motion's zoom follows the smaller of the
width and height ratios, input snapshots only for events that can step the
overview, a map with the overview open is not an overview action, and the
no-camera-slide check is restored as an internal test.
commit
4e1c07bc19ccf6087aa7aaccec32fe4ad7f3d848
author
brice · · 2026-10-04 23:29 UTC
parent
18098d8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +191 -18
M internal/core/input.go +10 -2
@@ -143,8 +143,13 @@ 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+ // Only a scroll that can step needs the shot (not the end of
147+ // one, nor the rest of a finger scroll that stepped).
146148 now := c.now()
147- shots := c.snapshot(now)
149+ var shots []viewShot
150+ if m := c.cur().mon; !v.Vertical.Stop && !v.Horizontal.Stop && !(v.Source == ports.AxisFinger && m.ov.scrolled) {
151+ shots = c.snapshot(now)
152+ }
148153 if c.cur().mon.overviewScroll(v) {
149154 c.transition(shots, now)
150155 if err := c.workspaceVisible(ctx, true); err != nil {
@@ -196,7 +201,10 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
196201 // A launcher or a menu holding the keyboard gets them first.
197202 if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() {
198203 now := c.now()
199- shots := c.snapshot(now)
204+ var shots []viewShot
205+ if key.Pressed && overviewKeyNamed(key) {
206+ shots = c.snapshot(now)
207+ }
200208 if key.Pressed && mon.overviewKey(key) {
201209 c.pressed[heldKey(key)] = true
202210 c.transition(shots, now)
@@ -143,8 +143,13 @@ 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+ // Only a scroll that can step needs the shot (not the end of
147+ // one, nor the rest of a finger scroll that stepped).
146 now := c.now()148 now := c.now()
147- shots := c.snapshot(now)149+ var shots []viewShot
150+ if m := c.cur().mon; !v.Vertical.Stop && !v.Horizontal.Stop && !(v.Source == ports.AxisFinger && m.ov.scrolled) {
151+ shots = c.snapshot(now)
152+ }
148 if c.cur().mon.overviewScroll(v) {153 if c.cur().mon.overviewScroll(v) {
149 c.transition(shots, now)154 c.transition(shots, now)
150 if err := c.workspaceVisible(ctx, true); err != nil {155 if err := c.workspaceVisible(ctx, true); err != nil {
@@ -196,7 +201,10 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
196 // A launcher or a menu holding the keyboard gets them first.201 // A launcher or a menu holding the keyboard gets them first.
197 if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() {202 if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() {
198 now := c.now()203 now := c.now()
199- shots := c.snapshot(now)204+ var shots []viewShot
205+ if key.Pressed && overviewKeyNamed(key) {
206+ shots = c.snapshot(now)
207+ }
200 if key.Pressed && mon.overviewKey(key) {208 if key.Pressed && mon.overviewKey(key) {
201 c.pressed[heldKey(key)] = true209 c.pressed[heldKey(key)] = true
202 c.transition(shots, now)210 c.transition(shots, now)
M internal/core/overview.go +20 -3
@@ -810,6 +810,9 @@ func overviewOutline(layout []Placement, b int) []ports.Separator {
810810 return nil
811811 }
812812
813+// overviewClickFade is the fade from which a card takes no click.
814+const overviewClickFade = 0.5
815+
813816 // overviewAt is the preview under the output-local point, if any. Front
814817 // cards take priority over peeks, including peeks appended after a front
815818 // tile in another row's layout.
@@ -818,19 +821,20 @@ func (m *Monitor) overviewAt(x, y float64) WindowID {
818821 }
819822
820823 // 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.
824+// shown one, so a card is hit where it is drawn while it moves. A card
825+// that is nearly invisible (still fading in) is not clickable.
822826 func overviewIn(layout []Placement, x, y float64) WindowID {
823827 for i := len(layout) - 1; i >= 0; i-- {
824828 p := layout[i]
825829 r := p.Rect
826- if p.Preview > 0 && !p.Hidden && !p.Peek && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) {
830+ if p.Preview > 0 && !p.Hidden && !p.Peek && p.Fade < overviewClickFade && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) {
827831 return p.ID
828832 }
829833 }
830834 for i := len(layout) - 1; i >= 0; i-- {
831835 p := layout[i]
832836 r := p.Rect
833- if p.Preview > 0 && !p.Hidden && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) {
837+ if p.Preview > 0 && !p.Hidden && p.Fade < overviewClickFade && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) {
834838 return p.ID
835839 }
836840 }
@@ -1006,6 +1010,19 @@ func (c *Core) overviewKeyboardTaken() bool {
10061010 return layer || c.popups[id] != nil
10071011 }
10081012
1013+// overviewKeyNamed reports whether key is one overviewKey handles (a bare key); it
1014+// lets input snapshot only for those.
1015+func overviewKeyNamed(key ports.KeyEvent) bool {
1016+ if key.Mods != 0 {
1017+ return false
1018+ }
1019+ switch keyName(key.Keysym) {
1020+ case "h", "Left", "l", "Right", "k", "Up", "j", "Down", "Return", "KP_Enter", "Escape":
1021+ return true
1022+ }
1023+ return false
1024+}
1025+
10091026 // overviewKey runs an overview key: true when the key was one.
10101027 func (m *Monitor) overviewKey(key ports.KeyEvent) bool {
10111028 if key.Mods != 0 {
@@ -810,6 +810,9 @@ func overviewOutline(layout []Placement, b int) []ports.Separator {
810 return nil810 return nil
811 }811 }
812 812
813+// overviewClickFade is the fade from which a card takes no click.
814+const overviewClickFade = 0.5
815+
813 // overviewAt is the preview under the output-local point, if any. Front816 // overviewAt is the preview under the output-local point, if any. Front
814 // cards take priority over peeks, including peeks appended after a front817 // cards take priority over peeks, including peeks appended after a front
815 // tile in another row's layout.818 // tile in another row's layout.
@@ -818,19 +821,20 @@ func (m *Monitor) overviewAt(x, y float64) WindowID {
818 }821 }
819 822
820 // overviewIn is overviewAt in a given layout: the click path passes the823 // 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.824+// shown one, so a card is hit where it is drawn while it moves. A card
825+// that is nearly invisible (still fading in) is not clickable.
822 func overviewIn(layout []Placement, x, y float64) WindowID {826 func overviewIn(layout []Placement, x, y float64) WindowID {
823 for i := len(layout) - 1; i >= 0; i-- {827 for i := len(layout) - 1; i >= 0; i-- {
824 p := layout[i]828 p := layout[i]
825 r := p.Rect829 r := p.Rect
826- if p.Preview > 0 && !p.Hidden && !p.Peek && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) {830+ if p.Preview > 0 && !p.Hidden && !p.Peek && p.Fade < overviewClickFade && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) {
827 return p.ID831 return p.ID
828 }832 }
829 }833 }
830 for i := len(layout) - 1; i >= 0; i-- {834 for i := len(layout) - 1; i >= 0; i-- {
831 p := layout[i]835 p := layout[i]
832 r := p.Rect836 r := p.Rect
833- if p.Preview > 0 && !p.Hidden && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) {837+ if p.Preview > 0 && !p.Hidden && p.Fade < overviewClickFade && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) {
834 return p.ID838 return p.ID
835 }839 }
836 }840 }
@@ -1006,6 +1010,19 @@ func (c *Core) overviewKeyboardTaken() bool {
1006 return layer || c.popups[id] != nil1010 return layer || c.popups[id] != nil
1007 }1011 }
1008 1012
1013+// overviewKeyNamed reports whether key is one overviewKey handles (a bare key); it
1014+// lets input snapshot only for those.
1015+func overviewKeyNamed(key ports.KeyEvent) bool {
1016+ if key.Mods != 0 {
1017+ return false
1018+ }
1019+ switch keyName(key.Keysym) {
1020+ case "h", "Left", "l", "Right", "k", "Up", "j", "Down", "Return", "KP_Enter", "Escape":
1021+ return true
1022+ }
1023+ return false
1024+}
1025+
1009 // overviewKey runs an overview key: true when the key was one.1026 // overviewKey runs an overview key: true when the key was one.
1010 func (m *Monitor) overviewKey(key ports.KeyEvent) bool {1027 func (m *Monitor) overviewKey(key ports.KeyEvent) bool {
1011 if key.Mods != 0 {1028 if key.Mods != 0 {
M internal/core/overview_core_test.go +12 -6
@@ -49,12 +49,18 @@ func noButtonSent(t *testing.T, ch chan ports.ClientCommand) {
4949 }
5050
5151 func TestOverviewCoreClickPicksOnPointerOutput(t *testing.T) {
52- r := startMulti(t, nil, left, right)
53- r.mapWindow(t, 1)
54- r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
55- r.mapWindow(t, 2)
56- r.mapWindow(t, 3)
57- set := r.key(t, "o", ports.ModAlt)
52+ both(t, func(t *testing.T, animated bool) {
53+ overviewClickPicksOnPointerOutput(t, startLanding(t, animated, nil, left, right))
54+ })
55+}
56+
57+func overviewClickPicksOnPointerOutput(t *testing.T, r *landRig) {
58+ r.mapLanded(t, 1)
59+ r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl)
60+ r.mapLanded(t, 2)
61+ r.mapLanded(t, 3)
62+ // Settled: a card still fading in takes no click.
63+ set := r.keyLanded(t, "o", ports.ModAlt)
5864 p, ok := scenePreview(outputScene(t, set, "DP-2"), 2)
5965 if !ok {
6066 t.Fatal("no preview of window 2 on DP-2")
@@ -49,12 +49,18 @@ func noButtonSent(t *testing.T, ch chan ports.ClientCommand) {
49 }49 }
50 50
51 func TestOverviewCoreClickPicksOnPointerOutput(t *testing.T) {51 func TestOverviewCoreClickPicksOnPointerOutput(t *testing.T) {
52- r := startMulti(t, nil, left, right)52+ both(t, func(t *testing.T, animated bool) {
53- r.mapWindow(t, 1)53+ overviewClickPicksOnPointerOutput(t, startLanding(t, animated, nil, left, right))
54- r.key(t, "Right", ports.ModAlt|ports.ModCtrl)54+ })
55- r.mapWindow(t, 2)55+}
56- r.mapWindow(t, 3)56+
57- set := r.key(t, "o", ports.ModAlt)57+func overviewClickPicksOnPointerOutput(t *testing.T, r *landRig) {
58+ r.mapLanded(t, 1)
59+ r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl)
60+ r.mapLanded(t, 2)
61+ r.mapLanded(t, 3)
62+ // Settled: a card still fading in takes no click.
63+ set := r.keyLanded(t, "o", ports.ModAlt)
58 p, ok := scenePreview(outputScene(t, set, "DP-2"), 2)64 p, ok := scenePreview(outputScene(t, set, "DP-2"), 2)
59 if !ok {65 if !ok {
60 t.Fatal("no preview of window 2 on DP-2")66 t.Fatal("no preview of window 2 on DP-2")
M internal/core/swipe_test.go +1 -1
@@ -697,7 +697,7 @@ func TestOverviewOpenedMidSwipeDropsIt(t *testing.T) {
697697 // own springs (the cards zooming back) start when it is handled,
698698 // which may follow a first flip: flip until the scene is settled.
699699 var s ports.Scene
700- for deadline := time.Now().Add(time.Second); ; {
700+ for deadline := time.Now().Add(5 * time.Second); ; {
701701 r.advance(5 * time.Second)
702702 r.frames <- ports.OutputFrame{Output: wide.Name}
703703 select {
@@ -697,7 +697,7 @@ func TestOverviewOpenedMidSwipeDropsIt(t *testing.T) {
697 // own springs (the cards zooming back) start when it is handled,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.698 // which may follow a first flip: flip until the scene is settled.
699 var s ports.Scene699 var s ports.Scene
700- for deadline := time.Now().Add(time.Second); ; {700+ for deadline := time.Now().Add(5 * time.Second); ; {
701 r.advance(5 * time.Second)701 r.advance(5 * time.Second)
702 r.frames <- ports.OutputFrame{Output: wide.Name}702 r.frames <- ports.OutputFrame{Output: wide.Name}
703 select {703 select {
M internal/core/transition.go +24 -5
@@ -59,6 +59,9 @@ type rectShot struct {
5959 id WindowID
6060 rect Rect
6161 off, vel rectOffsets
62+ // fade and fadeV are the fade offset of a running fade motion and its
63+ // speed, so an overview toggle carries it over (transitionOverview).
64+ fade, fadeV float64
6265 }
6366
6467 // rectMotion takes a window's drawn rect to its settled one: each component
@@ -70,7 +73,9 @@ type rectShot struct {
7073 // placement gets a Zoom of the settled zoom (its Preview, or 1) times
7174 // shown.W / settled.W (refreshShown), which the scene carries as Preview.
7275 // The walk shrinks content toward the content rect's top-left, so this
73-// suits aspect-preserving changes only. An overview card's settled zoom is
76+// suits aspect-preserving changes: the zoom follows the smaller of the width
77+// and height ratios, so content never exceeds its frame when the aspect
78+// differs a little (rounding) or a lot (the frame is then letterboxed). An overview card's settled zoom is
7479 // its Preview: a window that opens into a card goes from 1 to Preview with
7580 // its width, and back.
7681 //
@@ -155,7 +160,7 @@ func (m *rectMotion) show(p *Placement) {
155160 p.Rect = m.apply(settled)
156161 p.Fade = max(0, min(1, m.settledFade()+m.df))
157162 p.Dim = max(0, min(1, p.Dim+m.ddim))
158- if m.scale && settled.W > 0 {
163+ if m.scale && settled.W > 0 && settled.H > 0 {
159164 zoom := p.Preview
160165 if zoom == 0 {
161166 zoom = 1
@@ -164,7 +169,7 @@ func (m *rectMotion) show(p *Placement) {
164169 zoom = appearScale
165170 }
166171 }
167- zoom *= float64(p.Rect.W) / float64(settled.W)
172+ zoom *= min(float64(p.Rect.W)/float64(settled.W), float64(p.Rect.H)/float64(settled.H))
168173 if zoom >= 0.999 {
169174 // Drawn at its size: content is never magnified. publish
170175 // emits no Zoom for a window, and Zoom 1 for a card (whose
@@ -319,6 +324,13 @@ func (c *Core) mapWindow(v ports.WindowMapped) {
319324 return
320325 }
321326 shots := c.snapshot(now)
327+ for i := range shots {
328+ // A screen with its overview open re-lays its cards at once: a map
329+ // is not an overview action (transition skips a shot without ws).
330+ if shots[i].overview {
331+ shots[i].ws = nil
332+ }
333+ }
322334 c.placement.place(c, v)
323335 c.transition(shots, now)
324336 c.appearMapped(v.ID, now)
@@ -479,6 +491,8 @@ func (c *Core) snapshot(now time.Time) []viewShot {
479491 r := rectShot{id: p.ID, rect: p.Rect}
480492 if rm, ok := sc.rects[p.ID]; ok {
481493 r.off, r.vel = rm.sample(now)
494+ r.fade = rm.df
495+ sampleComponent(rm.fade, &r.fade, &r.fadeV, now)
482496 }
483497 s.rects = append(s.rects, r)
484498 }
@@ -639,10 +653,12 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) {
639653 // before (another row, a column the overview hid) fades in with no motion
640654 // of its own; one drawn before and not after just goes. Motions of the
641655 // other state are dropped first: their rects are not the ones snapshot
642-// measured from.
656+// measured from. A fade that ran on a window drawn in both states carries
657+// over (picking a card that still fades in does not pop it).
643658 func (c *Core) transitionOverview(b *viewShot, now time.Time) {
644659 sc := b.sc
645- if b.overview != sc.mon.ov.open {
660+ changed := b.overview != sc.mon.ov.open
661+ if changed {
646662 sc.stopRects()
647663 }
648664 for _, p := range sc.mon.Layout() {
@@ -651,6 +667,9 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) {
651667 }
652668 rm := sc.rects[p.ID]
653669 i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID })
670+ if i >= 0 && changed {
671+ c.retargetComponent(&rm.fade, &rm.df, b.rects[i].fade, b.rects[i].fadeV, now)
672+ }
654673 if i < 0 {
655674 c.retargetComponent(&rm.fade, &rm.df, 1, 0, now)
656675 } else if old := &b.rects[i]; old.rect != p.Rect {
@@ -59,6 +59,9 @@ type rectShot struct {
59 id WindowID59 id WindowID
60 rect Rect60 rect Rect
61 off, vel rectOffsets61 off, vel rectOffsets
62+ // fade and fadeV are the fade offset of a running fade motion and its
63+ // speed, so an overview toggle carries it over (transitionOverview).
64+ fade, fadeV float64
62 }65 }
63 66
64 // rectMotion takes a window's drawn rect to its settled one: each component67 // rectMotion takes a window's drawn rect to its settled one: each component
@@ -70,7 +73,9 @@ type rectShot struct {
70 // placement gets a Zoom of the settled zoom (its Preview, or 1) times73 // placement gets a Zoom of the settled zoom (its Preview, or 1) times
71 // shown.W / settled.W (refreshShown), which the scene carries as Preview.74 // shown.W / settled.W (refreshShown), which the scene carries as Preview.
72 // The walk shrinks content toward the content rect's top-left, so this75 // The walk shrinks content toward the content rect's top-left, so this
73-// suits aspect-preserving changes only. An overview card's settled zoom is76+// suits aspect-preserving changes: the zoom follows the smaller of the width
77+// and height ratios, so content never exceeds its frame when the aspect
78+// differs a little (rounding) or a lot (the frame is then letterboxed). An overview card's settled zoom is
74 // its Preview: a window that opens into a card goes from 1 to Preview with79 // its Preview: a window that opens into a card goes from 1 to Preview with
75 // its width, and back.80 // its width, and back.
76 //81 //
@@ -155,7 +160,7 @@ func (m *rectMotion) show(p *Placement) {
155 p.Rect = m.apply(settled)160 p.Rect = m.apply(settled)
156 p.Fade = max(0, min(1, m.settledFade()+m.df))161 p.Fade = max(0, min(1, m.settledFade()+m.df))
157 p.Dim = max(0, min(1, p.Dim+m.ddim))162 p.Dim = max(0, min(1, p.Dim+m.ddim))
158- if m.scale && settled.W > 0 {163+ if m.scale && settled.W > 0 && settled.H > 0 {
159 zoom := p.Preview164 zoom := p.Preview
160 if zoom == 0 {165 if zoom == 0 {
161 zoom = 1166 zoom = 1
@@ -164,7 +169,7 @@ func (m *rectMotion) show(p *Placement) {
164 zoom = appearScale169 zoom = appearScale
165 }170 }
166 }171 }
167- zoom *= float64(p.Rect.W) / float64(settled.W)172+ zoom *= min(float64(p.Rect.W)/float64(settled.W), float64(p.Rect.H)/float64(settled.H))
168 if zoom >= 0.999 {173 if zoom >= 0.999 {
169 // Drawn at its size: content is never magnified. publish174 // Drawn at its size: content is never magnified. publish
170 // emits no Zoom for a window, and Zoom 1 for a card (whose175 // emits no Zoom for a window, and Zoom 1 for a card (whose
@@ -319,6 +324,13 @@ func (c *Core) mapWindow(v ports.WindowMapped) {
319 return324 return
320 }325 }
321 shots := c.snapshot(now)326 shots := c.snapshot(now)
327+ for i := range shots {
328+ // A screen with its overview open re-lays its cards at once: a map
329+ // is not an overview action (transition skips a shot without ws).
330+ if shots[i].overview {
331+ shots[i].ws = nil
332+ }
333+ }
322 c.placement.place(c, v)334 c.placement.place(c, v)
323 c.transition(shots, now)335 c.transition(shots, now)
324 c.appearMapped(v.ID, now)336 c.appearMapped(v.ID, now)
@@ -479,6 +491,8 @@ func (c *Core) snapshot(now time.Time) []viewShot {
479 r := rectShot{id: p.ID, rect: p.Rect}491 r := rectShot{id: p.ID, rect: p.Rect}
480 if rm, ok := sc.rects[p.ID]; ok {492 if rm, ok := sc.rects[p.ID]; ok {
481 r.off, r.vel = rm.sample(now)493 r.off, r.vel = rm.sample(now)
494+ r.fade = rm.df
495+ sampleComponent(rm.fade, &r.fade, &r.fadeV, now)
482 }496 }
483 s.rects = append(s.rects, r)497 s.rects = append(s.rects, r)
484 }498 }
@@ -639,10 +653,12 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) {
639 // before (another row, a column the overview hid) fades in with no motion653 // 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 the654 // 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 snapshot655 // other state are dropped first: their rects are not the ones snapshot
642-// measured from.656+// measured from. A fade that ran on a window drawn in both states carries
657+// over (picking a card that still fades in does not pop it).
643 func (c *Core) transitionOverview(b *viewShot, now time.Time) {658 func (c *Core) transitionOverview(b *viewShot, now time.Time) {
644 sc := b.sc659 sc := b.sc
645- if b.overview != sc.mon.ov.open {660+ changed := b.overview != sc.mon.ov.open
661+ if changed {
646 sc.stopRects()662 sc.stopRects()
647 }663 }
648 for _, p := range sc.mon.Layout() {664 for _, p := range sc.mon.Layout() {
@@ -651,6 +667,9 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) {
651 }667 }
652 rm := sc.rects[p.ID]668 rm := sc.rects[p.ID]
653 i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID })669 i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID })
670+ if i >= 0 && changed {
671+ c.retargetComponent(&rm.fade, &rm.df, b.rects[i].fade, b.rects[i].fadeV, now)
672+ }
654 if i < 0 {673 if i < 0 {
655 c.retargetComponent(&rm.fade, &rm.df, 1, 0, now)674 c.retargetComponent(&rm.fade, &rm.df, 1, 0, now)
656 } else if old := &b.rects[i]; old.rect != p.Rect {675 } else if old := &b.rects[i]; old.rect != p.Rect {
M internal/core/transition_fade_internal_test.go +1 -0
@@ -292,6 +292,7 @@ func TestScaleMotionMultipliesSettledPreview(t *testing.T) {
292292 var rm rectMotion
293293 rm.scale, rm.dw = true, 50
294294 p := Placement{ID: 1, Rect: Rect{W: 100, H: 100}, Preview: 0.2}
295+ rm.dh = 50 // aspect kept: the zoom follows the smaller ratio
295296 rm.show(&p)
296297 if p.Rect.W != 150 || math.Abs(p.Zoom-0.3) > 1e-9 || p.Preview != 0.2 {
297298 t.Fatalf("shown %+v, want width 150, zoom 0.3 and the settled preview", p)
@@ -292,6 +292,7 @@ func TestScaleMotionMultipliesSettledPreview(t *testing.T) {
292 var rm rectMotion292 var rm rectMotion
293 rm.scale, rm.dw = true, 50293 rm.scale, rm.dw = true, 50
294 p := Placement{ID: 1, Rect: Rect{W: 100, H: 100}, Preview: 0.2}294 p := Placement{ID: 1, Rect: Rect{W: 100, H: 100}, Preview: 0.2}
295+ rm.dh = 50 // aspect kept: the zoom follows the smaller ratio
295 rm.show(&p)296 rm.show(&p)
296 if p.Rect.W != 150 || math.Abs(p.Zoom-0.3) > 1e-9 || p.Preview != 0.2 {297 if p.Rect.W != 150 || math.Abs(p.Zoom-0.3) > 1e-9 || p.Preview != 0.2 {
297 t.Fatalf("shown %+v, want width 150, zoom 0.3 and the settled preview", p)298 t.Fatalf("shown %+v, want width 150, zoom 0.3 and the settled preview", p)
M internal/core/transition_overview_internal_test.go +123 -1
@@ -292,7 +292,7 @@ func TestOverviewClickDuringOpenUsesShownCards(t *testing.T) {
292292 // full size still carries Zoom 1 (Preview alone would shrink it).
293293 func TestCardZoomClampedAndEmitted(t *testing.T) {
294294 var rm rectMotion
295- rm.scale, rm.dw = true, 400
295+ rm.scale, rm.dw, rm.dh = true, 400, 240
296296 p := Placement{ID: 1, Rect: Rect{W: 100, H: 60}, Preview: overviewCardZoom}
297297 rm.show(&p)
298298 if p.Zoom != 1 || p.Preview != overviewCardZoom {
@@ -321,3 +321,125 @@ func TestOverviewRectsDroppedOnStateChange(t *testing.T) {
321321 t.Fatalf("%d card motions survived the close without a transition", len(sc.rects))
322322 }
323323 }
324+
325+// The overview has no camera: toggling it, moving in it and closing it never
326+// start a column or workspace slide, right after the action (settled or not).
327+func TestOverviewStartsNoCameraSlide(t *testing.T) {
328+ c, ic, sc := overviewRig(t)
329+ m := sc.mon
330+ for _, id := range []WindowID{4, 5} {
331+ m.AddWindow(id) // more columns than fit: the view scrolls
332+ }
333+ settleShown(t, c)
334+ c.animate(ic.now.Add(5*time.Second), nil)
335+ ic.now = ic.now.Add(5 * time.Second)
336+ noCamera := func(when string) {
337+ t.Helper()
338+ if m.switchMotion.on || m.Current().motion.on {
339+ t.Fatalf("%s: a camera spring runs (switch %v, view %v)", when, m.switchMotion.on, m.Current().motion.on)
340+ }
341+ }
342+ noCamera("before")
343+ toggleOverview(c, sc, ic.now)
344+ noCamera("after open")
345+ frame(t, c, ic, 30*time.Millisecond)
346+ noCamera("mid open")
347+ for _, mv := range [][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
348+ shots := c.snapshot(ic.now)
349+ m.OverviewMove(mv[0], mv[1])
350+ c.transition(shots, ic.now)
351+ noCamera("after move")
352+ frame(t, c, ic, 30*time.Millisecond)
353+ }
354+ toggleOverview(c, sc, ic.now)
355+ noCamera("after close")
356+ frame(t, c, ic, 5*time.Second)
357+ noCamera("settled")
358+}
359+
360+// A card that still fades in (Fade >= 0.5) takes no click; a visible one
361+// does, and a faded card lets the one under it be picked.
362+func TestOverviewInSkipsFadedCards(t *testing.T) {
363+ r := Rect{X: 0, Y: 0, W: 100, H: 100}
364+ layout := []Placement{
365+ {ID: 1, Rect: r, Preview: 0.2},
366+ {ID: 2, Rect: r, Preview: 0.2, Fade: 0.6},
367+ }
368+ if id := overviewIn(layout, 50, 50); id != 1 {
369+ t.Fatalf("picked %d, want 1 (2 is nearly invisible)", id)
370+ }
371+ layout[1].Fade = 0.4
372+ if id := overviewIn(layout, 50, 50); id != 2 {
373+ t.Fatalf("picked %d, want 2 (visible enough)", id)
374+ }
375+ layout = layout[1:]
376+ layout[0].Fade = 0.9
377+ if id := overviewIn(layout, 50, 50); id != 0 {
378+ t.Fatalf("picked %d, want none", id)
379+ }
380+ // And through the shown layout of a running opening.
381+ c, ic, sc := overviewRig(t)
382+ toggleOverview(c, sc, ic.now)
383+ s := indicatorScene(t, c)
384+ w3 := sceneWindow(t, s, 3)
385+ if w3.Fade != 1 {
386+ t.Fatalf("setup: window 3 fade %v", w3.Fade)
387+ }
388+ if id := overviewIn(sc.shownLayout(), float64(w3.Rect.X+w3.Rect.W/2), float64(w3.Rect.Y+w3.Rect.H/2)); id == 3 {
389+ t.Fatal("the invisible card of the next row was picked")
390+ }
391+}
392+
393+// A fade that runs on a card carries over a state change: picking it
394+// mid-fade (here by closing the overview on it) starts from the fade it
395+// had, it does not jump to opaque.
396+func TestOverviewCarriesFadeAcrossState(t *testing.T) {
397+ c, ic, sc := overviewRig(t)
398+ toggleOverview(c, sc, ic.now)
399+ s := frame(t, c, ic, 40*time.Millisecond)
400+ mid := sceneWindow(t, s, 3).Fade
401+ if !(mid > 0.05 && mid < 0.95) {
402+ t.Fatalf("setup: window 3 fade %v mid-way", mid)
403+ }
404+ shots := c.snapshot(ic.now)
405+ sc.mon.OverviewPick(3)
406+ c.transition(shots, ic.now)
407+ if sc.mon.ov.open {
408+ t.Fatal("the overview stayed open")
409+ }
410+ got := sceneWindow(t, indicatorScene(t, c), 3)
411+ if math.Abs(got.Fade-mid) > 0.02 {
412+ t.Fatalf("fade %v after the pick, want the sampled %v", got.Fade, mid)
413+ }
414+ s = frame(t, c, ic, 5*time.Second)
415+ if w := sceneWindow(t, s, 3); w.Fade != 0 {
416+ t.Fatalf("settled fade %v", w.Fade)
417+ }
418+}
419+
420+// The content zoom follows the smaller of the width and height ratios: when
421+// the aspect does not hold, content never exceeds the frame.
422+func TestScaleZoomFollowsSmallerRatio(t *testing.T) {
423+ for _, tc := range []struct {
424+ name string
425+ dw, dh float64
426+ want float64
427+ }{
428+ {"narrower", -50, 0, 0.5},
429+ {"shorter", 0, -50, 0.5},
430+ {"both", -20, -40, 0.6},
431+ {"aspect kept", -30, -30, 0.7},
432+ } {
433+ t.Run(tc.name, func(t *testing.T) {
434+ rm := rectMotion{scale: true, dw: tc.dw, dh: tc.dh}
435+ p := Placement{ID: 1, Rect: Rect{W: 100, H: 100}}
436+ rm.show(&p)
437+ if math.Abs(p.Zoom-tc.want) > 1e-9 {
438+ t.Fatalf("zoom %v, want %v", p.Zoom, tc.want)
439+ }
440+ if float64(p.Rect.W)*1 < p.Zoom*100-1e-9 || float64(p.Rect.H) < p.Zoom*100-1e-9 {
441+ t.Fatalf("content %vx%v exceeds the frame %+v", p.Zoom*100, p.Zoom*100, p.Rect)
442+ }
443+ })
444+ }
445+}
@@ -292,7 +292,7 @@ func TestOverviewClickDuringOpenUsesShownCards(t *testing.T) {
292 // full size still carries Zoom 1 (Preview alone would shrink it).292 // full size still carries Zoom 1 (Preview alone would shrink it).
293 func TestCardZoomClampedAndEmitted(t *testing.T) {293 func TestCardZoomClampedAndEmitted(t *testing.T) {
294 var rm rectMotion294 var rm rectMotion
295- rm.scale, rm.dw = true, 400295+ rm.scale, rm.dw, rm.dh = true, 400, 240
296 p := Placement{ID: 1, Rect: Rect{W: 100, H: 60}, Preview: overviewCardZoom}296 p := Placement{ID: 1, Rect: Rect{W: 100, H: 60}, Preview: overviewCardZoom}
297 rm.show(&p)297 rm.show(&p)
298 if p.Zoom != 1 || p.Preview != overviewCardZoom {298 if p.Zoom != 1 || p.Preview != overviewCardZoom {
@@ -321,3 +321,125 @@ func TestOverviewRectsDroppedOnStateChange(t *testing.T) {
321 t.Fatalf("%d card motions survived the close without a transition", len(sc.rects))321 t.Fatalf("%d card motions survived the close without a transition", len(sc.rects))
322 }322 }
323 }323 }
324+
325+// The overview has no camera: toggling it, moving in it and closing it never
326+// start a column or workspace slide, right after the action (settled or not).
327+func TestOverviewStartsNoCameraSlide(t *testing.T) {
328+ c, ic, sc := overviewRig(t)
329+ m := sc.mon
330+ for _, id := range []WindowID{4, 5} {
331+ m.AddWindow(id) // more columns than fit: the view scrolls
332+ }
333+ settleShown(t, c)
334+ c.animate(ic.now.Add(5*time.Second), nil)
335+ ic.now = ic.now.Add(5 * time.Second)
336+ noCamera := func(when string) {
337+ t.Helper()
338+ if m.switchMotion.on || m.Current().motion.on {
339+ t.Fatalf("%s: a camera spring runs (switch %v, view %v)", when, m.switchMotion.on, m.Current().motion.on)
340+ }
341+ }
342+ noCamera("before")
343+ toggleOverview(c, sc, ic.now)
344+ noCamera("after open")
345+ frame(t, c, ic, 30*time.Millisecond)
346+ noCamera("mid open")
347+ for _, mv := range [][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
348+ shots := c.snapshot(ic.now)
349+ m.OverviewMove(mv[0], mv[1])
350+ c.transition(shots, ic.now)
351+ noCamera("after move")
352+ frame(t, c, ic, 30*time.Millisecond)
353+ }
354+ toggleOverview(c, sc, ic.now)
355+ noCamera("after close")
356+ frame(t, c, ic, 5*time.Second)
357+ noCamera("settled")
358+}
359+
360+// A card that still fades in (Fade >= 0.5) takes no click; a visible one
361+// does, and a faded card lets the one under it be picked.
362+func TestOverviewInSkipsFadedCards(t *testing.T) {
363+ r := Rect{X: 0, Y: 0, W: 100, H: 100}
364+ layout := []Placement{
365+ {ID: 1, Rect: r, Preview: 0.2},
366+ {ID: 2, Rect: r, Preview: 0.2, Fade: 0.6},
367+ }
368+ if id := overviewIn(layout, 50, 50); id != 1 {
369+ t.Fatalf("picked %d, want 1 (2 is nearly invisible)", id)
370+ }
371+ layout[1].Fade = 0.4
372+ if id := overviewIn(layout, 50, 50); id != 2 {
373+ t.Fatalf("picked %d, want 2 (visible enough)", id)
374+ }
375+ layout = layout[1:]
376+ layout[0].Fade = 0.9
377+ if id := overviewIn(layout, 50, 50); id != 0 {
378+ t.Fatalf("picked %d, want none", id)
379+ }
380+ // And through the shown layout of a running opening.
381+ c, ic, sc := overviewRig(t)
382+ toggleOverview(c, sc, ic.now)
383+ s := indicatorScene(t, c)
384+ w3 := sceneWindow(t, s, 3)
385+ if w3.Fade != 1 {
386+ t.Fatalf("setup: window 3 fade %v", w3.Fade)
387+ }
388+ if id := overviewIn(sc.shownLayout(), float64(w3.Rect.X+w3.Rect.W/2), float64(w3.Rect.Y+w3.Rect.H/2)); id == 3 {
389+ t.Fatal("the invisible card of the next row was picked")
390+ }
391+}
392+
393+// A fade that runs on a card carries over a state change: picking it
394+// mid-fade (here by closing the overview on it) starts from the fade it
395+// had, it does not jump to opaque.
396+func TestOverviewCarriesFadeAcrossState(t *testing.T) {
397+ c, ic, sc := overviewRig(t)
398+ toggleOverview(c, sc, ic.now)
399+ s := frame(t, c, ic, 40*time.Millisecond)
400+ mid := sceneWindow(t, s, 3).Fade
401+ if !(mid > 0.05 && mid < 0.95) {
402+ t.Fatalf("setup: window 3 fade %v mid-way", mid)
403+ }
404+ shots := c.snapshot(ic.now)
405+ sc.mon.OverviewPick(3)
406+ c.transition(shots, ic.now)
407+ if sc.mon.ov.open {
408+ t.Fatal("the overview stayed open")
409+ }
410+ got := sceneWindow(t, indicatorScene(t, c), 3)
411+ if math.Abs(got.Fade-mid) > 0.02 {
412+ t.Fatalf("fade %v after the pick, want the sampled %v", got.Fade, mid)
413+ }
414+ s = frame(t, c, ic, 5*time.Second)
415+ if w := sceneWindow(t, s, 3); w.Fade != 0 {
416+ t.Fatalf("settled fade %v", w.Fade)
417+ }
418+}
419+
420+// The content zoom follows the smaller of the width and height ratios: when
421+// the aspect does not hold, content never exceeds the frame.
422+func TestScaleZoomFollowsSmallerRatio(t *testing.T) {
423+ for _, tc := range []struct {
424+ name string
425+ dw, dh float64
426+ want float64
427+ }{
428+ {"narrower", -50, 0, 0.5},
429+ {"shorter", 0, -50, 0.5},
430+ {"both", -20, -40, 0.6},
431+ {"aspect kept", -30, -30, 0.7},
432+ } {
433+ t.Run(tc.name, func(t *testing.T) {
434+ rm := rectMotion{scale: true, dw: tc.dw, dh: tc.dh}
435+ p := Placement{ID: 1, Rect: Rect{W: 100, H: 100}}
436+ rm.show(&p)
437+ if math.Abs(p.Zoom-tc.want) > 1e-9 {
438+ t.Fatalf("zoom %v, want %v", p.Zoom, tc.want)
439+ }
440+ if float64(p.Rect.W)*1 < p.Zoom*100-1e-9 || float64(p.Rect.H) < p.Zoom*100-1e-9 {
441+ t.Fatalf("content %vx%v exceeds the frame %+v", p.Zoom*100, p.Zoom*100, p.Rect)
442+ }
443+ })
444+ }
445+}

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