detached 7d2748c

fix(core): order leaving tiles under the floats, reuse the settled buffer, clamp dim, snap the zoom on a re-flow

- A leaving tile is inserted before the first window that opens the
  floats, a leaving float appended; both layouts get the same index, so
  a closing tile never flashes over a float (TestLeavingPaintOrder).
- withLeaving builds the settled layout with its leaving entries into a
  reused screen buffer: TestPublishLeavingAllocations pins 28 allocs per
  frame (two leaving + four moving windows per screen), budget 36.
- Dim offsets are clamped into 0..1 on the shown placement and publish
  never emits a negative Dim.
- A re-flow that retargets a running rect motion clears its scale: the
  content snaps to the settled size (the aspect may change), the fade
  goes on (TestRetargetClearsScaleKeepsFade).
- floatDim counts leaving floats, scaled by 1-Fade, so the veil fades
  with the last float instead of dropping at once.
- A leaving window's content settles at the 90 % zoom through show;
  its placement's Preview stays 0.
commit
7d2748c03fe2eda7356f16b1e1eb2ae961df41e8
author
brice · · 2026-10-04 22:34 UTC
parent
a46389e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +244 -45
M Makefile +1 -1
@@ -60,7 +60,7 @@ arch:
6060 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
6161 # Fast, mandatory guards for tiled content publications and SHM copies.
6262 perf-check:
63- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishOverviewAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
63+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
6464 check: vet test arch fakes-check log-check perf-check
6565
6666 # On-demand SDR/HDR colour check with examples/testpattern on a headless
@@ -60,7 +60,7 @@ arch:
60 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...60 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
61 # Fast, mandatory guards for tiled content publications and SHM copies.61 # Fast, mandatory guards for tiled content publications and SHM copies.
62 perf-check:62 perf-check:
63- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishOverviewAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=163+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
64 check: vet test arch fakes-check log-check perf-check64 check: vet test arch fakes-check log-check perf-check
65 65
66 # On-demand SDR/HDR colour check with examples/testpattern on a headless66 # On-demand SDR/HDR colour check with examples/testpattern on a headless
M internal/core/core.go +13 -13
@@ -411,20 +411,23 @@ func onScreen(p Placement, o Rect) bool {
411411 // floatDim is the veil opacity of a layout: dim only when a float is
412412 // drawn above the tiles, never for a demoted covering float alone nor for
413413 // an overview preview.
414+// A leaving float (fading out after it closed or hid) keeps the veil,
415+// scaled by what is left of it, so the veil fades with the last float.
414416 func floatDim(layout []Placement, o Rect, dim float64) float64 {
415- shown := false
417+ k := 0.0
416418 for _, p := range layout {
417- if p.Floating && !p.Below && p.Preview == 0 && onScreen(p, o) {
419+ if p.Floating && !p.Below && p.Preview == 0 && (onScreen(p, o) || p.Leaving && p.Rect.Overlaps(o)) {
418420 if p.Fullscreen {
419421 return 0
420422 }
421- shown = true
423+ if p.Leaving {
424+ k = max(k, 1-p.Fade)
425+ } else {
426+ k = 1
427+ }
422428 }
423429 }
424- if !shown {
425- return 0
426- }
427- return dim
430+ return dim * k
428431 }
429432
430433 // visible reports whether the window, layer surface or popup is on screen
@@ -605,13 +608,10 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
605608 ps := settled[k]
606609 // Only the focused output has an activated window.
607610 focused := p.Focused && i == c.focusScreen
608- sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade, Dim: p.Dim}
609- if p.Zoom > 0 {
611+ sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade, Dim: max(0, p.Dim)}
612+ if p.Zoom > 0 && p.Zoom < 1 {
610613 // A scale motion: the content follows the drawn size.
611- sw.Preview = p.Zoom
612- if sw.Preview >= 1 {
613- sw.Preview = 0
614- }
614+ sw.Zoom = p.Zoom
615615 }
616616 if p.Peek {
617617 sw.Dim = min(sw.Dim+c.cfg.Stash.Dim, 1)
@@ -411,20 +411,23 @@ func onScreen(p Placement, o Rect) bool {
411 // floatDim is the veil opacity of a layout: dim only when a float is411 // floatDim is the veil opacity of a layout: dim only when a float is
412 // drawn above the tiles, never for a demoted covering float alone nor for412 // drawn above the tiles, never for a demoted covering float alone nor for
413 // an overview preview.413 // an overview preview.
414+// A leaving float (fading out after it closed or hid) keeps the veil,
415+// scaled by what is left of it, so the veil fades with the last float.
414 func floatDim(layout []Placement, o Rect, dim float64) float64 {416 func floatDim(layout []Placement, o Rect, dim float64) float64 {
415- shown := false417+ k := 0.0
416 for _, p := range layout {418 for _, p := range layout {
417- if p.Floating && !p.Below && p.Preview == 0 && onScreen(p, o) {419+ if p.Floating && !p.Below && p.Preview == 0 && (onScreen(p, o) || p.Leaving && p.Rect.Overlaps(o)) {
418 if p.Fullscreen {420 if p.Fullscreen {
419 return 0421 return 0
420 }422 }
421- shown = true423+ if p.Leaving {
424+ k = max(k, 1-p.Fade)
425+ } else {
426+ k = 1
427+ }
422 }428 }
423 }429 }
424- if !shown {430+ return dim * k
425- return 0
426- }
427- return dim
428 }431 }
429 432
430 // visible reports whether the window, layer surface or popup is on screen433 // visible reports whether the window, layer surface or popup is on screen
@@ -605,13 +608,10 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
605 ps := settled[k]608 ps := settled[k]
606 // Only the focused output has an activated window.609 // Only the focused output has an activated window.
607 focused := p.Focused && i == c.focusScreen610 focused := p.Focused && i == c.focusScreen
608- sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade, Dim: p.Dim}611+ sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade, Dim: max(0, p.Dim)}
609- if p.Zoom > 0 {612+ if p.Zoom > 0 && p.Zoom < 1 {
610 // A scale motion: the content follows the drawn size.613 // A scale motion: the content follows the drawn size.
611- sw.Preview = p.Zoom614+ sw.Zoom = p.Zoom
612- if sw.Preview >= 1 {
613- sw.Preview = 0
614- }
615 }615 }
616 if p.Peek {616 if p.Peek {
617 sw.Dim = min(sw.Dim+c.cfg.Stash.Dim, 1)617 sw.Dim = min(sw.Dim+c.cfg.Stash.Dim, 1)
M internal/core/layout.go +5 -5
@@ -128,11 +128,11 @@ type Placement struct {
128128 Leaving bool
129129 // Fade, Dim and Zoom are set on shown layouts only (refreshShown),
130130 // animated: the window's fade (0 opaque, 1 invisible), a veil opacity
131- // added to the one Peek draws, and the content zoom as drawn when a
132- // scale motion runs (Preview times the drawn/settled width, 1 when the
133- // content is drawn at its size; 0 without one). Preview itself stays
134- // the settled one: input and popups read it to tell a card from a
135- // window.
131+ // added to the one Peek draws (0..1), and the content zoom as drawn
132+ // when a scale motion runs (Preview times the drawn/settled width, 1
133+ // when the content is drawn at its size; 0 without one). Preview
134+ // itself stays the settled one: input, popups and the renderer's paint
135+ // order read it to tell a card from a window.
136136 Fade, Dim, Zoom float64
137137 }
138138
@@ -128,11 +128,11 @@ type Placement struct {
128 Leaving bool128 Leaving bool
129 // Fade, Dim and Zoom are set on shown layouts only (refreshShown),129 // Fade, Dim and Zoom are set on shown layouts only (refreshShown),
130 // animated: the window's fade (0 opaque, 1 invisible), a veil opacity130 // animated: the window's fade (0 opaque, 1 invisible), a veil opacity
131- // added to the one Peek draws, and the content zoom as drawn when a131+ // added to the one Peek draws (0..1), and the content zoom as drawn
132- // scale motion runs (Preview times the drawn/settled width, 1 when the132+ // when a scale motion runs (Preview times the drawn/settled width, 1
133- // content is drawn at its size; 0 without one). Preview itself stays133+ // when the content is drawn at its size; 0 without one). Preview
134- // the settled one: input and popups read it to tell a card from a134+ // itself stays the settled one: input, popups and the renderer's paint
135- // window.135+ // order read it to tell a card from a window.
136 Fade, Dim, Zoom float64136 Fade, Dim, Zoom float64
137 }137 }
138 138
M internal/core/publish_alloc_test.go +59 -0
@@ -14,8 +14,11 @@ import (
1414 // and the layouts are rebuilt for every publish, so the floor is not 0.
1515 // Measured: 43 before presizing the scene windows and separators, 36 after;
1616 // 70 with the overview open (its rows and card layouts are rebuilt too).
17+// Leaving entries (a closed window still fading) add none: the settled
18+// layout with them goes into a reused buffer.
1719 const (
1820 publishAllocBudget = 36
21+ publishLeavingAllocBudget = publishAllocBudget
1922 publishOverviewAllocBudget = 70
2023 )
2124
@@ -132,3 +135,59 @@ func TestPublishOverviewAllocations(t *testing.T) {
132135 t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget)
133136 }
134137 }
138+
139+// TestPublishLeavingAllocations pins the cost of a frame with leaving
140+// entries on every screen (two of the six windows closed, fading out, the
141+// other four with rect motions): not above a frame without them.
142+func TestPublishLeavingAllocations(t *testing.T) {
143+ c := publishRig(t, 2)
144+ t0 := time.Now()
145+ for _, sc := range c.screens {
146+ if sc.name() == "" {
147+ continue
148+ }
149+ c.refreshShown()
150+ layout := sc.settledLayout
151+ sc.rects = make(map[WindowID]rectMotion, 6)
152+ sp := viewSpring(100, 0)
153+ for i, p := range layout {
154+ if i < 2 {
155+ sc.mon.RemoveWindow(p.ID)
156+ c.leave(sc, p, t0)
157+ continue
158+ }
159+ sc.rects[p.ID] = rectMotion{
160+ x: c.spring(sp, t0), y: c.spring(sp, t0),
161+ w: c.spring(sp, t0), h: c.spring(sp, t0),
162+ dx: 100, dy: 100, dw: 100, dh: 100,
163+ }
164+ }
165+ sc.rectsWS = sc.mon.Current()
166+ }
167+ step := publishStep(t, c)
168+ step()
169+ for _, sc := range c.screens {
170+ if sc.name() == "" {
171+ continue
172+ }
173+ leaving := 0
174+ for _, p := range sc.shown {
175+ if p.Leaving {
176+ leaving++
177+ }
178+ }
179+ if len(sc.rects) != 6 || leaving != 2 || len(sc.shown) != 6 {
180+ t.Fatalf("setup: rects %d leaving %d shown %d", len(sc.rects), leaving, len(sc.shown))
181+ }
182+ }
183+ n := testing.AllocsPerRun(50, step)
184+ t.Logf("leaving allocs per step = %v", n)
185+ for _, sc := range c.screens {
186+ if sc.name() != "" && len(sc.rects) != 6 {
187+ t.Fatalf("motions settled during the measure: rects %d", len(sc.rects))
188+ }
189+ }
190+ if n > publishLeavingAllocBudget {
191+ t.Errorf("publish allocs per frame with leaving entries = %v, budget %d", n, publishLeavingAllocBudget)
192+ }
193+}
@@ -14,8 +14,11 @@ import (
14 // and the layouts are rebuilt for every publish, so the floor is not 0.14 // and the layouts are rebuilt for every publish, so the floor is not 0.
15 // Measured: 43 before presizing the scene windows and separators, 36 after;15 // Measured: 43 before presizing the scene windows and separators, 36 after;
16 // 70 with the overview open (its rows and card layouts are rebuilt too).16 // 70 with the overview open (its rows and card layouts are rebuilt too).
17+// Leaving entries (a closed window still fading) add none: the settled
18+// layout with them goes into a reused buffer.
17 const (19 const (
18 publishAllocBudget = 3620 publishAllocBudget = 36
21+ publishLeavingAllocBudget = publishAllocBudget
19 publishOverviewAllocBudget = 7022 publishOverviewAllocBudget = 70
20 )23 )
21 24
@@ -132,3 +135,59 @@ func TestPublishOverviewAllocations(t *testing.T) {
132 t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget)135 t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget)
133 }136 }
134 }137 }
138+
139+// TestPublishLeavingAllocations pins the cost of a frame with leaving
140+// entries on every screen (two of the six windows closed, fading out, the
141+// other four with rect motions): not above a frame without them.
142+func TestPublishLeavingAllocations(t *testing.T) {
143+ c := publishRig(t, 2)
144+ t0 := time.Now()
145+ for _, sc := range c.screens {
146+ if sc.name() == "" {
147+ continue
148+ }
149+ c.refreshShown()
150+ layout := sc.settledLayout
151+ sc.rects = make(map[WindowID]rectMotion, 6)
152+ sp := viewSpring(100, 0)
153+ for i, p := range layout {
154+ if i < 2 {
155+ sc.mon.RemoveWindow(p.ID)
156+ c.leave(sc, p, t0)
157+ continue
158+ }
159+ sc.rects[p.ID] = rectMotion{
160+ x: c.spring(sp, t0), y: c.spring(sp, t0),
161+ w: c.spring(sp, t0), h: c.spring(sp, t0),
162+ dx: 100, dy: 100, dw: 100, dh: 100,
163+ }
164+ }
165+ sc.rectsWS = sc.mon.Current()
166+ }
167+ step := publishStep(t, c)
168+ step()
169+ for _, sc := range c.screens {
170+ if sc.name() == "" {
171+ continue
172+ }
173+ leaving := 0
174+ for _, p := range sc.shown {
175+ if p.Leaving {
176+ leaving++
177+ }
178+ }
179+ if len(sc.rects) != 6 || leaving != 2 || len(sc.shown) != 6 {
180+ t.Fatalf("setup: rects %d leaving %d shown %d", len(sc.rects), leaving, len(sc.shown))
181+ }
182+ }
183+ n := testing.AllocsPerRun(50, step)
184+ t.Logf("leaving allocs per step = %v", n)
185+ for _, sc := range c.screens {
186+ if sc.name() != "" && len(sc.rects) != 6 {
187+ t.Fatalf("motions settled during the measure: rects %d", len(sc.rects))
188+ }
189+ }
190+ if n > publishLeavingAllocBudget {
191+ t.Errorf("publish allocs per frame with leaving entries = %v, budget %d", n, publishLeavingAllocBudget)
192+ }
193+}
M internal/core/screens.go +3 -1
@@ -37,7 +37,9 @@ type screen struct {
3737 // runs. Both are built once per publish (refreshShown); shown is nil
3838 // before the first publish, then callers measure the monitor's layout.
3939 settledLayout, shown []Placement
40- shownBuf []Placement
40+ // shownBuf and settledBuf back shown and settledLayout while rect
41+ // motions run (refreshShown), reused across publishes.
42+ shownBuf, settledBuf []Placement
4143 // rects are the running per-window rect motions (transition.go) of
4244 // rectsWS, the workspace they began on; created lazily.
4345 rects map[WindowID]rectMotion
@@ -37,7 +37,9 @@ type screen struct {
37 // runs. Both are built once per publish (refreshShown); shown is nil37 // runs. Both are built once per publish (refreshShown); shown is nil
38 // before the first publish, then callers measure the monitor's layout.38 // before the first publish, then callers measure the monitor's layout.
39 settledLayout, shown []Placement39 settledLayout, shown []Placement
40- shownBuf []Placement40+ // shownBuf and settledBuf back shown and settledLayout while rect
41+ // motions run (refreshShown), reused across publishes.
42+ shownBuf, settledBuf []Placement
41 // rects are the running per-window rect motions (transition.go) of43 // rects are the running per-window rect motions (transition.go) of
42 // rectsWS, the workspace they began on; created lazily.44 // rectsWS, the workspace they began on; created lazily.
43 rects map[WindowID]rectMotion45 rects map[WindowID]rectMotion
M internal/core/transition.go +37 -13
@@ -147,11 +147,15 @@ func (m *rectMotion) show(p *Placement) {
147147 settled := p.Rect
148148 p.Rect = m.apply(settled)
149149 p.Fade = max(0, min(1, m.settledFade()+m.df))
150- p.Dim = m.ddim
150+ p.Dim = max(0, min(1, p.Dim+m.ddim))
151151 if m.scale && settled.W > 0 {
152152 zoom := p.Preview
153153 if zoom == 0 {
154154 zoom = 1
155+ if m.leaving {
156+ // A leaving window settles shrunk, its content with it.
157+ zoom = appearScale
158+ }
155159 }
156160 zoom *= float64(p.Rect.W) / float64(settled.W)
157161 if zoom >= 0.999 {
@@ -208,7 +212,7 @@ func (c *Core) refreshShown() {
208212 sc.shown = sc.settledLayout
209213 continue
210214 }
211- sc.settledLayout = sc.appendLeaving(sc.settledLayout)
215+ sc.settledLayout = sc.withLeaving(sc.settledLayout)
212216 buf := append(sc.shownBuf[:0], sc.settledLayout...)
213217 for i := range buf {
214218 p := &buf[i]
@@ -223,24 +227,41 @@ func (c *Core) refreshShown() {
223227 }
224228 }
225229
226-// appendLeaving appends the leaving windows' last placements to layout,
227-// Hidden and Leaving, smallest ID first (a stable order keeps the scenes
228-// comparable frame to frame).
229-func (s *screen) appendLeaving(layout []Placement) []Placement {
230- n := len(layout)
230+// withLeaving is layout with the leaving windows' last placements added,
231+// Hidden and Leaving, into the screen's reused buffer (the layout itself
232+// is left alone). A leaving tile goes where the tiles are painted: before
233+// the first window that opens the floats (a float above the tiles, as the
234+// renderer orders them), so it never flashes over a float; a leaving float
235+// goes last, over the tiles. Within a group smallest ID first: a stable
236+// order keeps the scenes comparable frame to frame.
237+func (s *screen) withLeaving(layout []Placement) []Placement {
238+ out := append(s.settledBuf[:0], layout...)
239+ floats := len(out)
240+ for i, p := range out {
241+ if p.Floating && !p.Below && p.Preview == 0 && !p.Hidden {
242+ floats = i
243+ break
244+ }
245+ }
231246 for _, rm := range s.rects {
232247 if !rm.leaving {
233248 continue
234249 }
235250 p := rm.left
236251 p.Hidden, p.Leaving, p.Focused, p.Fade, p.Dim, p.Zoom = true, true, false, 0, 0, 0
237- i := n
238- for i < len(layout) && layout[i].ID < p.ID {
252+ lo, hi := floats, len(out)
253+ if !p.Floating {
254+ lo, hi = 0, floats
255+ floats++
256+ }
257+ i := lo
258+ for i < hi && (!out[i].Leaving || out[i].ID < p.ID) {
239259 i++
240260 }
241- layout = slices.Insert(layout, i, p)
261+ out = slices.Insert(out, i, p)
242262 }
243- return layout
263+ s.settledBuf = out
264+ return out
244265 }
245266
246267 // appear starts the entrance of a window the action made visible on sc:
@@ -288,9 +309,8 @@ func (c *Core) leave(sc *screen, p Placement, now time.Time) {
288309 rm := rectMotion{scale: true, leaving: true, left: p}
289310 rm.left.Hidden, rm.left.Leaving, rm.left.Focused = true, true, false
290311 // The entry settles at 90 % of the rect it left, its content zoomed
291- // to match (Preview): the offsets run from the full rect to that.
312+ // to match (show): the offsets run from the full rect to that.
292313 end := scaledRect(p.Rect, appearScale)
293- rm.left.Preview = appearScale
294314 c.retargetComponent(&rm.x, &rm.dx, float64(p.Rect.X-end.X), 0, now)
295315 c.retargetComponent(&rm.y, &rm.dy, float64(p.Rect.Y-end.Y), 0, now)
296316 c.retargetComponent(&rm.w, &rm.dw, float64(p.Rect.W-end.W), 0, now)
@@ -467,6 +487,10 @@ func (c *Core) transitionRects(b *viewShot, now time.Time) {
467487 }
468488 // Where the window was drawn, unrounded, relative to where it goes.
469489 rm := sc.rects[p.ID]
490+ // A re-flow may change the aspect: the content zoom (appear) stops
491+ // following the frame and snaps to the settled size; the fade goes
492+ // on.
493+ rm.scale = false
470494 c.retargetComponent(&rm.x, &rm.dx, float64(old.rect.X-p.Rect.X)+old.off.x, old.vel.x, now)
471495 c.retargetComponent(&rm.y, &rm.dy, float64(old.rect.Y-p.Rect.Y)+old.off.y, old.vel.y, now)
472496 c.retargetComponent(&rm.w, &rm.dw, float64(old.rect.W-p.Rect.W)+old.off.w, old.vel.w, now)
@@ -147,11 +147,15 @@ func (m *rectMotion) show(p *Placement) {
147 settled := p.Rect147 settled := p.Rect
148 p.Rect = m.apply(settled)148 p.Rect = m.apply(settled)
149 p.Fade = max(0, min(1, m.settledFade()+m.df))149 p.Fade = max(0, min(1, m.settledFade()+m.df))
150- p.Dim = m.ddim150+ p.Dim = max(0, min(1, p.Dim+m.ddim))
151 if m.scale && settled.W > 0 {151 if m.scale && settled.W > 0 {
152 zoom := p.Preview152 zoom := p.Preview
153 if zoom == 0 {153 if zoom == 0 {
154 zoom = 1154 zoom = 1
155+ if m.leaving {
156+ // A leaving window settles shrunk, its content with it.
157+ zoom = appearScale
158+ }
155 }159 }
156 zoom *= float64(p.Rect.W) / float64(settled.W)160 zoom *= float64(p.Rect.W) / float64(settled.W)
157 if zoom >= 0.999 {161 if zoom >= 0.999 {
@@ -208,7 +212,7 @@ func (c *Core) refreshShown() {
208 sc.shown = sc.settledLayout212 sc.shown = sc.settledLayout
209 continue213 continue
210 }214 }
211- sc.settledLayout = sc.appendLeaving(sc.settledLayout)215+ sc.settledLayout = sc.withLeaving(sc.settledLayout)
212 buf := append(sc.shownBuf[:0], sc.settledLayout...)216 buf := append(sc.shownBuf[:0], sc.settledLayout...)
213 for i := range buf {217 for i := range buf {
214 p := &buf[i]218 p := &buf[i]
@@ -223,24 +227,41 @@ func (c *Core) refreshShown() {
223 }227 }
224 }228 }
225 229
226-// appendLeaving appends the leaving windows' last placements to layout,230+// withLeaving is layout with the leaving windows' last placements added,
227-// Hidden and Leaving, smallest ID first (a stable order keeps the scenes231+// Hidden and Leaving, into the screen's reused buffer (the layout itself
228-// comparable frame to frame).232+// is left alone). A leaving tile goes where the tiles are painted: before
229-func (s *screen) appendLeaving(layout []Placement) []Placement {233+// the first window that opens the floats (a float above the tiles, as the
230- n := len(layout)234+// renderer orders them), so it never flashes over a float; a leaving float
235+// goes last, over the tiles. Within a group smallest ID first: a stable
236+// order keeps the scenes comparable frame to frame.
237+func (s *screen) withLeaving(layout []Placement) []Placement {
238+ out := append(s.settledBuf[:0], layout...)
239+ floats := len(out)
240+ for i, p := range out {
241+ if p.Floating && !p.Below && p.Preview == 0 && !p.Hidden {
242+ floats = i
243+ break
244+ }
245+ }
231 for _, rm := range s.rects {246 for _, rm := range s.rects {
232 if !rm.leaving {247 if !rm.leaving {
233 continue248 continue
234 }249 }
235 p := rm.left250 p := rm.left
236 p.Hidden, p.Leaving, p.Focused, p.Fade, p.Dim, p.Zoom = true, true, false, 0, 0, 0251 p.Hidden, p.Leaving, p.Focused, p.Fade, p.Dim, p.Zoom = true, true, false, 0, 0, 0
237- i := n252+ lo, hi := floats, len(out)
238- for i < len(layout) && layout[i].ID < p.ID {253+ if !p.Floating {
254+ lo, hi = 0, floats
255+ floats++
256+ }
257+ i := lo
258+ for i < hi && (!out[i].Leaving || out[i].ID < p.ID) {
239 i++259 i++
240 }260 }
241- layout = slices.Insert(layout, i, p)261+ out = slices.Insert(out, i, p)
242 }262 }
243- return layout263+ s.settledBuf = out
264+ return out
244 }265 }
245 266
246 // appear starts the entrance of a window the action made visible on sc:267 // appear starts the entrance of a window the action made visible on sc:
@@ -288,9 +309,8 @@ func (c *Core) leave(sc *screen, p Placement, now time.Time) {
288 rm := rectMotion{scale: true, leaving: true, left: p}309 rm := rectMotion{scale: true, leaving: true, left: p}
289 rm.left.Hidden, rm.left.Leaving, rm.left.Focused = true, true, false310 rm.left.Hidden, rm.left.Leaving, rm.left.Focused = true, true, false
290 // The entry settles at 90 % of the rect it left, its content zoomed311 // The entry settles at 90 % of the rect it left, its content zoomed
291- // to match (Preview): the offsets run from the full rect to that.312+ // to match (show): the offsets run from the full rect to that.
292 end := scaledRect(p.Rect, appearScale)313 end := scaledRect(p.Rect, appearScale)
293- rm.left.Preview = appearScale
294 c.retargetComponent(&rm.x, &rm.dx, float64(p.Rect.X-end.X), 0, now)314 c.retargetComponent(&rm.x, &rm.dx, float64(p.Rect.X-end.X), 0, now)
295 c.retargetComponent(&rm.y, &rm.dy, float64(p.Rect.Y-end.Y), 0, now)315 c.retargetComponent(&rm.y, &rm.dy, float64(p.Rect.Y-end.Y), 0, now)
296 c.retargetComponent(&rm.w, &rm.dw, float64(p.Rect.W-end.W), 0, now)316 c.retargetComponent(&rm.w, &rm.dw, float64(p.Rect.W-end.W), 0, now)
@@ -467,6 +487,10 @@ func (c *Core) transitionRects(b *viewShot, now time.Time) {
467 }487 }
468 // Where the window was drawn, unrounded, relative to where it goes.488 // Where the window was drawn, unrounded, relative to where it goes.
469 rm := sc.rects[p.ID]489 rm := sc.rects[p.ID]
490+ // A re-flow may change the aspect: the content zoom (appear) stops
491+ // following the frame and snaps to the settled size; the fade goes
492+ // on.
493+ rm.scale = false
470 c.retargetComponent(&rm.x, &rm.dx, float64(old.rect.X-p.Rect.X)+old.off.x, old.vel.x, now)494 c.retargetComponent(&rm.x, &rm.dx, float64(old.rect.X-p.Rect.X)+old.off.x, old.vel.x, now)
471 c.retargetComponent(&rm.y, &rm.dy, float64(old.rect.Y-p.Rect.Y)+old.off.y, old.vel.y, now)495 c.retargetComponent(&rm.y, &rm.dy, float64(old.rect.Y-p.Rect.Y)+old.off.y, old.vel.y, now)
472 c.retargetComponent(&rm.w, &rm.dw, float64(old.rect.W-p.Rect.W)+old.off.w, old.vel.w, now)496 c.retargetComponent(&rm.w, &rm.dw, float64(old.rect.W-p.Rect.W)+old.off.w, old.vel.w, now)
M internal/core/transition_fade_internal_test.go +126 -12
@@ -44,8 +44,9 @@ func sceneWindow(t *testing.T, s ports.Scene, id WindowID) ports.SceneWindow {
4444
4545 // appear fades a window in from invisible and grows it from 90 % of its
4646 // settled rect, its content zoomed to the drawn size; the shown placement
47-// carries the derived Fade and Preview, the settled one neither.
48-func TestAppearDerivesFadeAndPreview(t *testing.T) {
47+// carries the derived Fade and Zoom, the settled one neither; Preview
48+// stays 0 (not a card).
49+func TestAppearDerivesFadeAndZoom(t *testing.T) {
4950 c, ic, cmds := fadeCore(t)
5051 c.cfg.Animations.On = true
5152 sc := c.cur()
@@ -65,8 +66,8 @@ func TestAppearDerivesFadeAndPreview(t *testing.T) {
6566 if w.Rect != want {
6667 t.Fatalf("first rect %+v, want 90 %% of %+v = %+v", w.Rect, settled.Rect, want)
6768 }
68- if math.Abs(w.Preview-0.9) > 0.02 {
69- t.Fatalf("preview %v, want ~0.9 (content follows the drawn size)", w.Preview)
69+ if math.Abs(w.Zoom-0.9) > 0.02 || w.Preview != 0 {
70+ t.Fatalf("zoom %v preview %v, want ~0.9 and 0 (content follows the drawn size, not a card)", w.Zoom, w.Preview)
7071 }
7172 if ps := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 2 })]; ps != settled {
7273 t.Fatalf("settled placement changed: %+v vs %+v", ps, settled)
@@ -87,8 +88,8 @@ func TestAppearDerivesFadeAndPreview(t *testing.T) {
8788 c.animate(ic.now, nil)
8889 s = indicatorScene(t, c)
8990 w = sceneWindow(t, s, 2)
90- if !(w.Fade > 0 && w.Fade < 1) || !(w.Preview > 0.9 && w.Preview < 1) {
91- t.Fatalf("mid-way fade %v preview %v, want strictly between", w.Fade, w.Preview)
91+ if !(w.Fade > 0 && w.Fade < 1) || !(w.Zoom > 0.9 && w.Zoom < 1) {
92+ t.Fatalf("mid-way fade %v zoom %v, want strictly between", w.Fade, w.Zoom)
9293 }
9394 if !(w.Rect.W > want.W && w.Rect.W < settled.Rect.W) {
9495 t.Fatalf("mid-way width %d, want between %d and %d", w.Rect.W, want.W, settled.Rect.W)
@@ -98,7 +99,7 @@ func TestAppearDerivesFadeAndPreview(t *testing.T) {
9899 c.animate(ic.now, nil)
99100 s = indicatorScene(t, c)
100101 w = sceneWindow(t, s, 2)
101- if w.Fade != 0 || w.Preview != 0 || w.Rect != settled.Rect || len(sc.rects) != 0 {
102+ if w.Fade != 0 || w.Zoom != 0 || w.Rect != settled.Rect || len(sc.rects) != 0 {
102103 t.Fatalf("settled: %+v, rects %d", w, len(sc.rects))
103104 }
104105 if c.animating() {
@@ -116,7 +117,7 @@ func TestAppearOffIsInstant(t *testing.T) {
116117 if len(sc.rects) != 0 || c.animating() {
117118 t.Fatal("appear started a motion with animations off")
118119 }
119- if w := sceneWindow(t, indicatorScene(t, c), 1); w.Fade != 0 || w.Preview != 0 {
120+ if w := sceneWindow(t, indicatorScene(t, c), 1); w.Fade != 0 || w.Zoom != 0 {
120121 t.Fatalf("faded with animations off: %+v", w)
121122 }
122123 }
@@ -151,7 +152,7 @@ func TestLeavingEntryLifecycle(t *testing.T) {
151152 t.Fatalf("leaving entry: shown %+v settled %+v", sc.shown[k], sc.settledLayout[k])
152153 }
153154 w := sceneWindow(t, s, 2)
154- if w.Hidden || w.Fade != 0 || w.Rect != left.Rect || w.Preview != 0 {
155+ if w.Hidden || w.Fade != 0 || w.Rect != left.Rect || w.Zoom != 0 || w.Preview != 0 {
155156 t.Fatalf("first leaving frame %+v, want drawn opaque at %+v", w, left.Rect)
156157 }
157158 if ids := drainConfigures(cmds); slices.Contains(ids, 2) {
@@ -176,7 +177,7 @@ func TestLeavingEntryLifecycle(t *testing.T) {
176177 c.animate(ic.now, nil)
177178 s = indicatorScene(t, c)
178179 w = sceneWindow(t, s, 2)
179- if !(w.Fade > 0 && w.Fade < 1) || !(w.Rect.W < left.Rect.W) || !(w.Preview > 0.9 && w.Preview < 1) {
180+ if !(w.Fade > 0 && w.Fade < 1) || !(w.Rect.W < left.Rect.W) || !(w.Zoom > 0.9 && w.Zoom < 1) {
180181 t.Fatalf("mid-way %+v", w)
181182 }
182183
@@ -212,7 +213,7 @@ func TestLeavingEntryDroppedWhenShownAgain(t *testing.T) {
212213 for _, w := range s.Windows {
213214 if w.ID == 2 {
214215 n++
215- if w.Fade != 0 || w.Preview != 0 {
216+ if w.Fade != 0 || w.Zoom != 0 {
216217 t.Fatalf("the re-added window keeps the leaving fade: %+v", w)
217218 }
218219 }
@@ -295,7 +296,7 @@ func TestScaleMotionMultipliesSettledPreview(t *testing.T) {
295296 if p.Rect.W != 150 || math.Abs(p.Zoom-0.3) > 1e-9 || p.Preview != 0.2 {
296297 t.Fatalf("shown %+v, want width 150, zoom 0.3 and the settled preview", p)
297298 }
298- // A plain window drawn at its size: Zoom 1 (publish emits Preview 0).
299+ // A plain window drawn at its size: Zoom 1 (publish emits Zoom 0).
299300 rm.dw = 0.01
300301 p = Placement{ID: 1, Rect: Rect{W: 100, H: 100}}
301302 rm.show(&p)
@@ -322,3 +323,116 @@ func TestSnapshotSkipsLeavingEntries(t *testing.T) {
322323 t.Fatalf("snapshot rects %+v, want window 1 only", shots[0].rects)
323324 }
324325 }
326+
327+// A leaving tile is listed where the tiles are painted, before the first
328+// float that opens them; a leaving float goes last. Settled and shown stay
329+// aligned. The veil keeps following the leaving float, fading with it.
330+func TestLeavingPaintOrder(t *testing.T) {
331+ c, ic := indicatorCore(t)
332+ c.cfg.Animations.On = true
333+ c.cfg.Floating.Dim = 0.4
334+ sc := c.cur()
335+ ws := sc.mon.Current()
336+ sc.mon.AddWindow(1)
337+ sc.mon.AddWindow(2)
338+ ws.AddFloating(3, 80, 60)
339+ settleShown(t, c)
340+ tile := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 2 })]
341+ float := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 3 })]
342+ if !float.Floating || tile.Floating {
343+ t.Fatalf("setup: tile %+v float %+v", tile, float)
344+ }
345+
346+ // Close the tile: its entry sits before the float.
347+ sc.mon.RemoveWindow(2)
348+ c.leave(sc, tile, ic.now)
349+ s := indicatorScene(t, c)
350+ order := func(s ports.Scene) []WindowID {
351+ ids := make([]WindowID, 0, len(s.Windows))
352+ for _, w := range s.Windows {
353+ ids = append(ids, w.ID)
354+ }
355+ return ids
356+ }
357+ if got := order(s); !slices.Equal(got, []WindowID{1, 2, 3}) {
358+ t.Fatalf("order with a leaving tile %v, want [1 2 3] (tile before the float)", got)
359+ }
360+ for k := range sc.shown {
361+ if sc.shown[k].ID != sc.settledLayout[k].ID {
362+ t.Fatalf("index %d: shown %d, settled %d", k, sc.shown[k].ID, sc.settledLayout[k].ID)
363+ }
364+ }
365+ if s.Dim != 0.4 {
366+ t.Fatalf("veil %v with the float still shown, want 0.4", s.Dim)
367+ }
368+
369+ // Close the float too: its entry goes last and keeps the veil, which
370+ // fades with it.
371+ sc.mon.RemoveWindow(3)
372+ c.leave(sc, float, ic.now)
373+ s = indicatorScene(t, c)
374+ if got := order(s); !slices.Equal(got, []WindowID{1, 2, 3}) {
375+ t.Fatalf("order with a leaving float %v, want [1 2 3] (float last)", got)
376+ }
377+ if s.Dim != 0.4 {
378+ t.Fatalf("veil %v right after the float left, want 0.4", s.Dim)
379+ }
380+ ic.now = ic.now.Add(60 * time.Millisecond)
381+ c.animate(ic.now, nil)
382+ s = indicatorScene(t, c)
383+ f := sceneWindow(t, s, 3)
384+ if !(s.Dim > 0 && s.Dim < 0.4) || math.Abs(s.Dim-0.4*(1-f.Fade)) > 1e-9 {
385+ t.Fatalf("veil %v mid-fade (float fade %v), want 0.4*(1-fade)", s.Dim, f.Fade)
386+ }
387+ ic.now = ic.now.Add(5 * time.Second)
388+ c.animate(ic.now, nil)
389+ s = indicatorScene(t, c)
390+ if s.Dim != 0 || len(s.Windows) != 1 {
391+ t.Fatalf("after the settle: dim %v windows %+v", s.Dim, s.Windows)
392+ }
393+}
394+
395+// A re-flow that retargets an appearing window's rect motion stops its
396+// content zoom (the aspect may change) and keeps its fade.
397+func TestRetargetClearsScaleKeepsFade(t *testing.T) {
398+ c, ic := indicatorCore(t)
399+ c.cfg.Animations.On = true
400+ sc := c.cur()
401+ sc.mon.AddWindow(1)
402+ settleShown(t, c)
403+ c.appear(sc, 1, ic.now)
404+ settleShown(t, c)
405+ if rm := sc.rects[1]; !rm.scale || !rm.fade.on {
406+ t.Fatalf("setup: %+v", rm)
407+ }
408+ ic.now = ic.now.Add(20 * time.Millisecond)
409+ c.animate(ic.now, nil)
410+ settleShown(t, c)
411+ before := c.snapshot(ic.now)
412+ sc.mon.AddWindow(2) // re-flows window 1
413+ c.transition(before, ic.now)
414+ rm, ok := sc.rects[1]
415+ if !ok || rm.scale || !rm.fade.on || !rm.on() {
416+ t.Fatalf("after the re-flow: %+v", rm)
417+ }
418+ w := sceneWindow(t, indicatorScene(t, c), 1)
419+ if w.Zoom != 0 || !(w.Fade > 0 && w.Fade < 1) {
420+ t.Fatalf("zoom %v fade %v, want no zoom and a running fade", w.Zoom, w.Fade)
421+ }
422+}
423+
424+// Dim offsets are clamped into 0..1 on the shown placement.
425+func TestDimOffsetClamped(t *testing.T) {
426+ var rm rectMotion
427+ rm.ddim = -0.3
428+ p := Placement{ID: 1, Rect: Rect{W: 10, H: 10}}
429+ rm.show(&p)
430+ if p.Dim != 0 {
431+ t.Fatalf("dim %v, want 0", p.Dim)
432+ }
433+ rm.ddim = 1.5
434+ rm.show(&p)
435+ if p.Dim != 1 {
436+ t.Fatalf("dim %v, want 1", p.Dim)
437+ }
438+}
@@ -44,8 +44,9 @@ func sceneWindow(t *testing.T, s ports.Scene, id WindowID) ports.SceneWindow {
44 44
45 // appear fades a window in from invisible and grows it from 90 % of its45 // appear fades a window in from invisible and grows it from 90 % of its
46 // settled rect, its content zoomed to the drawn size; the shown placement46 // settled rect, its content zoomed to the drawn size; the shown placement
47-// carries the derived Fade and Preview, the settled one neither.47+// carries the derived Fade and Zoom, the settled one neither; Preview
48-func TestAppearDerivesFadeAndPreview(t *testing.T) {48+// stays 0 (not a card).
49+func TestAppearDerivesFadeAndZoom(t *testing.T) {
49 c, ic, cmds := fadeCore(t)50 c, ic, cmds := fadeCore(t)
50 c.cfg.Animations.On = true51 c.cfg.Animations.On = true
51 sc := c.cur()52 sc := c.cur()
@@ -65,8 +66,8 @@ func TestAppearDerivesFadeAndPreview(t *testing.T) {
65 if w.Rect != want {66 if w.Rect != want {
66 t.Fatalf("first rect %+v, want 90 %% of %+v = %+v", w.Rect, settled.Rect, want)67 t.Fatalf("first rect %+v, want 90 %% of %+v = %+v", w.Rect, settled.Rect, want)
67 }68 }
68- if math.Abs(w.Preview-0.9) > 0.02 {69+ if math.Abs(w.Zoom-0.9) > 0.02 || w.Preview != 0 {
69- t.Fatalf("preview %v, want ~0.9 (content follows the drawn size)", w.Preview)70+ t.Fatalf("zoom %v preview %v, want ~0.9 and 0 (content follows the drawn size, not a card)", w.Zoom, w.Preview)
70 }71 }
71 if ps := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 2 })]; ps != settled {72 if ps := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 2 })]; ps != settled {
72 t.Fatalf("settled placement changed: %+v vs %+v", ps, settled)73 t.Fatalf("settled placement changed: %+v vs %+v", ps, settled)
@@ -87,8 +88,8 @@ func TestAppearDerivesFadeAndPreview(t *testing.T) {
87 c.animate(ic.now, nil)88 c.animate(ic.now, nil)
88 s = indicatorScene(t, c)89 s = indicatorScene(t, c)
89 w = sceneWindow(t, s, 2)90 w = sceneWindow(t, s, 2)
90- if !(w.Fade > 0 && w.Fade < 1) || !(w.Preview > 0.9 && w.Preview < 1) {91+ if !(w.Fade > 0 && w.Fade < 1) || !(w.Zoom > 0.9 && w.Zoom < 1) {
91- t.Fatalf("mid-way fade %v preview %v, want strictly between", w.Fade, w.Preview)92+ t.Fatalf("mid-way fade %v zoom %v, want strictly between", w.Fade, w.Zoom)
92 }93 }
93 if !(w.Rect.W > want.W && w.Rect.W < settled.Rect.W) {94 if !(w.Rect.W > want.W && w.Rect.W < settled.Rect.W) {
94 t.Fatalf("mid-way width %d, want between %d and %d", w.Rect.W, want.W, settled.Rect.W)95 t.Fatalf("mid-way width %d, want between %d and %d", w.Rect.W, want.W, settled.Rect.W)
@@ -98,7 +99,7 @@ func TestAppearDerivesFadeAndPreview(t *testing.T) {
98 c.animate(ic.now, nil)99 c.animate(ic.now, nil)
99 s = indicatorScene(t, c)100 s = indicatorScene(t, c)
100 w = sceneWindow(t, s, 2)101 w = sceneWindow(t, s, 2)
101- if w.Fade != 0 || w.Preview != 0 || w.Rect != settled.Rect || len(sc.rects) != 0 {102+ if w.Fade != 0 || w.Zoom != 0 || w.Rect != settled.Rect || len(sc.rects) != 0 {
102 t.Fatalf("settled: %+v, rects %d", w, len(sc.rects))103 t.Fatalf("settled: %+v, rects %d", w, len(sc.rects))
103 }104 }
104 if c.animating() {105 if c.animating() {
@@ -116,7 +117,7 @@ func TestAppearOffIsInstant(t *testing.T) {
116 if len(sc.rects) != 0 || c.animating() {117 if len(sc.rects) != 0 || c.animating() {
117 t.Fatal("appear started a motion with animations off")118 t.Fatal("appear started a motion with animations off")
118 }119 }
119- if w := sceneWindow(t, indicatorScene(t, c), 1); w.Fade != 0 || w.Preview != 0 {120+ if w := sceneWindow(t, indicatorScene(t, c), 1); w.Fade != 0 || w.Zoom != 0 {
120 t.Fatalf("faded with animations off: %+v", w)121 t.Fatalf("faded with animations off: %+v", w)
121 }122 }
122 }123 }
@@ -151,7 +152,7 @@ func TestLeavingEntryLifecycle(t *testing.T) {
151 t.Fatalf("leaving entry: shown %+v settled %+v", sc.shown[k], sc.settledLayout[k])152 t.Fatalf("leaving entry: shown %+v settled %+v", sc.shown[k], sc.settledLayout[k])
152 }153 }
153 w := sceneWindow(t, s, 2)154 w := sceneWindow(t, s, 2)
154- if w.Hidden || w.Fade != 0 || w.Rect != left.Rect || w.Preview != 0 {155+ if w.Hidden || w.Fade != 0 || w.Rect != left.Rect || w.Zoom != 0 || w.Preview != 0 {
155 t.Fatalf("first leaving frame %+v, want drawn opaque at %+v", w, left.Rect)156 t.Fatalf("first leaving frame %+v, want drawn opaque at %+v", w, left.Rect)
156 }157 }
157 if ids := drainConfigures(cmds); slices.Contains(ids, 2) {158 if ids := drainConfigures(cmds); slices.Contains(ids, 2) {
@@ -176,7 +177,7 @@ func TestLeavingEntryLifecycle(t *testing.T) {
176 c.animate(ic.now, nil)177 c.animate(ic.now, nil)
177 s = indicatorScene(t, c)178 s = indicatorScene(t, c)
178 w = sceneWindow(t, s, 2)179 w = sceneWindow(t, s, 2)
179- if !(w.Fade > 0 && w.Fade < 1) || !(w.Rect.W < left.Rect.W) || !(w.Preview > 0.9 && w.Preview < 1) {180+ if !(w.Fade > 0 && w.Fade < 1) || !(w.Rect.W < left.Rect.W) || !(w.Zoom > 0.9 && w.Zoom < 1) {
180 t.Fatalf("mid-way %+v", w)181 t.Fatalf("mid-way %+v", w)
181 }182 }
182 183
@@ -212,7 +213,7 @@ func TestLeavingEntryDroppedWhenShownAgain(t *testing.T) {
212 for _, w := range s.Windows {213 for _, w := range s.Windows {
213 if w.ID == 2 {214 if w.ID == 2 {
214 n++215 n++
215- if w.Fade != 0 || w.Preview != 0 {216+ if w.Fade != 0 || w.Zoom != 0 {
216 t.Fatalf("the re-added window keeps the leaving fade: %+v", w)217 t.Fatalf("the re-added window keeps the leaving fade: %+v", w)
217 }218 }
218 }219 }
@@ -295,7 +296,7 @@ func TestScaleMotionMultipliesSettledPreview(t *testing.T) {
295 if p.Rect.W != 150 || math.Abs(p.Zoom-0.3) > 1e-9 || p.Preview != 0.2 {296 if p.Rect.W != 150 || math.Abs(p.Zoom-0.3) > 1e-9 || p.Preview != 0.2 {
296 t.Fatalf("shown %+v, want width 150, zoom 0.3 and the settled preview", p)297 t.Fatalf("shown %+v, want width 150, zoom 0.3 and the settled preview", p)
297 }298 }
298- // A plain window drawn at its size: Zoom 1 (publish emits Preview 0).299+ // A plain window drawn at its size: Zoom 1 (publish emits Zoom 0).
299 rm.dw = 0.01300 rm.dw = 0.01
300 p = Placement{ID: 1, Rect: Rect{W: 100, H: 100}}301 p = Placement{ID: 1, Rect: Rect{W: 100, H: 100}}
301 rm.show(&p)302 rm.show(&p)
@@ -322,3 +323,116 @@ func TestSnapshotSkipsLeavingEntries(t *testing.T) {
322 t.Fatalf("snapshot rects %+v, want window 1 only", shots[0].rects)323 t.Fatalf("snapshot rects %+v, want window 1 only", shots[0].rects)
323 }324 }
324 }325 }
326+
327+// A leaving tile is listed where the tiles are painted, before the first
328+// float that opens them; a leaving float goes last. Settled and shown stay
329+// aligned. The veil keeps following the leaving float, fading with it.
330+func TestLeavingPaintOrder(t *testing.T) {
331+ c, ic := indicatorCore(t)
332+ c.cfg.Animations.On = true
333+ c.cfg.Floating.Dim = 0.4
334+ sc := c.cur()
335+ ws := sc.mon.Current()
336+ sc.mon.AddWindow(1)
337+ sc.mon.AddWindow(2)
338+ ws.AddFloating(3, 80, 60)
339+ settleShown(t, c)
340+ tile := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 2 })]
341+ float := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 3 })]
342+ if !float.Floating || tile.Floating {
343+ t.Fatalf("setup: tile %+v float %+v", tile, float)
344+ }
345+
346+ // Close the tile: its entry sits before the float.
347+ sc.mon.RemoveWindow(2)
348+ c.leave(sc, tile, ic.now)
349+ s := indicatorScene(t, c)
350+ order := func(s ports.Scene) []WindowID {
351+ ids := make([]WindowID, 0, len(s.Windows))
352+ for _, w := range s.Windows {
353+ ids = append(ids, w.ID)
354+ }
355+ return ids
356+ }
357+ if got := order(s); !slices.Equal(got, []WindowID{1, 2, 3}) {
358+ t.Fatalf("order with a leaving tile %v, want [1 2 3] (tile before the float)", got)
359+ }
360+ for k := range sc.shown {
361+ if sc.shown[k].ID != sc.settledLayout[k].ID {
362+ t.Fatalf("index %d: shown %d, settled %d", k, sc.shown[k].ID, sc.settledLayout[k].ID)
363+ }
364+ }
365+ if s.Dim != 0.4 {
366+ t.Fatalf("veil %v with the float still shown, want 0.4", s.Dim)
367+ }
368+
369+ // Close the float too: its entry goes last and keeps the veil, which
370+ // fades with it.
371+ sc.mon.RemoveWindow(3)
372+ c.leave(sc, float, ic.now)
373+ s = indicatorScene(t, c)
374+ if got := order(s); !slices.Equal(got, []WindowID{1, 2, 3}) {
375+ t.Fatalf("order with a leaving float %v, want [1 2 3] (float last)", got)
376+ }
377+ if s.Dim != 0.4 {
378+ t.Fatalf("veil %v right after the float left, want 0.4", s.Dim)
379+ }
380+ ic.now = ic.now.Add(60 * time.Millisecond)
381+ c.animate(ic.now, nil)
382+ s = indicatorScene(t, c)
383+ f := sceneWindow(t, s, 3)
384+ if !(s.Dim > 0 && s.Dim < 0.4) || math.Abs(s.Dim-0.4*(1-f.Fade)) > 1e-9 {
385+ t.Fatalf("veil %v mid-fade (float fade %v), want 0.4*(1-fade)", s.Dim, f.Fade)
386+ }
387+ ic.now = ic.now.Add(5 * time.Second)
388+ c.animate(ic.now, nil)
389+ s = indicatorScene(t, c)
390+ if s.Dim != 0 || len(s.Windows) != 1 {
391+ t.Fatalf("after the settle: dim %v windows %+v", s.Dim, s.Windows)
392+ }
393+}
394+
395+// A re-flow that retargets an appearing window's rect motion stops its
396+// content zoom (the aspect may change) and keeps its fade.
397+func TestRetargetClearsScaleKeepsFade(t *testing.T) {
398+ c, ic := indicatorCore(t)
399+ c.cfg.Animations.On = true
400+ sc := c.cur()
401+ sc.mon.AddWindow(1)
402+ settleShown(t, c)
403+ c.appear(sc, 1, ic.now)
404+ settleShown(t, c)
405+ if rm := sc.rects[1]; !rm.scale || !rm.fade.on {
406+ t.Fatalf("setup: %+v", rm)
407+ }
408+ ic.now = ic.now.Add(20 * time.Millisecond)
409+ c.animate(ic.now, nil)
410+ settleShown(t, c)
411+ before := c.snapshot(ic.now)
412+ sc.mon.AddWindow(2) // re-flows window 1
413+ c.transition(before, ic.now)
414+ rm, ok := sc.rects[1]
415+ if !ok || rm.scale || !rm.fade.on || !rm.on() {
416+ t.Fatalf("after the re-flow: %+v", rm)
417+ }
418+ w := sceneWindow(t, indicatorScene(t, c), 1)
419+ if w.Zoom != 0 || !(w.Fade > 0 && w.Fade < 1) {
420+ t.Fatalf("zoom %v fade %v, want no zoom and a running fade", w.Zoom, w.Fade)
421+ }
422+}
423+
424+// Dim offsets are clamped into 0..1 on the shown placement.
425+func TestDimOffsetClamped(t *testing.T) {
426+ var rm rectMotion
427+ rm.ddim = -0.3
428+ p := Placement{ID: 1, Rect: Rect{W: 10, H: 10}}
429+ rm.show(&p)
430+ if p.Dim != 0 {
431+ t.Fatalf("dim %v, want 0", p.Dim)
432+ }
433+ rm.ddim = 1.5
434+ rm.show(&p)
435+ if p.Dim != 1 {
436+ t.Fatalf("dim %v, want 1", p.Dim)
437+ }
438+}

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