transition.go RawHistoryFolder

1package core
2
3import (
4	"math"
5	"slices"
6	"time"
7
8	"github.com/bnema/neferwl/internal/ports"
9)
10
11// Transitions start after a user action (a bind, a click that focuses, a
12// discrete swipe step): a snapshot of what each screen shows is taken before
13// the action and diffed after it. The state the action leaves is the
14// settled one; the transition only adds presentation springs over it
15// (Workspace.view, stashView, Monitor.switchView), starting from what was on screen.
16// Nothing here runs for events that come from clients or outputs.
17//
18// A window's rect transition (presentation-only rect transition) is presentation too: configures keep
19// using the settled rect, so the client resizes once to its final size; what
20// is drawn and hit-tested is the settled rect plus an offset that a spring
21// takes to zero. The offsets are relative to the camera-shifted layout, so a
22// window the view scrolls moves with the camera spring only.
23
24// viewShot is what one screen showed before an action.
25type viewShot struct {
26	sc *screen
27	ws *Workspace
28	// preview is set when the screen showed a preview (the overview or the
29	// column switcher): ws is then the workspace on screen, the camera
30	// fields stay zero, and rects are the cards.
31	preview bool
32	// settledView is ws's settled view and view where its columns were on screen
33	// (settledView plus shift).
34	settledView int
35	view        float64
36	// viewV and switchV are the speeds the running view and workspace
37	// springs have at the snapshot's time (units per second, 0 without
38	// one), not at their last frame. An action may stop them
39	// (Monitor.Focus), so the transition reads them here.
40	viewV, switchV float64
41	switchAxis     layoutAxis
42	// stashShown, stashAt, stashLen and stashPos are whether the stash was
43	// on screen, its selection, size and drawn position (selection plus
44	// stashView.off, in stash windows); stashV is its slide's speed.
45	stashShown        bool
46	stashAt, stashLen int
47	stashPos, stashV  float64
48	// list and pos are the numbered workspaces and the monitor's fractional
49	// position in them (index of the current one plus switchView.off); ok is
50	// false when there is none (a shown stash workspace).
51	list []*Workspace
52	pos  float64
53	ok   bool
54	// rects are the windows the screen drew (the cards in a preview),
55	// hidden windows left out. The slice is reused like list.
56	rects []rectShot
57	// stash holds the settled placements of the stash windows the screen
58	// drew, for the ones an action hides (transitionStash). Reused too.
59	stash []Placement
60}
61
62// rectOffsets are the four components of a rect motion: x, y, w, h.
63type rectOffsets struct{ x, y, w, h float64 }
64
65// rectShot is where a window was drawn: its settled rect plus the float
66// offsets of its running rect motion (zero without one), which the drawn
67// rect only rounds. vel is the speed of each offset component, in units per
68// second. Offsets and speeds are sampled at the snapshot's time.
69type rectShot struct {
70	id       WindowID
71	rect     Rect
72	off, vel rectOffsets
73	// fade and fadeV are the fade offset of a running fade motion and its
74	// speed, so a preview toggle carries it over (transitionOverview).
75	fade, fadeV float64
76	// dim and dimV are the dim offset of a running dim motion and its
77	// speed; veil is the veil the window's settled placement drew
78	// (Stash.Dim times Placement.Veil, 0 for another window). An action
79	// that changes it takes the veil from what was on screen to the new
80	// one (transitionRects).
81	dim, dimV, veil float64
82}
83
84// rectMotion takes a window's drawn rect to its settled one: each component
85// is an offset from the settled value, sprung to 0 (dx, dy, dw, dh hold the
86// current offsets, updated by advance). fade and dim are offsets too, from
87// the settled Fade (0, or 1 for a leaving window) and the settled Dim.
88//
89// With scale, the window's content follows its drawn size: the shown
90// placement gets a Zoom of the settled zoom (its Preview, or 1) times
91// shown.W / settled.W (refreshShown), which the scene carries as Preview.
92// The walk shrinks content toward the content rect's top-left, so this
93// suits aspect-preserving changes: the zoom follows the smaller of the width
94// and height ratios, so content never exceeds its frame when the aspect
95// differs a little (rounding) or a lot (the frame is then letterboxed). A card's settled zoom is
96// its Preview: a window that opens into a card goes from 1 to Preview with
97// its width, and back.
98//
99// A leaving motion draws a window that closed or hid while it fades out:
100// left is its last settled placement, which refreshShown appends (Hidden
101// and Leaving) to the settled and shown layouts, keeping them aligned.
102type rectMotion struct {
103	x, y, w, h     motion
104	dx, dy, dw, dh float64
105	fade, dim      motion
106	df, ddim       float64
107	scale          bool
108	leaving        bool
109	left           Placement
110}
111
112// apply is r moved by the current offsets; a size never drops below 1.
113func (m *rectMotion) apply(r Rect) Rect {
114	return Rect{
115		X: r.X + int(math.Round(m.dx)), Y: r.Y + int(math.Round(m.dy)),
116		W: max(r.W+int(math.Round(m.dw)), 1), H: max(r.H+int(math.Round(m.dh)), 1),
117	}
118}
119
120// sample is where the offsets and their speeds would be at now, without
121// moving the motion: a running component is sampled on a copy.
122func (m *rectMotion) sample(now time.Time) (off, vel rectOffsets) {
123	off = rectOffsets{m.dx, m.dy, m.dw, m.dh}
124	sampleComponent(m.x, &off.x, &vel.x, now)
125	sampleComponent(m.y, &off.y, &vel.y, now)
126	sampleComponent(m.w, &off.w, &vel.w, now)
127	sampleComponent(m.h, &off.h, &vel.h, now)
128	return off, vel
129}
130
131func sampleComponent(m motion, off, vel *float64, now time.Time) {
132	if m.on {
133		*off, *vel = m.sampleAt(now)
134	}
135}
136
137// advance moves the offsets to now; a settled component stops at 0.
138func (m *rectMotion) advance(now time.Time) {
139	m.dx = stepComponent(&m.x, m.dx, now)
140	m.dy = stepComponent(&m.y, m.dy, now)
141	m.dw = stepComponent(&m.w, m.dw, now)
142	m.dh = stepComponent(&m.h, m.dh, now)
143	m.df = stepComponent(&m.fade, m.df, now)
144	m.ddim = stepComponent(&m.dim, m.ddim, now)
145}
146
147func stepComponent(m *motion, cur float64, now time.Time) float64 {
148	if !m.on {
149		return cur
150	}
151	v, done := m.at(now)
152	if done {
153		*m = motion{}
154		return 0
155	}
156	return v
157}
158
159// on reports whether any component still runs.
160func (m *rectMotion) on() bool {
161	return m.x.on || m.y.on || m.w.on || m.h.on || m.fade.on || m.dim.on
162}
163
164// settledFade is the Fade the motion springs to: a leaving window fades
165// out, any other is opaque.
166func (m *rectMotion) settledFade() float64 {
167	if m.leaving {
168		return 1
169	}
170	return 0
171}
172
173// show is p as drawn: its rect moved by the offsets, its fade and dim
174// offsets added, and its content zoomed with its size when scale is set.
175func (m *rectMotion) show(p *Placement) {
176	settled := p.Rect
177	p.Rect = m.apply(settled)
178	p.Fade = max(0, min(1, m.settledFade()+m.df))
179	// A peek's Dim is an offset from its veil (publish adds Stash.Dim and
180	// clamps the sum): it may go below 0, down to cancelling the veil.
181	lo := 0.0
182	if p.Veil > 0 {
183		lo = -1
184	}
185	p.Dim = max(lo, min(1, p.Dim+m.ddim))
186	if m.scale && settled.W > 0 && settled.H > 0 {
187		zoom := p.Preview
188		if zoom == 0 {
189			zoom = 1
190			if m.leaving {
191				// A leaving window settles shrunk, its content with it.
192				zoom = appearScale
193			}
194		}
195		zoom *= min(float64(p.Rect.W)/float64(settled.W), float64(p.Rect.H)/float64(settled.H))
196		if zoom >= 0.999 {
197			// Drawn at its size: content is never magnified. publish
198			// emits no Zoom for a window, and Zoom 1 for a card (whose
199			// Preview would shrink it).
200			zoom = 1
201		}
202		p.Zoom = zoom
203	}
204}
205
206// retargetComponent starts m for an offset of off from its settled value at
207// now, with the speed v it had then. A component with nothing to move and no
208// speed stops.
209func (c *Core) retargetComponent(m *motion, cur *float64, off, v float64, now time.Time) {
210	c.retargetWith(m, cur, viewSpring(off, v), now)
211}
212
213// retargetLevel is retargetComponent for a fade or veil offset (a level in
214// 0..1): its spring runs to its fine epsilon, no pixel snap.
215func (c *Core) retargetLevel(m *motion, cur *float64, off, v float64, now time.Time) {
216	c.retargetWith(m, cur, levelSpring(off, v), now)
217}
218
219func (c *Core) retargetWith(m *motion, cur *float64, s spring, now time.Time) {
220	*cur = s.From
221	if s.From == 0 && s.Velocity == 0 {
222		*m = motion{}
223		return
224	}
225	*m = c.spring(s, now)
226}
227
228// refreshShown builds the layouts of every screen once per publish: the
229// settled one (what configures are sized from) and the shown one (the same
230// with the rect motions applied: what is drawn and hit-tested). Rect motions
231// belong to the workspace they began on, or to the open preview (the overview or the switcher's cards, rectsOwner):
232// any other change drops them.
233//
234// Leaving motions add an entry for a window the layout no longer shows:
235// appended to both layouts in ID order, Hidden (no input, focus, popups or
236// configure) and Leaving (drawn, fading). The two layouts stay
237// index-aligned: publish reads the settled entry at the shown one's index.
238func (c *Core) refreshShown() {
239	for _, sc := range c.screens {
240		m := sc.mon
241		if len(sc.rects) > 0 && sc.rectsWS != sc.rectsOwner() {
242			sc.stopRects()
243		}
244		sc.layoutBuf = m.layoutInto(sc.layoutBuf)
245		sc.settledLayout = sc.layoutBuf
246		if len(sc.settledLayout) == 0 {
247			// Empty stays nil, as a fresh Layout() was: shown nil falls
248			// back to the live layout until the next publish.
249			sc.settledLayout = nil
250		}
251		if len(sc.rects) > 0 {
252			// A window that left the layout (or is hidden) keeps no motion:
253			// it would ask for frames with nothing to move. A leaving one
254			// is the opposite: it goes once the layout shows the window.
255			for id, rm := range sc.rects {
256				shown := slices.ContainsFunc(sc.settledLayout, func(p Placement) bool {
257					return p.ID == id && !p.Hidden
258				})
259				if shown == rm.leaving {
260					delete(sc.rects, id)
261				}
262			}
263		}
264		if len(sc.rects) == 0 {
265			sc.shown = sc.settledLayout
266			continue
267		}
268		sc.settledLayout = sc.withLeaving(sc.settledLayout)
269		buf := append(sc.shownBuf[:0], sc.settledLayout...)
270		for i := range buf {
271			p := &buf[i]
272			if p.Hidden && !p.Leaving {
273				continue
274			}
275			if rm, ok := sc.rects[p.ID]; ok {
276				rm.show(p)
277			}
278		}
279		sc.shownBuf, sc.shown = buf, buf
280	}
281}
282
283// withLeaving is layout with the leaving windows' last placements added,
284// Hidden and Leaving, into the screen's reused buffer (the layout itself
285// is left alone). A window the layout still lists, Hidden (a hidden stash),
286// is replaced where it is. A leaving tile goes where the tiles are painted: before
287// the first window that opens the floats (a float above the tiles, as the
288// renderer orders them), so it never flashes over a float; a leaving float
289// goes last, over the tiles. Within a group the absent ones go smallest ID
290// first: a stable order keeps the scenes comparable frame to frame.
291func (s *screen) withLeaving(layout []Placement) []Placement {
292	out := append(s.settledBuf[:0], layout...)
293	// Pass 1: an entry the layout still lists, Hidden (a stash that hid),
294	// replaces it where it is, never twice. Where the pass goes through the
295	// map does not matter: every entry has its own place. The absent ones
296	// are collected for pass 2.
297	ids := s.leaveIDs[:0]
298	for id, rm := range s.rects {
299		if !rm.leaving {
300			continue
301		}
302		j := slices.IndexFunc(out, func(q Placement) bool { return q.ID == id && q.Hidden && !q.Leaving })
303		if j < 0 {
304			ids = append(ids, id)
305			continue
306		}
307		out[j] = rm.leavingPlacement()
308	}
309	// Pass 2: the absent ones in ID order, each at the end of its group
310	// (tiles before the first float, a leaving stash float included;
311	// floats last), so the order never depends on the map's.
312	floats := len(out)
313	for i, p := range out {
314		if p.Floating && !p.Below && p.Preview == 0 && (!p.Hidden || p.Leaving) {
315			floats = i
316			break
317		}
318	}
319	slices.Sort(ids)
320	for _, id := range ids {
321		p := s.rects[id].leavingPlacement()
322		if p.Floating {
323			out = append(out, p)
324			continue
325		}
326		out = slices.Insert(out, floats, p)
327		floats++
328	}
329	s.leaveIDs, s.settledBuf = ids, out
330	return out
331}
332
333// leavingPlacement is the placement a leaving motion draws: its last
334// settled one, Hidden and Leaving (the scene draws it; input, focus,
335// popups and configures see a hidden window, as the C0 design has it).
336// Hidden stays set on purpose: the leaving entry never takes input, even
337// though its stash window is still in the layout.
338func (m rectMotion) leavingPlacement() Placement {
339	p := m.left
340	p.Hidden, p.Leaving, p.Focused, p.Fade, p.Dim, p.Zoom = true, true, false, 0, 0, 0
341	return p
342}
343
344// appearAt starts the entrance of a window the action made visible on sc,
345// whose settled placement is p: it fades in from invisible and grows from
346// 90 % of its settled rect, around its centre. Nothing with animations off
347// or for a hidden placement. A leaving motion of the same window (a show
348// during a hide) is continued from, not replaced: the entrance starts at
349// the fade, rect and speeds it was drawn with.
350func (c *Core) appearAt(sc *screen, p Placement, now time.Time) {
351	if !c.animOn() || p.Hidden {
352		return
353	}
354	if sc.rects == nil {
355		sc.rects = make(map[WindowID]rectMotion)
356	}
357	var rm rectMotion
358	rm.scale = true
359	if old, ok := sc.rects[p.ID]; ok && old.leaving {
360		c.appearFrom(&rm, p, &old, now)
361	} else {
362		c.scaleFrom(&rm, p.Rect, appearScale, now)
363		c.retargetLevel(&rm.fade, &rm.df, 1, 0, now)
364	}
365	sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current()
366}
367
368// appearFrom starts rm, the entrance to the settled placement p, from where
369// the leaving motion old draws its window at now: its rect (old.left.Rect
370// plus its offsets, sampled), fade, veil and their speeds. The leaving fade
371// is an offset from 1, the entrance's from 0.
372func (c *Core) appearFrom(rm *rectMotion, p Placement, old *rectMotion, now time.Time) {
373	settled := p.Rect
374	off, vel := old.sample(now)
375	base := old.left.Rect
376	fade, fadeV := old.df, 0.0
377	sampleComponent(old.fade, &fade, &fadeV, now)
378	dim, dimV := old.ddim, 0.0
379	sampleComponent(old.dim, &dim, &dimV, now)
380	// The veil the leaving entry drew (its peek's, plus the offset) to the
381	// one the settled placement draws.
382	dim += c.peekDim(old.left) - c.peekDim(p)
383	c.retargetComponent(&rm.x, &rm.dx, float64(base.X-settled.X)+off.x, vel.x, now)
384	c.retargetComponent(&rm.y, &rm.dy, float64(base.Y-settled.Y)+off.y, vel.y, now)
385	c.retargetComponent(&rm.w, &rm.dw, float64(base.W-settled.W)+off.w, vel.w, now)
386	c.retargetComponent(&rm.h, &rm.dh, float64(base.H-settled.H)+off.h, vel.h, now)
387	c.retargetLevel(&rm.fade, &rm.df, 1+fade, fadeV, now)
388	c.retargetLevel(&rm.dim, &rm.ddim, dim, dimV, now)
389}
390
391// mapWindow handles a window's map: it joins its workspace, the tiles the
392// new one displaces re-flow with rect motions, and a window that comes on
393// screen appears (appearMapped). A map is the one client event that
394// animates; the settled layout is the same with animations off, so the
395// client is configured once, to its final size.
396func (c *Core) mapWindow(v ports.WindowMapped) {
397	now := c.now()
398	c.windows.mapped(v, now)
399	if s, _ := c.screenOf(v.ID); s != nil || !c.animOn() || c.security.Protected {
400		// Mapped before (a re-map keeps its place), animations off or a
401		// session lock: nothing to animate.
402		c.placement.place(c, v)
403		return
404	}
405	shots := c.snapshot(now)
406	for i := range shots {
407		// A screen showing a preview re-lays its cards at once: a map is
408		// not a preview action (transition skips a shot without ws).
409		if shots[i].preview {
410			shots[i].ws = nil
411		}
412	}
413	c.placement.place(c, v)
414	c.transition(shots, now)
415	c.appearMapped(v.ID, now)
416}
417
418// appearMapped starts the entrance of a window that just mapped when it
419// shows on its screen's current workspace as a tile or an ordinary float:
420// not with a preview open or a session lock, not fullscreen (it keeps
421// the direct scanout path), not a float below the columns, not a stash
422// peek.
423func (c *Core) appearMapped(id WindowID, now time.Time) {
424	sc, w := c.screenOf(id)
425	if sc == nil || c.security.Protected || sc.mon.previewing() || sc.mon.Current() != w {
426		return
427	}
428	for _, p := range sc.mon.Layout() {
429		if p.ID != id {
430			continue
431		}
432		if !p.Hidden && !p.Fullscreen && !p.Below && !p.Peek && p.Preview == 0 {
433			c.appearAt(sc, p, now)
434		}
435		return
436	}
437}
438
439// unmapWindow handles a window's unmap: it leaves its workspace, the tiles
440// around it re-flow with rect motions, and a window that was drawn as a
441// tile or an ordinary float keeps fading out from where it was drawn
442// (leaveFrom with the snapshot's record of it: a window closed mid-motion
443// leaves from its drawn rect, fade and speed, not from its settled rect).
444// Like a map, an unmap is a client event that animates; the settled layout
445// is the same with animations off. A fullscreen window, one under a
446// session lock or with a preview open just goes.
447func (c *Core) unmapWindow(v ports.WindowUnmapped) {
448	// The window is unmapped: a toplevel that maps again keeps its ID and
449	// must be configured from nothing (a leaving entry keeps no configure).
450	c.configures.forget(v.ID)
451	sc, w := c.screenOf(v.ID)
452	if sc == nil {
453		return
454	}
455	if !c.animOn() || c.security.Protected || sc.mon.previewing() {
456		sc.mon.RemoveWindow(v.ID)
457		delete(sc.rects, v.ID)
458		return
459	}
460	now := c.now()
461	// The placement that leaves is the settled one of the last publish:
462	// where the window was going.
463	var left Placement
464	if sc.mon.Current() == w {
465		for _, p := range sc.settledLayout {
466			if p.ID == v.ID {
467				left = p
468				break
469			}
470		}
471	}
472	shots := c.snapshot(now)
473	var shot *rectShot
474	for i := range shots {
475		if shots[i].preview {
476			shots[i].ws = nil
477		}
478		if shots[i].sc == sc {
479			if j := slices.IndexFunc(shots[i].rects, func(r rectShot) bool { return r.id == v.ID }); j >= 0 {
480				shot = &shots[i].rects[j]
481			}
482		}
483	}
484	sc.mon.RemoveWindow(v.ID)
485	// A window already leaving (a stash hide still fading) keeps its
486	// motion: the fade goes on from where it is, drawn from the content
487	// the renderer kept. Any other motion goes with the window.
488	if rm, ok := sc.rects[v.ID]; !ok || !rm.leaving {
489		delete(sc.rects, v.ID)
490	}
491	c.transition(shots, now)
492	if left.ID == v.ID && !left.Leaving && !left.Fullscreen && !left.Below && !left.Peek {
493		c.leaveFrom(sc, left, shot, now)
494	}
495}
496
497// resizeFloating applies a floating window's committed size. A float maps
498// before its first commit, so core first lays it out at a default size;
499// when its entrance runs, the settled rect changing under it would break
500// the aspect of the 90 % rect. The entrance's rect offsets (and their
501// springs, which are linear in their start and speed) are scaled by the
502// settled size ratio: the progress and speed carry over, and the window
503// still grows from 90 % of its real size around its centre.
504func (c *Core) resizeFloating(v ports.WindowResized) {
505	sc, w := c.screenOf(v.ID)
506	if w == nil {
507		return
508	}
509	rm, running := sc.rects[v.ID]
510	running = running && rm.scale && !rm.leaving
511	var old Rect
512	if running {
513		old = settledRect(sc.mon, v.ID)
514	}
515	w.ResizeFloating(v.ID, v.Width, v.Height)
516	if !running {
517		return
518	}
519	cur := settledRect(sc.mon, v.ID)
520	if old.W <= 0 || old.H <= 0 || cur.W <= 0 || cur.H <= 0 || old == cur {
521		return
522	}
523	kw, kh := float64(cur.W)/float64(old.W), float64(cur.H)/float64(old.H)
524	rm.dx, rm.dw = rm.dx*kw, rm.dw*kw
525	rm.dy, rm.dh = rm.dy*kh, rm.dh*kh
526	rm.x.scale(kw)
527	rm.w.scale(kw)
528	rm.y.scale(kh)
529	rm.h.scale(kh)
530	sc.rects[v.ID] = rm
531}
532
533// settledRect is where m's layout puts the visible window id, zero if it
534// shows none.
535func settledRect(m *Monitor, id WindowID) Rect {
536	for _, p := range m.Layout() {
537		if p.ID == id && !p.Hidden {
538			return p.Rect
539		}
540	}
541	return Rect{}
542}
543
544// leaveFrom keeps drawing a window the action closed or hid, from its last
545// settled placement p: it fades out and shrinks to 90 % around its centre,
546// then its entry goes. Nothing with animations off, or for a placement that
547// was not drawn. shot is the snapshot's record of how the window was drawn
548// (nil for a window at rest): the exit starts at the rect, fade and dim the
549// window had on screen, with their speeds, so a hide
550// during an entrance or a slide does not jump.
551func (c *Core) leaveFrom(sc *screen, p Placement, shot *rectShot, now time.Time) {
552	if !c.animOn() || p.Hidden || p.Preview > 0 || p.Rect.W <= 0 || p.Rect.H <= 0 {
553		return
554	}
555	if sc.rects == nil {
556		sc.rects = make(map[WindowID]rectMotion)
557	}
558	rm := rectMotion{scale: true, leaving: true, left: p}
559	rm.left.Hidden, rm.left.Leaving, rm.left.Focused = true, true, false
560	var off, vel rectOffsets
561	fade, fadeV, dim, dimV := 0.0, 0.0, 0.0, 0.0
562	if shot != nil {
563		off, vel, fade, fadeV, dim, dimV = shot.off, shot.vel, shot.fade, shot.fadeV, shot.dim, shot.dimV
564	}
565	// The entry settles at 90 % of the rect it left, its content zoomed
566	// to match (show): the offsets run from the rect it was drawn at (the
567	// full one at rest) to that.
568	end := scaledRect(p.Rect, appearScale)
569	c.retargetComponent(&rm.x, &rm.dx, float64(p.Rect.X-end.X)+off.x, vel.x, now)
570	c.retargetComponent(&rm.y, &rm.dy, float64(p.Rect.Y-end.Y)+off.y, vel.y, now)
571	c.retargetComponent(&rm.w, &rm.dw, float64(p.Rect.W-end.W)+off.w, vel.w, now)
572	c.retargetComponent(&rm.h, &rm.dh, float64(p.Rect.H-end.H)+off.h, vel.h, now)
573	rm.left.Rect = end
574	// The leaving fade is an offset from 1: the window drawn at fade f is
575	// at f-1.
576	c.retargetLevel(&rm.fade, &rm.df, fade-1, fadeV, now)
577	c.retargetLevel(&rm.dim, &rm.ddim, dim, dimV, now)
578	sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current()
579}
580
581// appearScale is the size, relative to the settled rect, a window appears
582// from and leaves to.
583const appearScale = 0.9
584
585// scaledRect is r scaled by k around its centre, in float.
586func scaledRect(r Rect, k float64) Rect {
587	w, h := float64(r.W)*k, float64(r.H)*k
588	x := float64(r.X) + (float64(r.W)-w)/2
589	y := float64(r.Y) + (float64(r.H)-h)/2
590	return Rect{X: int(math.Round(x)), Y: int(math.Round(y)), W: int(math.Round(w)), H: int(math.Round(h))}
591}
592
593// scaleFrom starts rm's rect components from settled scaled by k around
594// its centre, toward settled.
595func (c *Core) scaleFrom(rm *rectMotion, settled Rect, k float64, now time.Time) {
596	w, h := float64(settled.W)*k, float64(settled.H)*k
597	c.retargetComponent(&rm.x, &rm.dx, (float64(settled.W)-w)/2, 0, now)
598	c.retargetComponent(&rm.y, &rm.dy, (float64(settled.H)-h)/2, 0, now)
599	c.retargetComponent(&rm.w, &rm.dw, w-float64(settled.W), 0, now)
600	c.retargetComponent(&rm.h, &rm.dh, h-float64(settled.H), 0, now)
601}
602
603// snapshot records what every screen shows at now, previews (the overview,
604// the switcher's cards) included.
605// Running motions are sampled at now on a copy, so a transition that
606// follows chains from their speed at the time of the action, not from the
607// last frame's. The view is the one drawn (the layouts it is measured
608// against are), only its speed is sampled. The returned slice and the lists
609// in it belong to Core and are reused by the next snapshot: use it before
610// the next action only.
611func (c *Core) snapshot(now time.Time) []viewShot {
612	full := c.shots[:cap(c.shots)]
613	out := c.shots[:0]
614	for _, sc := range c.screens {
615		m := sc.mon
616		var s viewShot
617		if len(out) < len(full) {
618			s.list = full[len(out)].list[:0]
619			s.rects = full[len(out)].rects[:0]
620			s.stash = full[len(out)].stash[:0]
621		}
622		s.sc = sc
623		w := m.Current()
624		s.ws, s.preview = w, m.previewing()
625		if !m.previewing() {
626			// A preview (the overview or the switcher's cards) does not scroll or slide: no camera.
627			s.settledView, s.view = w.View, float64(w.View)+w.view.off
628			s.switchAxis = w.policy().workspace
629			if m.switchView.busy() {
630				s.switchAxis = m.switchAxis
631			}
632		}
633		if w.view.motion.on {
634			_, s.viewV = w.view.motion.sampleAt(now)
635		}
636		if m.switchView.motion.on {
637			_, s.switchV = m.switchView.motion.sampleAt(now)
638		}
639		s.stashShown, s.stashAt, s.stashLen, s.stashPos = w.stashShown(), w.stashAt, len(w.Stash), float64(w.stashAt)+w.stashView.off
640		if w.stashView.motion.on {
641			_, s.stashV = w.stashView.motion.sampleAt(now)
642		}
643		// settledLayout and shown are index-aligned and differ only by
644		// the offsets of sc.rects: the shot keeps both unrounded. The
645		// cards of a preview are recorded like windows.
646		for _, p := range sc.settledLayout {
647			if p.Hidden {
648				continue
649			}
650			r := rectShot{id: p.ID, rect: p.Rect}
651			if rm, ok := sc.rects[p.ID]; ok {
652				r.off, r.vel = rm.sample(now)
653				r.fade = rm.df
654				sampleComponent(rm.fade, &r.fade, &r.fadeV, now)
655				r.dim = rm.ddim
656				sampleComponent(rm.dim, &r.dim, &r.dimV, now)
657			}
658			r.veil = c.peekDim(p)
659			s.rects = append(s.rects, r)
660			if !m.previewing() && w.stashIndex(p.ID) >= 0 {
661				s.stash = append(s.stash, p)
662			}
663		}
664		if m.shown == nil && !m.previewing() {
665			// A landing slide measures in the list it began on.
666			list := m.Workspaces
667			if m.switchList != nil {
668				list = m.switchList
669			}
670			if i := indexOf(list, w); i >= 0 {
671				s.list = append(s.list, list...)
672				s.pos, s.ok = float64(i)+m.switchView.off, true
673			}
674		}
675		out = append(out, s)
676	}
677	for i := len(out); i < len(full); i++ {
678		// Shots of unplugged screens keep nothing of them alive.
679		full[i].sc, full[i].ws = nil, nil
680		clear(full[i].list[:cap(full[i].list)])
681		full[i].list = full[i].list[:0]
682	}
683	c.shots = out
684	return out
685}
686
687// transition starts the springs that take each screen from what before
688// showed to what the action left, at now. It does nothing with animations
689// off, or while a swipe follows the fingers on that screen. Per-window rect
690// motions join the camera here; with a preview open before or after
691// there is no camera, only the cards' (transitionOverview).
692//
693// A workspace moved to another monitor (move-workspace-to-monitor-*, or a
694// named workspace pulled to the focused one by a "workspace <name>" bind
695// while it shows on another) is animated on both screens: the destination's
696// windows slide in from where the source drew them (no workspace slide
697// there), the source slides to its next workspace. The focus-monitor-*
698// actions move nothing: no window changes place, so they are not animated
699// (the focus pulse marks the new focus).
700func (c *Core) transition(before []viewShot, now time.Time) {
701	if !c.animOn() {
702		return
703	}
704	for i := range before {
705		b := &before[i]
706		if b.ws == nil || !c.hasScreen(b.sc) || c.following(b.sc) {
707			continue
708		}
709		if b.preview || b.sc.mon.previewing() {
710			c.transitionOverview(b, now)
711			continue
712		}
713		c.transitionCamera(b, before, now)
714		c.transitionRects(b, before, now)
715		c.transitionStash(b, now)
716	}
717}
718
719// following reports whether a swipe moves sc's view with the fingers.
720func (c *Core) following(sc *screen) bool {
721	g := c.swipe
722	return g != nil && g.screen == sc && (g.mode == swipeColumns || g.mode == swipeWorkspaces || g.mode == swipeStash)
723}
724
725// transitionCamera slides the columns or the monitor from what b showed.
726func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) {
727	m := b.sc.mon
728	w := m.Current()
729	switch {
730	case w == b.ws:
731		if stashSlid(b, w) {
732			// A stash navigation slides the view from where it was drawn:
733			// the windows keep their settled places (transitionRects).
734			w.stashView.off = b.stashPos - float64(w.stashAt)
735			w.stashView.motion = c.spring(w.stashSpring(w.stashView.off, b.stashV), now)
736			return
737		}
738		if w.View == b.settledView || !w.slidable() {
739			return
740		}
741		// A running landing slide was retargeted by scroll(): its velocity
742		// carries over, and the spring starts now like every action's.
743		w.view.off = b.view - float64(w.View)
744		w.view.motion = c.spring(viewSpring(w.view.off, b.viewV), now)
745	case b.ok && m.shown == nil && movedFrom(b, before) == nil:
746		// A screen that received its workspace from another monitor does
747		// not slide to it: a running slide's list (switchList) may still
748		// hold the workspace, which would start a slide from nowhere. Only
749		// its windows move (transitionRects).
750		j := indexOf(b.list, w)
751		if j < 0 {
752			return
753		}
754		off := b.pos - float64(j)
755		// The snapshot's list is reused by the next action: the slide keeps
756		// its own copy.
757		m.switchView.off, m.switchList = off, slices.Clone(b.list)
758		m.switchAxis = b.switchAxis
759		if off == 0 || m.framedSwitch() {
760			m.stopSwitch()
761			return
762		}
763		m.switchView.motion = c.spring(m.switchSpring(off, b.switchV), now)
764	}
765}
766
767// stashSlid reports whether an action moved the selection of the stash b
768// showed on w: a navigation, not a toggle, an unstash or a hide.
769func stashSlid(b *viewShot, w *Workspace) bool {
770	return b.stashShown && w.stashAt != b.stashAt && len(w.Stash) == b.stashLen && w.stashShown()
771}
772
773// transitionRects starts or retargets the rect motions of the windows whose
774// layout the action changed. The new layout is measured with the camera as
775// transitionCamera left it (the view where it was on screen), so a window
776// the view scrolls differs only by what the layout itself changed. Windows
777// that were not drawn before (new, or hidden) or are hidden now get none.
778//
779// A workspace that arrived from another monitor starts from the rects its
780// source screen drew (found in before), translated to this screen's
781// coordinates; a source whose workspace left, or any other change of
782// workspace, gets none.
783func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) {
784	sc := b.sc
785	m := sc.mon
786	from, dx, dy := b, 0, 0
787	if m.Current() != b.ws {
788		// The motions are those of the workspace this screen left.
789		sc.stopRects()
790		if from = movedFrom(b, before); from == nil {
791			return
792		}
793		dx, dy = from.sc.x-sc.x, from.sc.y-sc.y
794	}
795	for _, p := range m.Layout() {
796		if p.Hidden || p.Preview > 0 {
797			continue
798		}
799		i := slices.IndexFunc(from.rects, func(r rectShot) bool { return r.id == p.ID })
800		if i < 0 {
801			continue
802		}
803		old := &from.rects[i]
804		oldRect := Rect{X: old.rect.X + dx, Y: old.rect.Y + dy, W: old.rect.W, H: old.rect.H}
805		// A window that becomes a peek or stops being one (a stash
806		// toggle) changes its veil: the offset takes the one drawn to the
807		// new one. A stash slide moves the veil with the view, so the veil
808		// of a navigation matches. Otherwise a running dim motion goes on
809		// as it is.
810		dimOff := old.dim + old.veil - c.peekDim(p)
811		dimMoves := math.Abs(old.veil-c.peekDim(p)) > veilEps
812		if oldRect == p.Rect && !dimMoves {
813			// The settled rect did not change: a running motion goes on
814			// as it is, and there is none to start.
815			continue
816		}
817		if sc.rects == nil {
818			sc.rects = make(map[WindowID]rectMotion)
819		}
820		// Where the window was drawn, unrounded, relative to where it goes.
821		rm := sc.rects[p.ID]
822		if oldRect != p.Rect {
823			// A re-flow may change the aspect: the content zoom (appear)
824			// stops following the frame and snaps to the settled size; the
825			// fade goes on.
826			rm.scale = false
827			c.retargetComponent(&rm.x, &rm.dx, float64(oldRect.X-p.Rect.X)+old.off.x, old.vel.x, now)
828			c.retargetComponent(&rm.y, &rm.dy, float64(oldRect.Y-p.Rect.Y)+old.off.y, old.vel.y, now)
829			c.retargetComponent(&rm.w, &rm.dw, float64(oldRect.W-p.Rect.W)+old.off.w, old.vel.w, now)
830			c.retargetComponent(&rm.h, &rm.dh, float64(oldRect.H-p.Rect.H)+old.off.h, old.vel.h, now)
831		}
832		if dimMoves {
833			c.retargetLevel(&rm.dim, &rm.ddim, dimOff, old.dimV, now)
834		}
835		if rm.on() {
836			sc.rects[p.ID], sc.rectsWS = rm, m.Current()
837		} else {
838			delete(sc.rects, p.ID)
839		}
840	}
841}
842
843// transitionStash animates the stash windows an action shows or hides on
844// the workspace b's screen keeps on screen (a toggle-stash-visible, a click
845// that closes the stash; a navigation slides the view instead, see
846// stashSlid): one drawn after and not before appears (appearAt), one
847// drawn before and hidden after leaves from its last settled placement
848// (leave). A window that is gone from the layout (unstashed, moved) or
849// fullscreen is not touched: its rect motion, if any, is transitionRects's.
850// Nothing during a session lock or with a covering fullscreen window (it
851// keeps its direct scanout path).
852func (c *Core) transitionStash(b *viewShot, now time.Time) {
853	sc := b.sc
854	m := sc.mon
855	w := m.Current()
856	if w != b.ws || len(w.Stash) == 0 || c.security.Protected || w.cover() != 0 || stashSlid(b, w) {
857		return
858	}
859	layout := m.Layout()
860	for _, p := range b.stash {
861		i := slices.IndexFunc(layout, func(q Placement) bool { return q.ID == p.ID })
862		if i >= 0 && layout[i].Hidden && !p.Fullscreen {
863			var shot *rectShot
864			if j := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }); j >= 0 {
865				shot = &b.rects[j]
866			}
867			c.leaveFrom(sc, p, shot, now)
868		}
869	}
870	for _, p := range layout {
871		if p.Hidden || p.Fullscreen || w.stashIndex(p.ID) < 0 || slices.ContainsFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }) {
872			continue
873		}
874		c.appearAt(sc, p, now)
875	}
876}
877
878// veilEps absorbs the float drift between a slide's drawn veil and the
879// settled one: a veil that differs by less is the same.
880const veilEps = 1e-9
881
882// peekDim is the veil the settled placement p draws: the configured stash
883// dim for a peek, none for another window. The placement's Dim is an
884// offset from it.
885func (c *Core) peekDim(p Placement) float64 {
886	return c.cfg.Stash.Dim * p.Veil
887}
888
889// transitionOverview starts the motions of an action that opened or closed
890// a preview (the overview or the column switcher), or moved inside it (the
891// selection, the rows). The camera springs stay stopped (ToggleOverview):
892// every window drawn before and after moves from its shown rect to its
893// settled one, its content scaled with it (a card's Zoom,
894// rectMotion.show). A window drawn after and not before (another row, a
895// column a preview hid) fades in with no motion
896// of its own; one drawn before and not after just goes. Motions of the
897// other state are dropped first: their rects are not the ones snapshot
898// measured from. A fade that ran on a window drawn in both states carries
899// over (picking a card that still fades in does not pop it).
900func (c *Core) transitionOverview(b *viewShot, now time.Time) {
901	sc := b.sc
902	changed := b.preview != sc.mon.previewing()
903	if changed {
904		sc.stopRects()
905	}
906	for _, p := range sc.mon.Layout() {
907		if p.Hidden {
908			continue
909		}
910		rm := sc.rects[p.ID]
911		i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID })
912		if i >= 0 && changed {
913			c.retargetLevel(&rm.fade, &rm.df, b.rects[i].fade, b.rects[i].fadeV, now)
914		}
915		if i < 0 {
916			c.retargetLevel(&rm.fade, &rm.df, 1, 0, now)
917		} else if old := &b.rects[i]; old.rect != p.Rect {
918			rm.scale = true
919			c.retargetComponent(&rm.x, &rm.dx, float64(old.rect.X-p.Rect.X)+old.off.x, old.vel.x, now)
920			c.retargetComponent(&rm.y, &rm.dy, float64(old.rect.Y-p.Rect.Y)+old.off.y, old.vel.y, now)
921			c.retargetComponent(&rm.w, &rm.dw, float64(old.rect.W-p.Rect.W)+old.off.w, old.vel.w, now)
922			c.retargetComponent(&rm.h, &rm.dh, float64(old.rect.H-p.Rect.H)+old.off.h, old.vel.h, now)
923		} else {
924			continue
925		}
926		if !rm.on() {
927			delete(sc.rects, p.ID)
928			continue
929		}
930		if sc.rects == nil {
931			sc.rects = make(map[WindowID]rectMotion)
932		}
933		sc.rects[p.ID], sc.rectsWS = rm, sc.rectsOwner()
934	}
935}
936
937// movedFrom is the shot of the other screen that showed the workspace b's
938// screen shows now (moved to it by an action), or nil.
939func movedFrom(b *viewShot, before []viewShot) *viewShot {
940	w := b.sc.mon.Current()
941	for i := range before {
942		if o := &before[i]; o.ws == w && o.sc != b.sc && !o.preview {
943			return o
944		}
945	}
946	return nil
947}

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 25

Tags 10

↑↓ moveenter openesc close