detached f0176e6

fix(core): follow a float's first commit in its entrance, drop it only for a fullscreen that applied

A float maps with no size (it commits after the map), so its entrance was laid
out from the default rect and broke the aspect once WindowResized moved the
settled rect. WindowResized now scales the running entrance's offsets and
springs by the settled size ratio, keeping progress and speed. A fullscreen
request that was ignored (grace after the map) no longer drops the entrance.
The headless collector stops in a cleanup.
commit
f0176e656ea4a9550928d41021705dcf5504a034
author
brice · · 2026-10-04 23:07 UTC
parent
5a2f88e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +148 -8
M internal/app/app_test.go +7 -2
@@ -9,6 +9,7 @@ import (
99 "os/exec"
1010 "path/filepath"
1111 "strings"
12+ "sync"
1213 "testing"
1314 "time"
1415
@@ -262,6 +263,11 @@ func headlessPointerClickFocus(t *testing.T, animated bool) {
262263 }
263264 }
264265 }()
266+ // Stopped before the scenes are read, or at the latest when the test
267+ // ends (an early Fatal or Skip).
268+ var halt sync.Once
269+ stopCollector := func() { halt.Do(func() { close(stop); <-collected }) }
270+ t.Cleanup(stopCollector)
265271 err := Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: cfg, Script: io.NopCloser(strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n")), Timeout: 5 * time.Second, testScenes: scenes})
266272 if err != nil {
267273 if strings.Contains(strings.ToLower(err.Error()), "vulkan") {
@@ -269,8 +275,7 @@ func headlessPointerClickFocus(t *testing.T, animated bool) {
269275 }
270276 t.Fatal(err)
271277 }
272- close(stop)
273- <-collected
278+ stopCollector()
274279 for len(scenes) > 0 {
275280 got = append(got, <-scenes)
276281 }
@@ -9,6 +9,7 @@ import (
9 "os/exec"9 "os/exec"
10 "path/filepath"10 "path/filepath"
11 "strings"11 "strings"
12+ "sync"
12 "testing"13 "testing"
13 "time"14 "time"
14 15
@@ -262,6 +263,11 @@ func headlessPointerClickFocus(t *testing.T, animated bool) {
262 }263 }
263 }264 }
264 }()265 }()
266+ // Stopped before the scenes are read, or at the latest when the test
267+ // ends (an early Fatal or Skip).
268+ var halt sync.Once
269+ stopCollector := func() { halt.Do(func() { close(stop); <-collected }) }
270+ t.Cleanup(stopCollector)
265 err := Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: cfg, Script: io.NopCloser(strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n")), Timeout: 5 * time.Second, testScenes: scenes})271 err := Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: cfg, Script: io.NopCloser(strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n")), Timeout: 5 * time.Second, testScenes: scenes})
266 if err != nil {272 if err != nil {
267 if strings.Contains(strings.ToLower(err.Error()), "vulkan") {273 if strings.Contains(strings.ToLower(err.Error()), "vulkan") {
@@ -269,8 +275,7 @@ func headlessPointerClickFocus(t *testing.T, animated bool) {
269 }275 }
270 t.Fatal(err)276 t.Fatal(err)
271 }277 }
272- close(stop)278+ stopCollector()
273- <-collected
274 for len(scenes) > 0 {279 for len(scenes) > 0 {
275 got = append(got, <-scenes)280 got = append(got, <-scenes)
276 }281 }
M internal/core/core.go +6 -6
@@ -1107,9 +1107,7 @@ func (c *Core) Run(ctx context.Context) error {
11071107 case ports.WindowMapped:
11081108 c.mapWindow(v)
11091109 case ports.WindowResized:
1110- if _, w := c.screenOf(v.ID); w != nil {
1111- w.ResizeFloating(v.ID, v.Width, v.Height)
1112- }
1110+ c.resizeFloating(v)
11131111 case ports.PopupRequest:
11141112 if err := c.placePopup(ctx, v); err != nil {
11151113 return nil
@@ -1185,9 +1183,11 @@ func (c *Core) Run(ctx context.Context) error {
11851183 }
11861184 if s, _ := c.screenOf(v.ID); s != nil {
11871185 s.mon.SetFullscreen(v.ID, v.Fullscreen)
1188- if v.Fullscreen {
1189- // A game asking for fullscreen right after its map
1190- // does not fade in: it keeps the direct scanout path.
1186+ if w := s.mon.Current(); v.Fullscreen && w.fullscreen == v.ID {
1187+ // A taskbar or late fullscreen request that took
1188+ // effect: the window does not fade in, it keeps the
1189+ // direct scanout path. One that was ignored (the
1190+ // grace after the map) leaves the entrance running.
11911191 delete(s.rects, v.ID)
11921192 }
11931193 }
@@ -1107,9 +1107,7 @@ func (c *Core) Run(ctx context.Context) error {
1107 case ports.WindowMapped:1107 case ports.WindowMapped:
1108 c.mapWindow(v)1108 c.mapWindow(v)
1109 case ports.WindowResized:1109 case ports.WindowResized:
1110- if _, w := c.screenOf(v.ID); w != nil {1110+ c.resizeFloating(v)
1111- w.ResizeFloating(v.ID, v.Width, v.Height)
1112- }
1113 case ports.PopupRequest:1111 case ports.PopupRequest:
1114 if err := c.placePopup(ctx, v); err != nil {1112 if err := c.placePopup(ctx, v); err != nil {
1115 return nil1113 return nil
@@ -1185,9 +1183,11 @@ func (c *Core) Run(ctx context.Context) error {
1185 }1183 }
1186 if s, _ := c.screenOf(v.ID); s != nil {1184 if s, _ := c.screenOf(v.ID); s != nil {
1187 s.mon.SetFullscreen(v.ID, v.Fullscreen)1185 s.mon.SetFullscreen(v.ID, v.Fullscreen)
1188- if v.Fullscreen {1186+ if w := s.mon.Current(); v.Fullscreen && w.fullscreen == v.ID {
1189- // A game asking for fullscreen right after its map1187+ // A taskbar or late fullscreen request that took
1190- // does not fade in: it keeps the direct scanout path.1188+ // effect: the window does not fade in, it keeps the
1189+ // direct scanout path. One that was ignored (the
1190+ // grace after the map) leaves the entrance running.
1191 delete(s.rects, v.ID)1191 delete(s.rects, v.ID)
1192 }1192 }
1193 }1193 }
M internal/core/map_animation_test.go +19 -0
@@ -81,6 +81,25 @@ func TestMapFullscreenRequestDropsEntrance(t *testing.T) {
8181 }
8282 }
8383
84+// A fullscreen request inside the grace after the map is ignored (Wine at a
85+// remembered size): the entrance goes on.
86+func TestMapFullscreenRequestWithinGraceKeepsEntrance(t *testing.T) {
87+ r := startSwipe(t, nil)
88+ s := r.multiRig.mapWindow(t, 1)[0]
89+ if windowOf(t, s, 1).Fade != 1 {
90+ t.Fatal("no entrance to keep")
91+ }
92+ r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true}
93+ // The flip is a barrier: the request is handled, a leftover scene dropped.
94+ set, ok := r.clk.flip(t, r.frames, r.scenes, 30*time.Millisecond, wide.Name)
95+ if !ok {
96+ t.Fatal("no frame: the entrance stopped")
97+ }
98+ if w := windowOf(t, set[0], 1); w.Fullscreen || !(w.Fade > 0 && w.Fade < 1) {
99+ t.Fatalf("ignored fullscreen request changed the window: %+v", w)
100+ }
101+}
102+
84103 func windowOf(t *testing.T, s ports.Scene, id ports.WindowID) ports.SceneWindow {
85104 t.Helper()
86105 for _, w := range s.Windows {
@@ -81,6 +81,25 @@ func TestMapFullscreenRequestDropsEntrance(t *testing.T) {
81 }81 }
82 }82 }
83 83
84+// A fullscreen request inside the grace after the map is ignored (Wine at a
85+// remembered size): the entrance goes on.
86+func TestMapFullscreenRequestWithinGraceKeepsEntrance(t *testing.T) {
87+ r := startSwipe(t, nil)
88+ s := r.multiRig.mapWindow(t, 1)[0]
89+ if windowOf(t, s, 1).Fade != 1 {
90+ t.Fatal("no entrance to keep")
91+ }
92+ r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true}
93+ // The flip is a barrier: the request is handled, a leftover scene dropped.
94+ set, ok := r.clk.flip(t, r.frames, r.scenes, 30*time.Millisecond, wide.Name)
95+ if !ok {
96+ t.Fatal("no frame: the entrance stopped")
97+ }
98+ if w := windowOf(t, set[0], 1); w.Fullscreen || !(w.Fade > 0 && w.Fade < 1) {
99+ t.Fatalf("ignored fullscreen request changed the window: %+v", w)
100+ }
101+}
102+
84 func windowOf(t *testing.T, s ports.Scene, id ports.WindowID) ports.SceneWindow {103 func windowOf(t *testing.T, s ports.Scene, id ports.WindowID) ports.SceneWindow {
85 t.Helper()104 t.Helper()
86 for _, w := range s.Windows {105 for _, w := range s.Windows {
M internal/core/motion.go +11 -0
@@ -221,6 +221,17 @@ func (m motion) sampleAt(now time.Time) (pos, vel float64) {
221221 return pos, m.v
222222 }
223223
224+// scale multiplies the motion's distance and speed by k: the spring is
225+// linear in its start offset and speed, so the progress is unchanged.
226+func (m *motion) scale(k float64) {
227+ if !m.on {
228+ return
229+ }
230+ m.spring.From *= k
231+ m.spring.Velocity *= k
232+ m.v *= k
233+}
234+
224235 // velocity is the speed of the last sample, in units per second.
225236 func (m motion) velocity() float64 { return m.v }
226237
@@ -221,6 +221,17 @@ func (m motion) sampleAt(now time.Time) (pos, vel float64) {
221 return pos, m.v221 return pos, m.v
222 }222 }
223 223
224+// scale multiplies the motion's distance and speed by k: the spring is
225+// linear in its start offset and speed, so the progress is unchanged.
226+func (m *motion) scale(k float64) {
227+ if !m.on {
228+ return
229+ }
230+ m.spring.From *= k
231+ m.spring.Velocity *= k
232+ m.v *= k
233+}
234+
224 // velocity is the speed of the last sample, in units per second.235 // velocity is the speed of the last sample, in units per second.
225 func (m motion) velocity() float64 { return m.v }236 func (m motion) velocity() float64 { return m.v }
226 237
M internal/core/transition.go +47 -0
@@ -338,6 +338,53 @@ func (c *Core) appearMapped(id WindowID, now time.Time) {
338338 }
339339 }
340340
341+// resizeFloating applies a floating window's committed size. A float maps
342+// before its first commit, so core first lays it out at a default size;
343+// when its entrance runs, the settled rect changing under it would break
344+// the aspect of the 90 % rect. The entrance's rect offsets (and their
345+// springs, which are linear in their start and speed) are scaled by the
346+// settled size ratio: the progress and speed carry over, and the window
347+// still grows from 90 % of its real size around its centre.
348+func (c *Core) resizeFloating(v ports.WindowResized) {
349+ sc, w := c.screenOf(v.ID)
350+ if w == nil {
351+ return
352+ }
353+ rm, running := sc.rects[v.ID]
354+ running = running && rm.scale && !rm.leaving
355+ var old Rect
356+ if running {
357+ old = settledRect(sc.mon, v.ID)
358+ }
359+ w.ResizeFloating(v.ID, v.Width, v.Height)
360+ if !running {
361+ return
362+ }
363+ cur := settledRect(sc.mon, v.ID)
364+ if old.W <= 0 || old.H <= 0 || cur.W <= 0 || cur.H <= 0 || old == cur {
365+ return
366+ }
367+ kw, kh := float64(cur.W)/float64(old.W), float64(cur.H)/float64(old.H)
368+ rm.dx, rm.dw = rm.dx*kw, rm.dw*kw
369+ rm.dy, rm.dh = rm.dy*kh, rm.dh*kh
370+ rm.x.scale(kw)
371+ rm.w.scale(kw)
372+ rm.y.scale(kh)
373+ rm.h.scale(kh)
374+ sc.rects[v.ID] = rm
375+}
376+
377+// settledRect is where m's layout puts the visible window id, zero if it
378+// shows none.
379+func settledRect(m *Monitor, id WindowID) Rect {
380+ for _, p := range m.Layout() {
381+ if p.ID == id && !p.Hidden {
382+ return p.Rect
383+ }
384+ }
385+ return Rect{}
386+}
387+
341388 // leave keeps drawing a window the action closed or hid, from its last
342389 // settled placement p: it fades out and shrinks to 90 % around its centre,
343390 // then its entry goes. Nothing with animations off, or for a placement that
@@ -338,6 +338,53 @@ func (c *Core) appearMapped(id WindowID, now time.Time) {
338 }338 }
339 }339 }
340 340
341+// resizeFloating applies a floating window's committed size. A float maps
342+// before its first commit, so core first lays it out at a default size;
343+// when its entrance runs, the settled rect changing under it would break
344+// the aspect of the 90 % rect. The entrance's rect offsets (and their
345+// springs, which are linear in their start and speed) are scaled by the
346+// settled size ratio: the progress and speed carry over, and the window
347+// still grows from 90 % of its real size around its centre.
348+func (c *Core) resizeFloating(v ports.WindowResized) {
349+ sc, w := c.screenOf(v.ID)
350+ if w == nil {
351+ return
352+ }
353+ rm, running := sc.rects[v.ID]
354+ running = running && rm.scale && !rm.leaving
355+ var old Rect
356+ if running {
357+ old = settledRect(sc.mon, v.ID)
358+ }
359+ w.ResizeFloating(v.ID, v.Width, v.Height)
360+ if !running {
361+ return
362+ }
363+ cur := settledRect(sc.mon, v.ID)
364+ if old.W <= 0 || old.H <= 0 || cur.W <= 0 || cur.H <= 0 || old == cur {
365+ return
366+ }
367+ kw, kh := float64(cur.W)/float64(old.W), float64(cur.H)/float64(old.H)
368+ rm.dx, rm.dw = rm.dx*kw, rm.dw*kw
369+ rm.dy, rm.dh = rm.dy*kh, rm.dh*kh
370+ rm.x.scale(kw)
371+ rm.w.scale(kw)
372+ rm.y.scale(kh)
373+ rm.h.scale(kh)
374+ sc.rects[v.ID] = rm
375+}
376+
377+// settledRect is where m's layout puts the visible window id, zero if it
378+// shows none.
379+func settledRect(m *Monitor, id WindowID) Rect {
380+ for _, p := range m.Layout() {
381+ if p.ID == id && !p.Hidden {
382+ return p.Rect
383+ }
384+ }
385+ return Rect{}
386+}
387+
341 // leave keeps drawing a window the action closed or hid, from its last388 // leave keeps drawing a window the action closed or hid, from its last
342 // settled placement p: it fades out and shrinks to 90 % around its centre,389 // settled placement p: it fades out and shrinks to 90 % around its centre,
343 // then its entry goes. Nothing with animations off, or for a placement that390 // then its entry goes. Nothing with animations off, or for a placement that
M internal/core/transition_map_internal_test.go +58 -0
@@ -198,3 +198,61 @@ func TestMapExceptions(t *testing.T) {
198198 }
199199 })
200200 }
201+
202+// A float maps before its first commit: Width and Height are 0, so core lays
203+// it out at a default size, and the real one comes in WindowResized. The
204+// entrance must restart from 90 % of the real size, not stay distorted.
205+func TestMapFloatEntranceFollowsItsFirstCommit(t *testing.T) {
206+ c, ic, _ := fadeCore(t)
207+ c.cfg.Animations.On = true
208+ sc := c.cur()
209+ c.mapWindow(ports.WindowMapped{ID: 1})
210+ c.stopAnimations()
211+ c.mapWindow(ports.WindowMapped{ID: 2, Floating: true})
212+ indicatorScene(t, c)
213+ guess := settledOf(t, sc, 2).Rect
214+
215+ c.resizeFloating(ports.WindowResized{ID: 2, Width: 60, Height: 20})
216+ s := indicatorScene(t, c)
217+ real := settledOf(t, sc, 2).Rect
218+ if real == guess || real.W == real.H*guess.W/guess.H {
219+ t.Fatalf("the resize did not change the settled shape: %+v then %+v", guess, real)
220+ }
221+ w := sceneWindow(t, s, 2)
222+ want := scaledRect(real, appearScale)
223+ near := func(a, b int) bool { return a-b <= 1 && b-a <= 1 }
224+ if w.Fade != 1 || !near(w.Rect.X, want.X) || !near(w.Rect.Y, want.Y) || !near(w.Rect.W, want.W) || !near(w.Rect.H, want.H) {
225+ t.Fatalf("first frame %+v, want 90 %% of the real %+v = %+v", w, real, want)
226+ }
227+ if math.Abs(w.Zoom-appearScale) > 0.02 {
228+ t.Fatalf("zoom %v, want ~0.9 (aspect kept)", w.Zoom)
229+ }
230+
231+ // Mid-way the frame keeps the real aspect, the zoom follows its width.
232+ ic.now = ic.now.Add(40 * time.Millisecond)
233+ c.animate(ic.now, nil)
234+ w = sceneWindow(t, indicatorScene(t, c), 2)
235+ ratio, wantRatio := float64(w.Rect.W)/float64(w.Rect.H), float64(real.W)/float64(real.H)
236+ if math.Abs(ratio-wantRatio)/wantRatio > 0.06 || !(w.Rect.W > want.W && w.Rect.W < real.W) || math.Abs(w.Zoom-float64(w.Rect.W)/float64(real.W)) > 0.01 {
237+ t.Fatalf("mid-way %+v: aspect %.3f want %.3f", w, ratio, wantRatio)
238+ }
239+
240+ ic.now = ic.now.Add(5 * time.Second)
241+ c.animate(ic.now, nil)
242+ if w := sceneWindow(t, indicatorScene(t, c), 2); w.Fade != 0 || w.Zoom != 0 || w.Rect != real {
243+ t.Fatalf("settled %+v, want %+v", w, real)
244+ }
245+}
246+
247+// A resize with no entrance running only moves the settled rect.
248+func TestResizeFloatWithoutEntranceKeepsNoMotion(t *testing.T) {
249+ c, _, _ := fadeCore(t)
250+ c.cfg.Animations.On = true
251+ sc := c.cur()
252+ c.mapWindow(ports.WindowMapped{ID: 1, Floating: true})
253+ c.stopAnimations()
254+ c.resizeFloating(ports.WindowResized{ID: 1, Width: 60, Height: 20})
255+ if len(sc.rects) != 0 {
256+ t.Fatalf("a resize started a motion: %+v", sc.rects)
257+ }
258+}
@@ -198,3 +198,61 @@ func TestMapExceptions(t *testing.T) {
198 }198 }
199 })199 })
200 }200 }
201+
202+// A float maps before its first commit: Width and Height are 0, so core lays
203+// it out at a default size, and the real one comes in WindowResized. The
204+// entrance must restart from 90 % of the real size, not stay distorted.
205+func TestMapFloatEntranceFollowsItsFirstCommit(t *testing.T) {
206+ c, ic, _ := fadeCore(t)
207+ c.cfg.Animations.On = true
208+ sc := c.cur()
209+ c.mapWindow(ports.WindowMapped{ID: 1})
210+ c.stopAnimations()
211+ c.mapWindow(ports.WindowMapped{ID: 2, Floating: true})
212+ indicatorScene(t, c)
213+ guess := settledOf(t, sc, 2).Rect
214+
215+ c.resizeFloating(ports.WindowResized{ID: 2, Width: 60, Height: 20})
216+ s := indicatorScene(t, c)
217+ real := settledOf(t, sc, 2).Rect
218+ if real == guess || real.W == real.H*guess.W/guess.H {
219+ t.Fatalf("the resize did not change the settled shape: %+v then %+v", guess, real)
220+ }
221+ w := sceneWindow(t, s, 2)
222+ want := scaledRect(real, appearScale)
223+ near := func(a, b int) bool { return a-b <= 1 && b-a <= 1 }
224+ if w.Fade != 1 || !near(w.Rect.X, want.X) || !near(w.Rect.Y, want.Y) || !near(w.Rect.W, want.W) || !near(w.Rect.H, want.H) {
225+ t.Fatalf("first frame %+v, want 90 %% of the real %+v = %+v", w, real, want)
226+ }
227+ if math.Abs(w.Zoom-appearScale) > 0.02 {
228+ t.Fatalf("zoom %v, want ~0.9 (aspect kept)", w.Zoom)
229+ }
230+
231+ // Mid-way the frame keeps the real aspect, the zoom follows its width.
232+ ic.now = ic.now.Add(40 * time.Millisecond)
233+ c.animate(ic.now, nil)
234+ w = sceneWindow(t, indicatorScene(t, c), 2)
235+ ratio, wantRatio := float64(w.Rect.W)/float64(w.Rect.H), float64(real.W)/float64(real.H)
236+ if math.Abs(ratio-wantRatio)/wantRatio > 0.06 || !(w.Rect.W > want.W && w.Rect.W < real.W) || math.Abs(w.Zoom-float64(w.Rect.W)/float64(real.W)) > 0.01 {
237+ t.Fatalf("mid-way %+v: aspect %.3f want %.3f", w, ratio, wantRatio)
238+ }
239+
240+ ic.now = ic.now.Add(5 * time.Second)
241+ c.animate(ic.now, nil)
242+ if w := sceneWindow(t, indicatorScene(t, c), 2); w.Fade != 0 || w.Zoom != 0 || w.Rect != real {
243+ t.Fatalf("settled %+v, want %+v", w, real)
244+ }
245+}
246+
247+// A resize with no entrance running only moves the settled rect.
248+func TestResizeFloatWithoutEntranceKeepsNoMotion(t *testing.T) {
249+ c, _, _ := fadeCore(t)
250+ c.cfg.Animations.On = true
251+ sc := c.cur()
252+ c.mapWindow(ports.WindowMapped{ID: 1, Floating: true})
253+ c.stopAnimations()
254+ c.resizeFloating(ports.WindowResized{ID: 1, Width: 60, Height: 20})
255+ if len(sc.rects) != 0 {
256+ t.Fatalf("a resize started a motion: %+v", sc.rects)
257+ }
258+}

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close