detached 6337cf2

fix(core): chain stash leave and appear from what was drawn, order leaving entries deterministically

A hide during an entrance or a slide starts from the drawn rect, fade and
veil with their speeds; a show during a hide continues the leaving motion
instead of restarting at 90 %. withLeaving replaces the entries the layout
still lists first, then inserts the absent ones in ID order.
commit
6337cf25c4a4af71a5a1848ab6b6bf44c8d61f4d
author
brice · · 2026-10-05 00:09 UTC
parent
684c464
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +302 -37
M internal/core/animate_alloc_test.go +13 -1
@@ -39,13 +39,25 @@ func TestAnimateAllocations(t *testing.T) {
3939 sc.rects[WindowID(100+i)] = rm
4040 }
4141
42+ // A stash peek's veil moves alone (a navigation): dim-only motions,
43+ // some going below zero, as an offset from Stash.Dim.
44+ for i := range 4 {
45+ var rm rectMotion
46+ from := 0.5
47+ if i%2 == 1 {
48+ from = -0.5
49+ }
50+ rm.dim, rm.ddim = c.spring(viewSpring(from, 0), t0), from
51+ sc.rects[WindowID(200+i)] = rm
52+ }
53+
4254 now := t0.Add(10 * time.Millisecond)
4355 for name, only := range map[string]*screen{"all": nil, "only": sc} {
4456 if n := testing.AllocsPerRun(100, func() { c.animate(now, only) }); n != 0 {
4557 t.Errorf("animate(%s) allocs = %v, want 0", name, n)
4658 }
4759 }
48- if !ws.motion.on || !m.switchMotion.on || len(sc.rects) != 20 {
60+ if !ws.motion.on || !m.switchMotion.on || len(sc.rects) != 24 {
4961 t.Fatalf("motions settled: camera %v switch %v rects %d", ws.motion.on, m.switchMotion.on, len(sc.rects))
5062 }
5163 }
@@ -39,13 +39,25 @@ func TestAnimateAllocations(t *testing.T) {
39 sc.rects[WindowID(100+i)] = rm39 sc.rects[WindowID(100+i)] = rm
40 }40 }
41 41
42+ // A stash peek's veil moves alone (a navigation): dim-only motions,
43+ // some going below zero, as an offset from Stash.Dim.
44+ for i := range 4 {
45+ var rm rectMotion
46+ from := 0.5
47+ if i%2 == 1 {
48+ from = -0.5
49+ }
50+ rm.dim, rm.ddim = c.spring(viewSpring(from, 0), t0), from
51+ sc.rects[WindowID(200+i)] = rm
52+ }
53+
42 now := t0.Add(10 * time.Millisecond)54 now := t0.Add(10 * time.Millisecond)
43 for name, only := range map[string]*screen{"all": nil, "only": sc} {55 for name, only := range map[string]*screen{"all": nil, "only": sc} {
44 if n := testing.AllocsPerRun(100, func() { c.animate(now, only) }); n != 0 {56 if n := testing.AllocsPerRun(100, func() { c.animate(now, only) }); n != 0 {
45 t.Errorf("animate(%s) allocs = %v, want 0", name, n)57 t.Errorf("animate(%s) allocs = %v, want 0", name, n)
46 }58 }
47 }59 }
48- if !ws.motion.on || !m.switchMotion.on || len(sc.rects) != 20 {60+ if !ws.motion.on || !m.switchMotion.on || len(sc.rects) != 24 {
49 t.Fatalf("motions settled: camera %v switch %v rects %d", ws.motion.on, m.switchMotion.on, len(sc.rects))61 t.Fatalf("motions settled: camera %v switch %v rects %d", ws.motion.on, m.switchMotion.on, len(sc.rects))
50 }62 }
51 }63 }
M internal/core/screens.go +2 -0
@@ -40,6 +40,8 @@ type screen struct {
4040 // shownBuf and settledBuf back shown and settledLayout while rect
4141 // motions run (refreshShown), reused across publishes.
4242 shownBuf, settledBuf []Placement
43+ // leaveIDs is withLeaving's scratch list.
44+ leaveIDs []WindowID
4345 // rects are the running per-window rect motions (transition.go) of
4446 // rectsWS, the workspace they began on (nil: the overview); created
4547 // lazily.
@@ -40,6 +40,8 @@ type screen struct {
40 // shownBuf and settledBuf back shown and settledLayout while rect40 // shownBuf and settledBuf back shown and settledLayout while rect
41 // motions run (refreshShown), reused across publishes.41 // motions run (refreshShown), reused across publishes.
42 shownBuf, settledBuf []Placement42 shownBuf, settledBuf []Placement
43+ // leaveIDs is withLeaving's scratch list.
44+ leaveIDs []WindowID
43 // rects are the running per-window rect motions (transition.go) of45 // rects are the running per-window rect motions (transition.go) of
44 // rectsWS, the workspace they began on (nil: the overview); created46 // rectsWS, the workspace they began on (nil: the overview); created
45 // lazily.47 // lazily.
M internal/core/transition.go +102 -34
@@ -261,44 +261,61 @@ func (c *Core) refreshShown() {
261261 // is replaced where it is. A leaving tile goes where the tiles are painted: before
262262 // the first window that opens the floats (a float above the tiles, as the
263263 // renderer orders them), so it never flashes over a float; a leaving float
264-// goes last, over the tiles. Within a group smallest ID first: a stable
265-// order keeps the scenes comparable frame to frame.
264+// goes last, over the tiles. Within a group the absent ones go smallest ID
265+// first: a stable order keeps the scenes comparable frame to frame.
266266 func (s *screen) withLeaving(layout []Placement) []Placement {
267267 out := append(s.settledBuf[:0], layout...)
268+ // Pass 1: an entry the layout still lists, Hidden (a stash that hid),
269+ // replaces it where it is, never twice. Where the pass goes through the
270+ // map does not matter: every entry has its own place. The absent ones
271+ // are collected for pass 2.
272+ ids := s.leaveIDs[:0]
273+ for id, rm := range s.rects {
274+ if !rm.leaving {
275+ continue
276+ }
277+ j := slices.IndexFunc(out, func(q Placement) bool { return q.ID == id && q.Hidden && !q.Leaving })
278+ if j < 0 {
279+ ids = append(ids, id)
280+ continue
281+ }
282+ out[j] = rm.leavingPlacement()
283+ }
284+ // Pass 2: the absent ones in ID order, each at the end of its group
285+ // (tiles before the first float, a leaving stash float included;
286+ // floats last), so the order never depends on the map's.
268287 floats := len(out)
269288 for i, p := range out {
270- if p.Floating && !p.Below && p.Preview == 0 && !p.Hidden {
289+ if p.Floating && !p.Below && p.Preview == 0 && (!p.Hidden || p.Leaving) {
271290 floats = i
272291 break
273292 }
274293 }
275- for _, rm := range s.rects {
276- if !rm.leaving {
294+ slices.Sort(ids)
295+ for _, id := range ids {
296+ p := s.rects[id].leavingPlacement()
297+ if p.Floating {
298+ out = append(out, p)
277299 continue
278300 }
279- p := rm.left
280- p.Hidden, p.Leaving, p.Focused, p.Fade, p.Dim, p.Zoom = true, true, false, 0, 0, 0
281- if j := slices.IndexFunc(out, func(q Placement) bool { return q.ID == p.ID && q.Hidden && !q.Leaving }); j >= 0 {
282- // The layout still lists the window, Hidden (a stash that
283- // hid): the entry is drawn in its place, never twice.
284- out[j] = p
285- continue
286- }
287- lo, hi := floats, len(out)
288- if !p.Floating {
289- lo, hi = 0, floats
290- floats++
291- }
292- i := lo
293- for i < hi && (!out[i].Leaving || out[i].ID < p.ID) {
294- i++
295- }
296- out = slices.Insert(out, i, p)
301+ out = slices.Insert(out, floats, p)
302+ floats++
297303 }
298- s.settledBuf = out
304+ s.leaveIDs, s.settledBuf = ids, out
299305 return out
300306 }
301307
308+// leavingPlacement is the placement a leaving motion draws: its last
309+// settled one, Hidden and Leaving (the scene draws it; input, focus,
310+// popups and configures see a hidden window, as the C0 design has it).
311+// Hidden stays set on purpose: the leaving entry never takes input, even
312+// though its stash window is still in the layout.
313+func (m rectMotion) leavingPlacement() Placement {
314+ p := m.left
315+ p.Hidden, p.Leaving, p.Focused, p.Fade, p.Dim, p.Zoom = true, true, false, 0, 0, 0
316+ return p
317+}
318+
302319 // appear starts the entrance of a window the action made visible on sc:
303320 // it fades in from invisible and grows from 90 % of its settled rect,
304321 // around its centre. Nothing with animations off or if the layout does not
@@ -315,7 +332,10 @@ func (c *Core) appear(sc *screen, id WindowID, now time.Time) {
315332 }
316333 }
317334
318-// appearAt is appear for a window whose settled placement is p.
335+// appearAt is appear for a window whose settled placement is p. A leaving
336+// motion of the same window (a show during a hide) is continued from, not
337+// replaced: the entrance starts at the fade, rect and speeds it was drawn
338+// with.
319339 func (c *Core) appearAt(sc *screen, p Placement, now time.Time) {
320340 if !c.animOn() || p.Hidden {
321341 return
@@ -325,11 +345,38 @@ func (c *Core) appearAt(sc *screen, p Placement, now time.Time) {
325345 }
326346 var rm rectMotion
327347 rm.scale = true
328- c.scaleFrom(&rm, p.Rect, appearScale, now)
329- c.retargetComponent(&rm.fade, &rm.df, 1, 0, now)
348+ if old, ok := sc.rects[p.ID]; ok && old.leaving {
349+ c.appearFrom(&rm, p, &old, now)
350+ } else {
351+ c.scaleFrom(&rm, p.Rect, appearScale, now)
352+ c.retargetComponent(&rm.fade, &rm.df, 1, 0, now)
353+ }
330354 sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current()
331355 }
332356
357+// appearFrom starts rm, the entrance to the settled placement p, from where
358+// the leaving motion old draws its window at now: its rect (old.left.Rect
359+// plus its offsets, sampled), fade, veil and their speeds. The leaving fade
360+// is an offset from 1, the entrance's from 0.
361+func (c *Core) appearFrom(rm *rectMotion, p Placement, old *rectMotion, now time.Time) {
362+ settled := p.Rect
363+ off, vel := old.sample(now)
364+ base := old.left.Rect
365+ fade, fadeV := old.df, 0.0
366+ sampleComponent(old.fade, &fade, &fadeV, now)
367+ dim, dimV := old.ddim, 0.0
368+ sampleComponent(old.dim, &dim, &dimV, now)
369+ // The veil the leaving entry drew (its peek's, plus the offset) to the
370+ // one the settled placement draws.
371+ dim += c.peekDim(old.left) - c.peekDim(p)
372+ c.retargetComponent(&rm.x, &rm.dx, float64(base.X-settled.X)+off.x, vel.x, now)
373+ c.retargetComponent(&rm.y, &rm.dy, float64(base.Y-settled.Y)+off.y, vel.y, now)
374+ c.retargetComponent(&rm.w, &rm.dw, float64(base.W-settled.W)+off.w, vel.w, now)
375+ c.retargetComponent(&rm.h, &rm.dh, float64(base.H-settled.H)+off.h, vel.h, now)
376+ c.retargetComponent(&rm.fade, &rm.df, 1+fade, fadeV, now)
377+ c.retargetComponent(&rm.dim, &rm.ddim, dim, dimV, now)
378+}
379+
333380 // mapWindow handles a window's map: it joins its workspace, the tiles the
334381 // new one displaces re-flow with rect motions, and a window that comes on
335382 // screen appears (appearMapped). A map is the one client event that
@@ -430,6 +477,14 @@ func settledRect(m *Monitor, id WindowID) Rect {
430477 // then its entry goes. Nothing with animations off, or for a placement that
431478 // was not drawn.
432479 func (c *Core) leave(sc *screen, p Placement, now time.Time) {
480+ c.leaveFrom(sc, p, nil, now)
481+}
482+
483+// leaveFrom is leave for a window that was drawn as shot says (the
484+// snapshot's record of it, nil for a window at rest): the exit starts at the
485+// rect, fade and dim the window had on screen, with their speeds, so a hide
486+// during an entrance or a slide does not jump.
487+func (c *Core) leaveFrom(sc *screen, p Placement, shot *rectShot, now time.Time) {
433488 if !c.animOn() || p.Hidden || p.Preview > 0 || p.Rect.W <= 0 || p.Rect.H <= 0 {
434489 return
435490 }
@@ -438,15 +493,24 @@ func (c *Core) leave(sc *screen, p Placement, now time.Time) {
438493 }
439494 rm := rectMotion{scale: true, leaving: true, left: p}
440495 rm.left.Hidden, rm.left.Leaving, rm.left.Focused = true, true, false
496+ var off, vel rectOffsets
497+ fade, fadeV, dim, dimV := 0.0, 0.0, 0.0, 0.0
498+ if shot != nil {
499+ off, vel, fade, fadeV, dim, dimV = shot.off, shot.vel, shot.fade, shot.fadeV, shot.dim, shot.dimV
500+ }
441501 // The entry settles at 90 % of the rect it left, its content zoomed
442- // to match (show): the offsets run from the full rect to that.
502+ // to match (show): the offsets run from the rect it was drawn at (the
503+ // full one at rest) to that.
443504 end := scaledRect(p.Rect, appearScale)
444- c.retargetComponent(&rm.x, &rm.dx, float64(p.Rect.X-end.X), 0, now)
445- c.retargetComponent(&rm.y, &rm.dy, float64(p.Rect.Y-end.Y), 0, now)
446- c.retargetComponent(&rm.w, &rm.dw, float64(p.Rect.W-end.W), 0, now)
447- c.retargetComponent(&rm.h, &rm.dh, float64(p.Rect.H-end.H), 0, now)
505+ c.retargetComponent(&rm.x, &rm.dx, float64(p.Rect.X-end.X)+off.x, vel.x, now)
506+ c.retargetComponent(&rm.y, &rm.dy, float64(p.Rect.Y-end.Y)+off.y, vel.y, now)
507+ c.retargetComponent(&rm.w, &rm.dw, float64(p.Rect.W-end.W)+off.w, vel.w, now)
508+ c.retargetComponent(&rm.h, &rm.dh, float64(p.Rect.H-end.H)+off.h, vel.h, now)
448509 rm.left.Rect = end
449- c.retargetComponent(&rm.fade, &rm.df, -1, 0, now)
510+ // The leaving fade is an offset from 1: the window drawn at fade f is
511+ // at f-1.
512+ c.retargetComponent(&rm.fade, &rm.df, fade-1, fadeV, now)
513+ c.retargetComponent(&rm.dim, &rm.ddim, dim, dimV, now)
450514 sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current()
451515 }
452516
@@ -704,7 +768,11 @@ func (c *Core) transitionStash(b *viewShot, now time.Time) {
704768 for _, p := range b.stash {
705769 i := slices.IndexFunc(layout, func(q Placement) bool { return q.ID == p.ID })
706770 if i >= 0 && layout[i].Hidden && !p.Fullscreen {
707- c.leave(sc, p, now)
771+ var shot *rectShot
772+ if j := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }); j >= 0 {
773+ shot = &b.rects[j]
774+ }
775+ c.leaveFrom(sc, p, shot, now)
708776 }
709777 }
710778 for _, p := range layout {
@@ -261,44 +261,61 @@ func (c *Core) refreshShown() {
261 // is replaced where it is. A leaving tile goes where the tiles are painted: before261 // is replaced where it is. A leaving tile goes where the tiles are painted: before
262 // the first window that opens the floats (a float above the tiles, as the262 // the first window that opens the floats (a float above the tiles, as the
263 // renderer orders them), so it never flashes over a float; a leaving float263 // renderer orders them), so it never flashes over a float; a leaving float
264-// goes last, over the tiles. Within a group smallest ID first: a stable264+// goes last, over the tiles. Within a group the absent ones go smallest ID
265-// order keeps the scenes comparable frame to frame.265+// first: a stable order keeps the scenes comparable frame to frame.
266 func (s *screen) withLeaving(layout []Placement) []Placement {266 func (s *screen) withLeaving(layout []Placement) []Placement {
267 out := append(s.settledBuf[:0], layout...)267 out := append(s.settledBuf[:0], layout...)
268+ // Pass 1: an entry the layout still lists, Hidden (a stash that hid),
269+ // replaces it where it is, never twice. Where the pass goes through the
270+ // map does not matter: every entry has its own place. The absent ones
271+ // are collected for pass 2.
272+ ids := s.leaveIDs[:0]
273+ for id, rm := range s.rects {
274+ if !rm.leaving {
275+ continue
276+ }
277+ j := slices.IndexFunc(out, func(q Placement) bool { return q.ID == id && q.Hidden && !q.Leaving })
278+ if j < 0 {
279+ ids = append(ids, id)
280+ continue
281+ }
282+ out[j] = rm.leavingPlacement()
283+ }
284+ // Pass 2: the absent ones in ID order, each at the end of its group
285+ // (tiles before the first float, a leaving stash float included;
286+ // floats last), so the order never depends on the map's.
268 floats := len(out)287 floats := len(out)
269 for i, p := range out {288 for i, p := range out {
270- if p.Floating && !p.Below && p.Preview == 0 && !p.Hidden {289+ if p.Floating && !p.Below && p.Preview == 0 && (!p.Hidden || p.Leaving) {
271 floats = i290 floats = i
272 break291 break
273 }292 }
274 }293 }
275- for _, rm := range s.rects {294+ slices.Sort(ids)
276- if !rm.leaving {295+ for _, id := range ids {
296+ p := s.rects[id].leavingPlacement()
297+ if p.Floating {
298+ out = append(out, p)
277 continue299 continue
278 }300 }
279- p := rm.left301+ out = slices.Insert(out, floats, p)
280- p.Hidden, p.Leaving, p.Focused, p.Fade, p.Dim, p.Zoom = true, true, false, 0, 0, 0302+ floats++
281- if j := slices.IndexFunc(out, func(q Placement) bool { return q.ID == p.ID && q.Hidden && !q.Leaving }); j >= 0 {
282- // The layout still lists the window, Hidden (a stash that
283- // hid): the entry is drawn in its place, never twice.
284- out[j] = p
285- continue
286- }
287- lo, hi := floats, len(out)
288- if !p.Floating {
289- lo, hi = 0, floats
290- floats++
291- }
292- i := lo
293- for i < hi && (!out[i].Leaving || out[i].ID < p.ID) {
294- i++
295- }
296- out = slices.Insert(out, i, p)
297 }303 }
298- s.settledBuf = out304+ s.leaveIDs, s.settledBuf = ids, out
299 return out305 return out
300 }306 }
301 307
308+// leavingPlacement is the placement a leaving motion draws: its last
309+// settled one, Hidden and Leaving (the scene draws it; input, focus,
310+// popups and configures see a hidden window, as the C0 design has it).
311+// Hidden stays set on purpose: the leaving entry never takes input, even
312+// though its stash window is still in the layout.
313+func (m rectMotion) leavingPlacement() Placement {
314+ p := m.left
315+ p.Hidden, p.Leaving, p.Focused, p.Fade, p.Dim, p.Zoom = true, true, false, 0, 0, 0
316+ return p
317+}
318+
302 // appear starts the entrance of a window the action made visible on sc:319 // appear starts the entrance of a window the action made visible on sc:
303 // it fades in from invisible and grows from 90 % of its settled rect,320 // it fades in from invisible and grows from 90 % of its settled rect,
304 // around its centre. Nothing with animations off or if the layout does not321 // around its centre. Nothing with animations off or if the layout does not
@@ -315,7 +332,10 @@ func (c *Core) appear(sc *screen, id WindowID, now time.Time) {
315 }332 }
316 }333 }
317 334
318-// appearAt is appear for a window whose settled placement is p.335+// appearAt is appear for a window whose settled placement is p. A leaving
336+// motion of the same window (a show during a hide) is continued from, not
337+// replaced: the entrance starts at the fade, rect and speeds it was drawn
338+// with.
319 func (c *Core) appearAt(sc *screen, p Placement, now time.Time) {339 func (c *Core) appearAt(sc *screen, p Placement, now time.Time) {
320 if !c.animOn() || p.Hidden {340 if !c.animOn() || p.Hidden {
321 return341 return
@@ -325,11 +345,38 @@ func (c *Core) appearAt(sc *screen, p Placement, now time.Time) {
325 }345 }
326 var rm rectMotion346 var rm rectMotion
327 rm.scale = true347 rm.scale = true
328- c.scaleFrom(&rm, p.Rect, appearScale, now)348+ if old, ok := sc.rects[p.ID]; ok && old.leaving {
329- c.retargetComponent(&rm.fade, &rm.df, 1, 0, now)349+ c.appearFrom(&rm, p, &old, now)
350+ } else {
351+ c.scaleFrom(&rm, p.Rect, appearScale, now)
352+ c.retargetComponent(&rm.fade, &rm.df, 1, 0, now)
353+ }
330 sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current()354 sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current()
331 }355 }
332 356
357+// appearFrom starts rm, the entrance to the settled placement p, from where
358+// the leaving motion old draws its window at now: its rect (old.left.Rect
359+// plus its offsets, sampled), fade, veil and their speeds. The leaving fade
360+// is an offset from 1, the entrance's from 0.
361+func (c *Core) appearFrom(rm *rectMotion, p Placement, old *rectMotion, now time.Time) {
362+ settled := p.Rect
363+ off, vel := old.sample(now)
364+ base := old.left.Rect
365+ fade, fadeV := old.df, 0.0
366+ sampleComponent(old.fade, &fade, &fadeV, now)
367+ dim, dimV := old.ddim, 0.0
368+ sampleComponent(old.dim, &dim, &dimV, now)
369+ // The veil the leaving entry drew (its peek's, plus the offset) to the
370+ // one the settled placement draws.
371+ dim += c.peekDim(old.left) - c.peekDim(p)
372+ c.retargetComponent(&rm.x, &rm.dx, float64(base.X-settled.X)+off.x, vel.x, now)
373+ c.retargetComponent(&rm.y, &rm.dy, float64(base.Y-settled.Y)+off.y, vel.y, now)
374+ c.retargetComponent(&rm.w, &rm.dw, float64(base.W-settled.W)+off.w, vel.w, now)
375+ c.retargetComponent(&rm.h, &rm.dh, float64(base.H-settled.H)+off.h, vel.h, now)
376+ c.retargetComponent(&rm.fade, &rm.df, 1+fade, fadeV, now)
377+ c.retargetComponent(&rm.dim, &rm.ddim, dim, dimV, now)
378+}
379+
333 // mapWindow handles a window's map: it joins its workspace, the tiles the380 // mapWindow handles a window's map: it joins its workspace, the tiles the
334 // new one displaces re-flow with rect motions, and a window that comes on381 // new one displaces re-flow with rect motions, and a window that comes on
335 // screen appears (appearMapped). A map is the one client event that382 // screen appears (appearMapped). A map is the one client event that
@@ -430,6 +477,14 @@ func settledRect(m *Monitor, id WindowID) Rect {
430 // then its entry goes. Nothing with animations off, or for a placement that477 // then its entry goes. Nothing with animations off, or for a placement that
431 // was not drawn.478 // was not drawn.
432 func (c *Core) leave(sc *screen, p Placement, now time.Time) {479 func (c *Core) leave(sc *screen, p Placement, now time.Time) {
480+ c.leaveFrom(sc, p, nil, now)
481+}
482+
483+// leaveFrom is leave for a window that was drawn as shot says (the
484+// snapshot's record of it, nil for a window at rest): the exit starts at the
485+// rect, fade and dim the window had on screen, with their speeds, so a hide
486+// during an entrance or a slide does not jump.
487+func (c *Core) leaveFrom(sc *screen, p Placement, shot *rectShot, now time.Time) {
433 if !c.animOn() || p.Hidden || p.Preview > 0 || p.Rect.W <= 0 || p.Rect.H <= 0 {488 if !c.animOn() || p.Hidden || p.Preview > 0 || p.Rect.W <= 0 || p.Rect.H <= 0 {
434 return489 return
435 }490 }
@@ -438,15 +493,24 @@ func (c *Core) leave(sc *screen, p Placement, now time.Time) {
438 }493 }
439 rm := rectMotion{scale: true, leaving: true, left: p}494 rm := rectMotion{scale: true, leaving: true, left: p}
440 rm.left.Hidden, rm.left.Leaving, rm.left.Focused = true, true, false495 rm.left.Hidden, rm.left.Leaving, rm.left.Focused = true, true, false
496+ var off, vel rectOffsets
497+ fade, fadeV, dim, dimV := 0.0, 0.0, 0.0, 0.0
498+ if shot != nil {
499+ off, vel, fade, fadeV, dim, dimV = shot.off, shot.vel, shot.fade, shot.fadeV, shot.dim, shot.dimV
500+ }
441 // The entry settles at 90 % of the rect it left, its content zoomed501 // The entry settles at 90 % of the rect it left, its content zoomed
442- // to match (show): the offsets run from the full rect to that.502+ // to match (show): the offsets run from the rect it was drawn at (the
503+ // full one at rest) to that.
443 end := scaledRect(p.Rect, appearScale)504 end := scaledRect(p.Rect, appearScale)
444- c.retargetComponent(&rm.x, &rm.dx, float64(p.Rect.X-end.X), 0, now)505+ c.retargetComponent(&rm.x, &rm.dx, float64(p.Rect.X-end.X)+off.x, vel.x, now)
445- c.retargetComponent(&rm.y, &rm.dy, float64(p.Rect.Y-end.Y), 0, now)506+ c.retargetComponent(&rm.y, &rm.dy, float64(p.Rect.Y-end.Y)+off.y, vel.y, now)
446- c.retargetComponent(&rm.w, &rm.dw, float64(p.Rect.W-end.W), 0, now)507+ c.retargetComponent(&rm.w, &rm.dw, float64(p.Rect.W-end.W)+off.w, vel.w, now)
447- c.retargetComponent(&rm.h, &rm.dh, float64(p.Rect.H-end.H), 0, now)508+ c.retargetComponent(&rm.h, &rm.dh, float64(p.Rect.H-end.H)+off.h, vel.h, now)
448 rm.left.Rect = end509 rm.left.Rect = end
449- c.retargetComponent(&rm.fade, &rm.df, -1, 0, now)510+ // The leaving fade is an offset from 1: the window drawn at fade f is
511+ // at f-1.
512+ c.retargetComponent(&rm.fade, &rm.df, fade-1, fadeV, now)
513+ c.retargetComponent(&rm.dim, &rm.ddim, dim, dimV, now)
450 sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current()514 sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current()
451 }515 }
452 516
@@ -704,7 +768,11 @@ func (c *Core) transitionStash(b *viewShot, now time.Time) {
704 for _, p := range b.stash {768 for _, p := range b.stash {
705 i := slices.IndexFunc(layout, func(q Placement) bool { return q.ID == p.ID })769 i := slices.IndexFunc(layout, func(q Placement) bool { return q.ID == p.ID })
706 if i >= 0 && layout[i].Hidden && !p.Fullscreen {770 if i >= 0 && layout[i].Hidden && !p.Fullscreen {
707- c.leave(sc, p, now)771+ var shot *rectShot
772+ if j := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }); j >= 0 {
773+ shot = &b.rects[j]
774+ }
775+ c.leaveFrom(sc, p, shot, now)
708 }776 }
709 }777 }
710 for _, p := range layout {778 for _, p := range layout {
M internal/core/transition_stash_internal_test.go +185 -2
@@ -1,6 +1,7 @@
11 package core
22
33 import (
4+ "math"
45 "slices"
56 "testing"
67 "time"
@@ -29,6 +30,21 @@ func stashRig(t *testing.T, on bool) (*Core, *indicatorClock, *screen, chan port
2930 return c, ic, sc, cmds
3031 }
3132
33+// configuresOf drains the commands into the configures sent per window.
34+func configuresOf(cmds chan ports.ClientCommand) map[WindowID][]ports.ConfigureWindow {
35+ out := map[WindowID][]ports.ConfigureWindow{}
36+ for {
37+ select {
38+ case cmd := <-cmds:
39+ if v, ok := cmd.(ports.ConfigureWindow); ok {
40+ out[v.ID] = append(out[v.ID], v)
41+ }
42+ default:
43+ return out
44+ }
45+ }
46+}
47+
3248 // act is a bind: snapshot, action, transition.
3349 func act(c *Core, ic *indicatorClock, a Action) {
3450 shots := c.snapshot(ic.now)
@@ -73,8 +89,15 @@ func TestStashShowAppears(t *testing.T) {
7389 t.Fatalf("window 4 stays off screen: %+v", w)
7490 }
7591 // The action configures what it changes once, at the settled size.
76- if ids := drainConfigures(cmds); !slices.Contains(ids, 2) {
77- t.Fatalf("configures %v, want window 2 shown", ids)
92+ cfgs := configuresOf(cmds)
93+ for _, id := range []WindowID{2, 3} {
94+ if len(cfgs[id]) != 1 {
95+ t.Fatalf("window %d configured %d times, want once: %+v", id, len(cfgs[id]), cfgs[id])
96+ }
97+ want := c.clientRect(settledPlacement(t, sc, id))
98+ if v := cfgs[id][0]; !v.Visible || v.Width != want.W || v.Height != want.H {
99+ t.Fatalf("window %d configure %+v, want %dx%d (the settled client rect)", id, v, want.W, want.H)
100+ }
78101 }
79102
80103 s = frame(t, c, ic, 40*time.Millisecond)
@@ -310,3 +333,163 @@ func TestStashNavigationAppearsAndLeaves(t *testing.T) {
310333 t.Fatal("still animating")
311334 }
312335 }
336+
337+// near fails the test unless a and b differ by less than eps.
338+func near(t *testing.T, what string, a, b, eps float64) {
339+ t.Helper()
340+ if math.Abs(a-b) > eps {
341+ t.Fatalf("%s jumped: %v then %v", what, a, b)
342+ }
343+}
344+
345+// A hide during the show's fade-in continues from what was drawn: no jump
346+// in fade or rect, then it settles gone.
347+func TestStashHideDuringShowDoesNotJump(t *testing.T) {
348+ c, ic, sc, _ := stashRig(t, true)
349+ act(c, ic, ActionToggleStashVisible)
350+ indicatorScene(t, c)
351+ ic.now = ic.now.Add(5 * time.Second)
352+ c.animate(ic.now, nil)
353+ indicatorScene(t, c)
354+ act(c, ic, ActionToggleStashVisible) // show
355+ indicatorScene(t, c)
356+ s := frame(t, c, ic, 30*time.Millisecond)
357+ last := sceneWindow(t, s, 2)
358+ if !(last.Fade > 0 && last.Fade < 1) {
359+ t.Fatalf("setup: fade %v", last.Fade)
360+ }
361+ act(c, ic, ActionToggleStashVisible) // hide, same instant
362+ w := sceneWindow(t, indicatorScene(t, c), 2)
363+ if w.Hidden {
364+ t.Fatalf("window 2 vanished: %+v", w)
365+ }
366+ near(t, "fade", last.Fade, w.Fade, 0.02)
367+ near(t, "width", float64(last.Rect.W), float64(w.Rect.W), 2)
368+ near(t, "x", float64(last.Rect.X), float64(w.Rect.X), 2)
369+ near(t, "zoom", contentScale(last), contentScale(w), 0.03)
370+ s = frame(t, c, ic, 5*time.Second)
371+ if w := sceneWindow(t, s, 2); !w.Hidden || c.animating() || len(sc.rects) != 0 {
372+ t.Fatalf("after the settle: %+v, %d rects", w, len(sc.rects))
373+ }
374+}
375+
376+// A show during the hide's fade-out continues from what was drawn.
377+func TestStashShowDuringHideDoesNotJump(t *testing.T) {
378+ c, ic, sc, _ := stashRig(t, true)
379+ act(c, ic, ActionToggleStashVisible) // hide
380+ indicatorScene(t, c)
381+ s := frame(t, c, ic, 30*time.Millisecond)
382+ last := sceneWindow(t, s, 2)
383+ if !(last.Fade > 0 && last.Fade < 1) {
384+ t.Fatalf("setup: fade %v", last.Fade)
385+ }
386+ act(c, ic, ActionToggleStashVisible) // show
387+ w := sceneWindow(t, indicatorScene(t, c), 2)
388+ if w.Hidden {
389+ t.Fatalf("window 2 hidden: %+v", w)
390+ }
391+ near(t, "fade", last.Fade, w.Fade, 0.02)
392+ near(t, "width", float64(last.Rect.W), float64(w.Rect.W), 2)
393+ near(t, "x", float64(last.Rect.X), float64(w.Rect.X), 2)
394+ near(t, "zoom", contentScale(last), contentScale(w), 0.03)
395+ // The entrance now runs back to opaque (the fade's speed carries over:
396+ // it may still rise a few frames before it turns).
397+ s = frame(t, c, ic, 150*time.Millisecond)
398+ if n := sceneWindow(t, s, 2); !(n.Fade < w.Fade) {
399+ t.Fatalf("fade %v then %v, want it to clear", w.Fade, n.Fade)
400+ }
401+ s = frame(t, c, ic, 5*time.Second)
402+ p := settledPlacement(t, sc, 2)
403+ if w := sceneWindow(t, s, 2); w.Fade != 0 || w.Zoom != 0 || w.Rect != p.Rect || c.animating() {
404+ t.Fatalf("settled %+v, want %+v", w, p.Rect)
405+ }
406+}
407+
408+// A peek sliding to a margin that a fast second navigation sends off screen
409+// leaves from where it was drawn, mid-slide, not from its settled rect.
410+func TestStashNavigationLeavesFromSlide(t *testing.T) {
411+ c, ic, sc, _ := stashRig(t, true)
412+ // 2 selected, 3 peeks left. Left: 3 centred, 2 peeks right, 4 enters.
413+ act(c, ic, ActionFocusColumnLeft)
414+ indicatorScene(t, c)
415+ s := frame(t, c, ic, 30*time.Millisecond)
416+ last := sceneWindow(t, s, 2)
417+ settled := settledPlacement(t, sc, 2)
418+ if last.Rect == settled.Rect {
419+ t.Fatal("setup: window 2 already at rest")
420+ }
421+ // Left again: 4 centred, 3 peeks right, 2 goes off.
422+ act(c, ic, ActionFocusColumnLeft)
423+ w := sceneWindow(t, indicatorScene(t, c), 2)
424+ if w.Hidden {
425+ t.Fatalf("window 2 vanished: %+v", w)
426+ }
427+ near(t, "x", float64(last.Rect.X), float64(w.Rect.X), 2)
428+ near(t, "width", float64(last.Rect.W), float64(w.Rect.W), 2)
429+ near(t, "dim", last.Dim, w.Dim, 0.03)
430+ if s = frame(t, c, ic, 5*time.Second); !sceneWindow(t, s, 2).Hidden {
431+ t.Fatal("window 2 still drawn after the settle")
432+ }
433+}
434+
435+// Leaving entries land in the same order whatever the map's: a hidden stash
436+// float (replaced in place) and absent tiles and floats (ID order, tiles
437+// before the first float, floats last).
438+func TestWithLeavingOrderIsDeterministic(t *testing.T) {
439+ c, ic := indicatorCore(t)
440+ c.cfg.Animations.On = true
441+ sc := c.cur()
442+ w := sc.mon.Current()
443+ for id := WindowID(1); id <= 4; id++ {
444+ sc.mon.AddWindow(id)
445+ }
446+ w.FocusID(4)
447+ c.applyAction(ActionToggleWindowStash) // 4 stashed, shown
448+ w.AddFloating(5, 20, 10)
449+ settleShown(t, c)
450+ var left []Placement
451+ for _, p := range sc.settledLayout {
452+ if p.ID == 2 || p.ID == 3 || p.ID == 4 || p.ID == 5 {
453+ left = append(left, p)
454+ }
455+ }
456+ if len(left) != 4 {
457+ t.Fatalf("setup: %d placements", len(left))
458+ }
459+ // 2 and 3 close (absent tiles), 5 closes (absent float), 4 hides.
460+ sc.mon.RemoveWindow(2)
461+ sc.mon.RemoveWindow(3)
462+ sc.mon.RemoveWindow(5)
463+ c.applyAction(ActionToggleStashVisible)
464+ for _, p := range left {
465+ c.leave(sc, p, ic.now)
466+ }
467+ var want []WindowID
468+ for i := range 40 {
469+ c.refreshShown()
470+ var ids []WindowID
471+ for _, p := range sc.settledLayout {
472+ ids = append(ids, p.ID)
473+ }
474+ if i == 0 {
475+ want = ids
476+ if n := len(ids); n < 4 {
477+ t.Fatalf("layout %v", ids)
478+ }
479+ i2, i3 := slices.Index(ids, 2), slices.Index(ids, 3)
480+ i4, i5 := slices.Index(ids, 4), slices.Index(ids, 5)
481+ // Absent tiles by ID, then the leaving stash float (in its
482+ // slot), the absent float last.
483+ if !(i2 >= 0 && i2 < i3 && i3 < i4 && i4 < i5 && i5 == len(ids)-1) {
484+ t.Fatalf("order %v: want tiles 2, 3, then the stash float 4, then 5", ids)
485+ }
486+ if k := slices.Index(ids, 4); !sc.settledLayout[k].Leaving {
487+ t.Fatal("the hidden stash window is not replaced in place")
488+ }
489+ continue
490+ }
491+ if !slices.Equal(ids, want) {
492+ t.Fatalf("run %d: order %v, first run %v", i, ids, want)
493+ }
494+ }
495+}
@@ -1,6 +1,7 @@
1 package core1 package core
2 2
3 import (3 import (
4+ "math"
4 "slices"5 "slices"
5 "testing"6 "testing"
6 "time"7 "time"
@@ -29,6 +30,21 @@ func stashRig(t *testing.T, on bool) (*Core, *indicatorClock, *screen, chan port
29 return c, ic, sc, cmds30 return c, ic, sc, cmds
30 }31 }
31 32
33+// configuresOf drains the commands into the configures sent per window.
34+func configuresOf(cmds chan ports.ClientCommand) map[WindowID][]ports.ConfigureWindow {
35+ out := map[WindowID][]ports.ConfigureWindow{}
36+ for {
37+ select {
38+ case cmd := <-cmds:
39+ if v, ok := cmd.(ports.ConfigureWindow); ok {
40+ out[v.ID] = append(out[v.ID], v)
41+ }
42+ default:
43+ return out
44+ }
45+ }
46+}
47+
32 // act is a bind: snapshot, action, transition.48 // act is a bind: snapshot, action, transition.
33 func act(c *Core, ic *indicatorClock, a Action) {49 func act(c *Core, ic *indicatorClock, a Action) {
34 shots := c.snapshot(ic.now)50 shots := c.snapshot(ic.now)
@@ -73,8 +89,15 @@ func TestStashShowAppears(t *testing.T) {
73 t.Fatalf("window 4 stays off screen: %+v", w)89 t.Fatalf("window 4 stays off screen: %+v", w)
74 }90 }
75 // The action configures what it changes once, at the settled size.91 // The action configures what it changes once, at the settled size.
76- if ids := drainConfigures(cmds); !slices.Contains(ids, 2) {92+ cfgs := configuresOf(cmds)
77- t.Fatalf("configures %v, want window 2 shown", ids)93+ for _, id := range []WindowID{2, 3} {
94+ if len(cfgs[id]) != 1 {
95+ t.Fatalf("window %d configured %d times, want once: %+v", id, len(cfgs[id]), cfgs[id])
96+ }
97+ want := c.clientRect(settledPlacement(t, sc, id))
98+ if v := cfgs[id][0]; !v.Visible || v.Width != want.W || v.Height != want.H {
99+ t.Fatalf("window %d configure %+v, want %dx%d (the settled client rect)", id, v, want.W, want.H)
100+ }
78 }101 }
79 102
80 s = frame(t, c, ic, 40*time.Millisecond)103 s = frame(t, c, ic, 40*time.Millisecond)
@@ -310,3 +333,163 @@ func TestStashNavigationAppearsAndLeaves(t *testing.T) {
310 t.Fatal("still animating")333 t.Fatal("still animating")
311 }334 }
312 }335 }
336+
337+// near fails the test unless a and b differ by less than eps.
338+func near(t *testing.T, what string, a, b, eps float64) {
339+ t.Helper()
340+ if math.Abs(a-b) > eps {
341+ t.Fatalf("%s jumped: %v then %v", what, a, b)
342+ }
343+}
344+
345+// A hide during the show's fade-in continues from what was drawn: no jump
346+// in fade or rect, then it settles gone.
347+func TestStashHideDuringShowDoesNotJump(t *testing.T) {
348+ c, ic, sc, _ := stashRig(t, true)
349+ act(c, ic, ActionToggleStashVisible)
350+ indicatorScene(t, c)
351+ ic.now = ic.now.Add(5 * time.Second)
352+ c.animate(ic.now, nil)
353+ indicatorScene(t, c)
354+ act(c, ic, ActionToggleStashVisible) // show
355+ indicatorScene(t, c)
356+ s := frame(t, c, ic, 30*time.Millisecond)
357+ last := sceneWindow(t, s, 2)
358+ if !(last.Fade > 0 && last.Fade < 1) {
359+ t.Fatalf("setup: fade %v", last.Fade)
360+ }
361+ act(c, ic, ActionToggleStashVisible) // hide, same instant
362+ w := sceneWindow(t, indicatorScene(t, c), 2)
363+ if w.Hidden {
364+ t.Fatalf("window 2 vanished: %+v", w)
365+ }
366+ near(t, "fade", last.Fade, w.Fade, 0.02)
367+ near(t, "width", float64(last.Rect.W), float64(w.Rect.W), 2)
368+ near(t, "x", float64(last.Rect.X), float64(w.Rect.X), 2)
369+ near(t, "zoom", contentScale(last), contentScale(w), 0.03)
370+ s = frame(t, c, ic, 5*time.Second)
371+ if w := sceneWindow(t, s, 2); !w.Hidden || c.animating() || len(sc.rects) != 0 {
372+ t.Fatalf("after the settle: %+v, %d rects", w, len(sc.rects))
373+ }
374+}
375+
376+// A show during the hide's fade-out continues from what was drawn.
377+func TestStashShowDuringHideDoesNotJump(t *testing.T) {
378+ c, ic, sc, _ := stashRig(t, true)
379+ act(c, ic, ActionToggleStashVisible) // hide
380+ indicatorScene(t, c)
381+ s := frame(t, c, ic, 30*time.Millisecond)
382+ last := sceneWindow(t, s, 2)
383+ if !(last.Fade > 0 && last.Fade < 1) {
384+ t.Fatalf("setup: fade %v", last.Fade)
385+ }
386+ act(c, ic, ActionToggleStashVisible) // show
387+ w := sceneWindow(t, indicatorScene(t, c), 2)
388+ if w.Hidden {
389+ t.Fatalf("window 2 hidden: %+v", w)
390+ }
391+ near(t, "fade", last.Fade, w.Fade, 0.02)
392+ near(t, "width", float64(last.Rect.W), float64(w.Rect.W), 2)
393+ near(t, "x", float64(last.Rect.X), float64(w.Rect.X), 2)
394+ near(t, "zoom", contentScale(last), contentScale(w), 0.03)
395+ // The entrance now runs back to opaque (the fade's speed carries over:
396+ // it may still rise a few frames before it turns).
397+ s = frame(t, c, ic, 150*time.Millisecond)
398+ if n := sceneWindow(t, s, 2); !(n.Fade < w.Fade) {
399+ t.Fatalf("fade %v then %v, want it to clear", w.Fade, n.Fade)
400+ }
401+ s = frame(t, c, ic, 5*time.Second)
402+ p := settledPlacement(t, sc, 2)
403+ if w := sceneWindow(t, s, 2); w.Fade != 0 || w.Zoom != 0 || w.Rect != p.Rect || c.animating() {
404+ t.Fatalf("settled %+v, want %+v", w, p.Rect)
405+ }
406+}
407+
408+// A peek sliding to a margin that a fast second navigation sends off screen
409+// leaves from where it was drawn, mid-slide, not from its settled rect.
410+func TestStashNavigationLeavesFromSlide(t *testing.T) {
411+ c, ic, sc, _ := stashRig(t, true)
412+ // 2 selected, 3 peeks left. Left: 3 centred, 2 peeks right, 4 enters.
413+ act(c, ic, ActionFocusColumnLeft)
414+ indicatorScene(t, c)
415+ s := frame(t, c, ic, 30*time.Millisecond)
416+ last := sceneWindow(t, s, 2)
417+ settled := settledPlacement(t, sc, 2)
418+ if last.Rect == settled.Rect {
419+ t.Fatal("setup: window 2 already at rest")
420+ }
421+ // Left again: 4 centred, 3 peeks right, 2 goes off.
422+ act(c, ic, ActionFocusColumnLeft)
423+ w := sceneWindow(t, indicatorScene(t, c), 2)
424+ if w.Hidden {
425+ t.Fatalf("window 2 vanished: %+v", w)
426+ }
427+ near(t, "x", float64(last.Rect.X), float64(w.Rect.X), 2)
428+ near(t, "width", float64(last.Rect.W), float64(w.Rect.W), 2)
429+ near(t, "dim", last.Dim, w.Dim, 0.03)
430+ if s = frame(t, c, ic, 5*time.Second); !sceneWindow(t, s, 2).Hidden {
431+ t.Fatal("window 2 still drawn after the settle")
432+ }
433+}
434+
435+// Leaving entries land in the same order whatever the map's: a hidden stash
436+// float (replaced in place) and absent tiles and floats (ID order, tiles
437+// before the first float, floats last).
438+func TestWithLeavingOrderIsDeterministic(t *testing.T) {
439+ c, ic := indicatorCore(t)
440+ c.cfg.Animations.On = true
441+ sc := c.cur()
442+ w := sc.mon.Current()
443+ for id := WindowID(1); id <= 4; id++ {
444+ sc.mon.AddWindow(id)
445+ }
446+ w.FocusID(4)
447+ c.applyAction(ActionToggleWindowStash) // 4 stashed, shown
448+ w.AddFloating(5, 20, 10)
449+ settleShown(t, c)
450+ var left []Placement
451+ for _, p := range sc.settledLayout {
452+ if p.ID == 2 || p.ID == 3 || p.ID == 4 || p.ID == 5 {
453+ left = append(left, p)
454+ }
455+ }
456+ if len(left) != 4 {
457+ t.Fatalf("setup: %d placements", len(left))
458+ }
459+ // 2 and 3 close (absent tiles), 5 closes (absent float), 4 hides.
460+ sc.mon.RemoveWindow(2)
461+ sc.mon.RemoveWindow(3)
462+ sc.mon.RemoveWindow(5)
463+ c.applyAction(ActionToggleStashVisible)
464+ for _, p := range left {
465+ c.leave(sc, p, ic.now)
466+ }
467+ var want []WindowID
468+ for i := range 40 {
469+ c.refreshShown()
470+ var ids []WindowID
471+ for _, p := range sc.settledLayout {
472+ ids = append(ids, p.ID)
473+ }
474+ if i == 0 {
475+ want = ids
476+ if n := len(ids); n < 4 {
477+ t.Fatalf("layout %v", ids)
478+ }
479+ i2, i3 := slices.Index(ids, 2), slices.Index(ids, 3)
480+ i4, i5 := slices.Index(ids, 4), slices.Index(ids, 5)
481+ // Absent tiles by ID, then the leaving stash float (in its
482+ // slot), the absent float last.
483+ if !(i2 >= 0 && i2 < i3 && i3 < i4 && i4 < i5 && i5 == len(ids)-1) {
484+ t.Fatalf("order %v: want tiles 2, 3, then the stash float 4, then 5", ids)
485+ }
486+ if k := slices.Index(ids, 4); !sc.settledLayout[k].Leaving {
487+ t.Fatal("the hidden stash window is not replaced in place")
488+ }
489+ continue
490+ }
491+ if !slices.Equal(ids, want) {
492+ t.Fatalf("run %d: order %v, first run %v", i, ids, want)
493+ }
494+ }
495+}

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 26

Tags 10

↑↓ moveenter openesc close