detached cfea105

feat(core): per-window rect transitions with a cached shown layout

commit
cfea105e6794f86522c70a04327f6d1604edabd9
author
brice · · 2026-10-04 16:39 UTC
committer
brice · · 2026-10-04 19:08 UTC
parent
3e41022
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +426 -19
M internal/core/border_invariants_test.go +1 -0
@@ -15,6 +15,7 @@ func TestBorderSceneInvariants(t *testing.T) {
1515 t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) {
1616 rng := rand.New(rand.NewSource(20260928))
1717 r := startMulti(t, func(c *ports.Config) {
18+ c.Animations.On = false
1819 c.Border.Width = 2
1920 c.Layout.MaxColumns = 3
2021 c.Layout.Overflow = overflow
@@ -15,6 +15,7 @@ func TestBorderSceneInvariants(t *testing.T) {
15 t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) {15 t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) {
16 rng := rand.New(rand.NewSource(20260928))16 rng := rand.New(rand.NewSource(20260928))
17 r := startMulti(t, func(c *ports.Config) {17 r := startMulti(t, func(c *ports.Config) {
18+ c.Animations.On = false
18 c.Border.Width = 219 c.Border.Width = 2
19 c.Layout.MaxColumns = 320 c.Layout.MaxColumns = 3
20 c.Layout.Overflow = overflow21 c.Layout.Overflow = overflow
M internal/core/core.go +12 -7
@@ -511,6 +511,7 @@ func (c *Core) publish(ctx context.Context) error {
511511 sc.mon.Current().settleLeft()
512512 }
513513 }
514+ c.refreshShown()
514515 c.captureExpire()
515516 capture, err := c.captureEvaluate(ctx)
516517 if err != nil {
@@ -559,7 +560,9 @@ func (c *Core) publish(ctx context.Context) error {
559560 clip = frame
560561 }
561562 scene := ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)}
562- layout := sc.mon.Layout()
563+ // layout is what is drawn; settled (same indexes) is where the
564+ // windows are going, and alone sizes the configures.
565+ layout, settled := sc.shown, sc.settled
563566 var real map[WindowID]Placement
564567 if sc.mon.ov.open {
565568 real = make(map[WindowID]Placement)
@@ -587,7 +590,8 @@ func (c *Core) publish(ctx context.Context) error {
587590 }
588591 // A window alone on screen needs no pulse to show it has the focus.
589592 alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, frame) == 1
590- for _, p := range layout {
593+ for k, p := range layout {
594+ ps := settled[k]
591595 // Only the focused output has an activated window.
592596 focused := p.Focused && i == c.focusScreen
593597 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}
@@ -604,7 +608,7 @@ func (c *Core) publish(ctx context.Context) error {
604608 t := configureTarget{output: sc.name(), area: frame, focused: focused, captured: capture != nil && capture.window == p.ID}
605609 if !p.Hidden && p.Preview == 0 {
606610 // Only a sized configure needs the client size.
607- t.client, t.imposed = c.clientRect(p), sc.mon.Current().imposedFloat(p.ID)
611+ t.client, t.imposed = c.clientRect(ps), sc.mon.Current().imposedFloat(p.ID)
608612 } else if t.captured && p.Hidden {
609613 // A captured hidden window is sized like its capture.
610614 t.client = capture.windowSz
@@ -614,8 +618,8 @@ func (c *Core) publish(ctx context.Context) error {
614618 t.client = c.clientRect(rp)
615619 }
616620 }
617- cp, t := c.captureConfigure(sc, p, t)
618- if v, send := c.configures.nextWithCapture(p, t, cp); send {
621+ cp, t := c.captureConfigure(sc, ps, t)
622+ if v, send := c.configures.nextWithCapture(ps, t, cp); send {
619623 if err := c.command(ctx, v); err != nil {
620624 return err
621625 }
@@ -812,7 +816,7 @@ func (c *Core) shownClient(id WindowID) (full, visible Rect, ok bool) {
812816 if s == nil {
813817 return Rect{}, Rect{}, false
814818 }
815- for _, p := range s.mon.Layout() {
819+ for _, p := range s.shownLayout() {
816820 if p.ID != id || p.Hidden || p.Preview > 0 {
817821 continue
818822 }
@@ -962,7 +966,7 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
962966 }
963967 var id WindowID
964968 var sx, sy float64
965- for _, p := range sc.mon.Layout() {
969+ for _, p := range sc.shownLayout() {
966970 r := c.clientRect(p)
967971 // A peek is clickable wherever it shows, border included: it may
968972 // be narrower than its border. The point is clamped to its client.
@@ -1102,6 +1106,7 @@ func (c *Core) Run(ctx context.Context) error {
11021106 }
11031107 if s, _ := c.screenOf(v.ID); s != nil {
11041108 s.mon.RemoveWindow(v.ID)
1109+ delete(s.rects, v.ID)
11051110 }
11061111 c.releaseSlots()
11071112 if c.pointer == v.ID {
@@ -511,6 +511,7 @@ func (c *Core) publish(ctx context.Context) error {
511 sc.mon.Current().settleLeft()511 sc.mon.Current().settleLeft()
512 }512 }
513 }513 }
514+ c.refreshShown()
514 c.captureExpire()515 c.captureExpire()
515 capture, err := c.captureEvaluate(ctx)516 capture, err := c.captureEvaluate(ctx)
516 if err != nil {517 if err != nil {
@@ -559,7 +560,9 @@ func (c *Core) publish(ctx context.Context) error {
559 clip = frame560 clip = frame
560 }561 }
561 scene := ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)}562 scene := ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)}
562- layout := sc.mon.Layout()563+ // layout is what is drawn; settled (same indexes) is where the
564+ // windows are going, and alone sizes the configures.
565+ layout, settled := sc.shown, sc.settled
563 var real map[WindowID]Placement566 var real map[WindowID]Placement
564 if sc.mon.ov.open {567 if sc.mon.ov.open {
565 real = make(map[WindowID]Placement)568 real = make(map[WindowID]Placement)
@@ -587,7 +590,8 @@ func (c *Core) publish(ctx context.Context) error {
587 }590 }
588 // A window alone on screen needs no pulse to show it has the focus.591 // A window alone on screen needs no pulse to show it has the focus.
589 alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, frame) == 1592 alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, frame) == 1
590- for _, p := range layout {593+ for k, p := range layout {
594+ ps := settled[k]
591 // Only the focused output has an activated window.595 // Only the focused output has an activated window.
592 focused := p.Focused && i == c.focusScreen596 focused := p.Focused && i == c.focusScreen
593 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}597 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}
@@ -604,7 +608,7 @@ func (c *Core) publish(ctx context.Context) error {
604 t := configureTarget{output: sc.name(), area: frame, focused: focused, captured: capture != nil && capture.window == p.ID}608 t := configureTarget{output: sc.name(), area: frame, focused: focused, captured: capture != nil && capture.window == p.ID}
605 if !p.Hidden && p.Preview == 0 {609 if !p.Hidden && p.Preview == 0 {
606 // Only a sized configure needs the client size.610 // Only a sized configure needs the client size.
607- t.client, t.imposed = c.clientRect(p), sc.mon.Current().imposedFloat(p.ID)611+ t.client, t.imposed = c.clientRect(ps), sc.mon.Current().imposedFloat(p.ID)
608 } else if t.captured && p.Hidden {612 } else if t.captured && p.Hidden {
609 // A captured hidden window is sized like its capture.613 // A captured hidden window is sized like its capture.
610 t.client = capture.windowSz614 t.client = capture.windowSz
@@ -614,8 +618,8 @@ func (c *Core) publish(ctx context.Context) error {
614 t.client = c.clientRect(rp)618 t.client = c.clientRect(rp)
615 }619 }
616 }620 }
617- cp, t := c.captureConfigure(sc, p, t)621+ cp, t := c.captureConfigure(sc, ps, t)
618- if v, send := c.configures.nextWithCapture(p, t, cp); send {622+ if v, send := c.configures.nextWithCapture(ps, t, cp); send {
619 if err := c.command(ctx, v); err != nil {623 if err := c.command(ctx, v); err != nil {
620 return err624 return err
621 }625 }
@@ -812,7 +816,7 @@ func (c *Core) shownClient(id WindowID) (full, visible Rect, ok bool) {
812 if s == nil {816 if s == nil {
813 return Rect{}, Rect{}, false817 return Rect{}, Rect{}, false
814 }818 }
815- for _, p := range s.mon.Layout() {819+ for _, p := range s.shownLayout() {
816 if p.ID != id || p.Hidden || p.Preview > 0 {820 if p.ID != id || p.Hidden || p.Preview > 0 {
817 continue821 continue
818 }822 }
@@ -962,7 +966,7 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
962 }966 }
963 var id WindowID967 var id WindowID
964 var sx, sy float64968 var sx, sy float64
965- for _, p := range sc.mon.Layout() {969+ for _, p := range sc.shownLayout() {
966 r := c.clientRect(p)970 r := c.clientRect(p)
967 // A peek is clickable wherever it shows, border included: it may971 // A peek is clickable wherever it shows, border included: it may
968 // be narrower than its border. The point is clamped to its client.972 // be narrower than its border. The point is clamped to its client.
@@ -1102,6 +1106,7 @@ func (c *Core) Run(ctx context.Context) error {
1102 }1106 }
1103 if s, _ := c.screenOf(v.ID); s != nil {1107 if s, _ := c.screenOf(v.ID); s != nil {
1104 s.mon.RemoveWindow(v.ID)1108 s.mon.RemoveWindow(v.ID)
1109+ delete(s.rects, v.ID)
1105 }1110 }
1106 c.releaseSlots()1111 c.releaseSlots()
1107 if c.pointer == v.ID {1112 if c.pointer == v.ID {
M internal/core/drag_test.go +1 -1
@@ -333,7 +333,7 @@ func TestDragTileZonesAndHints(t *testing.T) {
333333 }
334334
335335 func TestDragTileGapInsert(t *testing.T) {
336- r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut)
336+ r := startDragRig(t, func(c *ports.Config) { c.Animations.On = false; c.Layout.MaxColumns = 3 }, dragOut)
337337 for id := ports.WindowID(1); id <= 3; id++ {
338338 r.client <- ports.WindowMapped{ID: id}
339339 r.settle(t)
@@ -333,7 +333,7 @@ func TestDragTileZonesAndHints(t *testing.T) {
333 }333 }
334 334
335 func TestDragTileGapInsert(t *testing.T) {335 func TestDragTileGapInsert(t *testing.T) {
336- r := startDragRig(t, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut)336+ r := startDragRig(t, func(c *ports.Config) { c.Animations.On = false; c.Layout.MaxColumns = 3 }, dragOut)
337 for id := ports.WindowID(1); id <= 3; id++ {337 for id := ports.WindowID(1); id <= 3; id++ {
338 r.client <- ports.WindowMapped{ID: id}338 r.client <- ports.WindowMapped{ID: id}
339 r.settle(t)339 r.settle(t)
M internal/core/free_core_test.go +1 -1
@@ -21,7 +21,7 @@ func windowIn(set []ports.Scene, output string, id ports.WindowID) (ports.SceneW
2121 }
2222
2323 func TestFreeFloatKeepsPlaceAcrossMonitors(t *testing.T) {
24- r := startMulti(t, func(c *ports.Config) { c.Layout.Gaps = 0 }, left, right)
24+ r := startMulti(t, func(c *ports.Config) { c.Animations.On = false; c.Layout.Gaps = 0 }, left, right)
2525 r.mapWindow(t, 1)
2626 r.mapWindow(t, 2)
2727 r.key(t, "space", ports.ModAlt|ports.ModShift)
@@ -21,7 +21,7 @@ func windowIn(set []ports.Scene, output string, id ports.WindowID) (ports.SceneW
21 }21 }
22 22
23 func TestFreeFloatKeepsPlaceAcrossMonitors(t *testing.T) {23 func TestFreeFloatKeepsPlaceAcrossMonitors(t *testing.T) {
24- r := startMulti(t, func(c *ports.Config) { c.Layout.Gaps = 0 }, left, right)24+ r := startMulti(t, func(c *ports.Config) { c.Animations.On = false; c.Layout.Gaps = 0 }, left, right)
25 r.mapWindow(t, 1)25 r.mapWindow(t, 1)
26 r.mapWindow(t, 2)26 r.mapWindow(t, 2)
27 r.key(t, "space", ports.ModAlt|ports.ModShift)27 r.key(t, "space", ports.ModAlt|ports.ModShift)
M internal/core/gesture.go +13 -4
@@ -340,7 +340,7 @@ func (c *Core) spring(s spring, now time.Time) motion {
340340 // stopAnimations settles every running spring where it is going and stops
341341 // the frame timer: a session lock or a switch to animations = off. A swipe
342342 // in progress keeps its fingers' view: only running springs stop. Per-window
343-// rect motions stop here too once they exist.
343+// rect motions stop too.
344344 func (c *Core) stopAnimations() {
345345 for _, sc := range c.screens {
346346 if sc.mon.switchMotion.on {
@@ -351,6 +351,7 @@ func (c *Core) stopAnimations() {
351351 w.stopSlide()
352352 }
353353 }
354+ clear(sc.rects)
354355 }
355356 c.stopFrame()
356357 }
@@ -379,13 +380,21 @@ func (c *Core) animate(now time.Time, only *screen) {
379380 }
380381 }
381382 }
383+ for id, rm := range sc.rects {
384+ rm.advance(now)
385+ if rm.on() {
386+ sc.rects[id] = rm
387+ } else {
388+ delete(sc.rects, id)
389+ }
390+ }
382391 }
383392 }
384393
385394 // sliding reports whether a spring runs on the named output.
386395 func (c *Core) sliding(output string) bool {
387396 i := c.screenIndex(output)
388- return i >= 0 && (c.screens[i].mon.springing() || c.pulsing(c.screens[i]))
397+ return i >= 0 && (c.screens[i].springing() || c.pulsing(c.screens[i]))
389398 }
390399
391400 // animating reports whether a spring or the focus pulse runs on any output.
@@ -394,7 +403,7 @@ func (c *Core) animating() bool {
394403 return true
395404 }
396405 for _, sc := range c.screens {
397- if sc.mon.springing() {
406+ if sc.springing() {
398407 return true
399408 }
400409 }
@@ -418,7 +427,7 @@ func (m *Monitor) springing() bool {
418427 func (c *Core) frameFallback() time.Duration {
419428 refresh := 60000
420429 for _, sc := range c.screens {
421- if r := sc.info.RefreshMilli; r > 0 && (sc.mon.springing() || c.pulsing(sc)) {
430+ if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.pulsing(sc)) {
422431 refresh = min(refresh, r)
423432 }
424433 }
@@ -340,7 +340,7 @@ func (c *Core) spring(s spring, now time.Time) motion {
340 // stopAnimations settles every running spring where it is going and stops340 // stopAnimations settles every running spring where it is going and stops
341 // the frame timer: a session lock or a switch to animations = off. A swipe341 // the frame timer: a session lock or a switch to animations = off. A swipe
342 // in progress keeps its fingers' view: only running springs stop. Per-window342 // in progress keeps its fingers' view: only running springs stop. Per-window
343-// rect motions stop here too once they exist.343+// rect motions stop too.
344 func (c *Core) stopAnimations() {344 func (c *Core) stopAnimations() {
345 for _, sc := range c.screens {345 for _, sc := range c.screens {
346 if sc.mon.switchMotion.on {346 if sc.mon.switchMotion.on {
@@ -351,6 +351,7 @@ func (c *Core) stopAnimations() {
351 w.stopSlide()351 w.stopSlide()
352 }352 }
353 }353 }
354+ clear(sc.rects)
354 }355 }
355 c.stopFrame()356 c.stopFrame()
356 }357 }
@@ -379,13 +380,21 @@ func (c *Core) animate(now time.Time, only *screen) {
379 }380 }
380 }381 }
381 }382 }
383+ for id, rm := range sc.rects {
384+ rm.advance(now)
385+ if rm.on() {
386+ sc.rects[id] = rm
387+ } else {
388+ delete(sc.rects, id)
389+ }
390+ }
382 }391 }
383 }392 }
384 393
385 // sliding reports whether a spring runs on the named output.394 // sliding reports whether a spring runs on the named output.
386 func (c *Core) sliding(output string) bool {395 func (c *Core) sliding(output string) bool {
387 i := c.screenIndex(output)396 i := c.screenIndex(output)
388- return i >= 0 && (c.screens[i].mon.springing() || c.pulsing(c.screens[i]))397+ return i >= 0 && (c.screens[i].springing() || c.pulsing(c.screens[i]))
389 }398 }
390 399
391 // animating reports whether a spring or the focus pulse runs on any output.400 // animating reports whether a spring or the focus pulse runs on any output.
@@ -394,7 +403,7 @@ func (c *Core) animating() bool {
394 return true403 return true
395 }404 }
396 for _, sc := range c.screens {405 for _, sc := range c.screens {
397- if sc.mon.springing() {406+ if sc.springing() {
398 return true407 return true
399 }408 }
400 }409 }
@@ -418,7 +427,7 @@ func (m *Monitor) springing() bool {
418 func (c *Core) frameFallback() time.Duration {427 func (c *Core) frameFallback() time.Duration {
419 refresh := 60000428 refresh := 60000
420 for _, sc := range c.screens {429 for _, sc := range c.screens {
421- if r := sc.info.RefreshMilli; r > 0 && (sc.mon.springing() || c.pulsing(sc)) {430+ if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.pulsing(sc)) {
422 refresh = min(refresh, r)431 refresh = min(refresh, r)
423 }432 }
424 }433 }
M internal/core/popup.go +2 -2
@@ -144,7 +144,7 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) {
144144 return sc, l.Rect, true
145145 }
146146 for _, sc := range c.screens {
147- for _, pl := range sc.mon.Layout() {
147+ for _, pl := range sc.shownLayout() {
148148 if pl.ID == id {
149149 // Hidden or scrolled off: not on screen, like its popups.
150150 // A preview takes no input and shows no popups.
@@ -161,7 +161,7 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) {
161161 // peeking reports whether id is a stashed window peeking in on screen.
162162 func (c *Core) peeking(id WindowID) bool {
163163 for _, sc := range c.screens {
164- for _, pl := range sc.mon.Layout() {
164+ for _, pl := range sc.shownLayout() {
165165 if pl.ID == id {
166166 return pl.Peek
167167 }
@@ -144,7 +144,7 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) {
144 return sc, l.Rect, true144 return sc, l.Rect, true
145 }145 }
146 for _, sc := range c.screens {146 for _, sc := range c.screens {
147- for _, pl := range sc.mon.Layout() {147+ for _, pl := range sc.shownLayout() {
148 if pl.ID == id {148 if pl.ID == id {
149 // Hidden or scrolled off: not on screen, like its popups.149 // Hidden or scrolled off: not on screen, like its popups.
150 // A preview takes no input and shows no popups.150 // A preview takes no input and shows no popups.
@@ -161,7 +161,7 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) {
161 // peeking reports whether id is a stashed window peeking in on screen.161 // peeking reports whether id is a stashed window peeking in on screen.
162 func (c *Core) peeking(id WindowID) bool {162 func (c *Core) peeking(id WindowID) bool {
163 for _, sc := range c.screens {163 for _, sc := range c.screens {
164- for _, pl := range sc.mon.Layout() {164+ for _, pl := range sc.shownLayout() {
165 if pl.ID == id {165 if pl.ID == id {
166 return pl.Peek166 return pl.Peek
167 }167 }
M internal/core/pulse.go +3 -2
@@ -104,7 +104,7 @@ func (c *Core) pulseTick() bool {
104104 // A swipe or its landing slide still shows the neighbours: decide on
105105 // the settled layout, or a window left alone would start a pulse cut
106106 // short.
107- if !c.cur().mon.settled() {
107+ if !c.cur().isSettled() {
108108 p.timerC, p.timerStop = newTimer(c.ch.Clock, pulseRecheck)
109109 return false
110110 }
@@ -137,7 +137,8 @@ func (c *Core) advancePulse(now time.Time) float64 {
137137 return 0
138138 }
139139
140-// settled reports whether no swipe or slide moves the monitor's layout.
140+// settled reports whether no swipe or slide moves the monitor's layout
141+// (screen.isSettled adds the rect motions).
141142 func (m *Monitor) settled() bool {
142143 if m.switchMotion.on || m.switchOff != 0 {
143144 return false
@@ -104,7 +104,7 @@ func (c *Core) pulseTick() bool {
104 // A swipe or its landing slide still shows the neighbours: decide on104 // A swipe or its landing slide still shows the neighbours: decide on
105 // the settled layout, or a window left alone would start a pulse cut105 // the settled layout, or a window left alone would start a pulse cut
106 // short.106 // short.
107- if !c.cur().mon.settled() {107+ if !c.cur().isSettled() {
108 p.timerC, p.timerStop = newTimer(c.ch.Clock, pulseRecheck)108 p.timerC, p.timerStop = newTimer(c.ch.Clock, pulseRecheck)
109 return false109 return false
110 }110 }
@@ -137,7 +137,8 @@ func (c *Core) advancePulse(now time.Time) float64 {
137 return 0137 return 0
138 }138 }
139 139
140-// settled reports whether no swipe or slide moves the monitor's layout.140+// settled reports whether no swipe or slide moves the monitor's layout
141+// (screen.isSettled adds the rect motions).
141 func (m *Monitor) settled() bool {142 func (m *Monitor) settled() bool {
142 if m.switchMotion.on || m.switchOff != 0 {143 if m.switchMotion.on || m.switchOff != 0 {
143 return false144 return false
M internal/core/screens.go +27 -0
@@ -31,6 +31,33 @@ type screen struct {
3131 // last is the scene published for this screen: an identical next one
3232 // keeps its Seq (Scene.SameAs). Zero after a protected publish.
3333 last ports.Scene
34+ // settled is the monitor's layout as of the last publish: what configures
35+ // are sized from. shown is the same with the rect motions applied: what is
36+ // drawn and hit-tested. They are the same slice while no rect motion
37+ // runs. Both are built once per publish (refreshShown); shown is nil
38+ // before the first publish, then callers measure the monitor's layout.
39+ settled, shown []Placement
40+ shownBuf []Placement
41+ // rects are the running per-window rect motions (transition.go) of
42+ // rectsWS, the workspace they began on; created lazily.
43+ rects map[WindowID]rectMotion
44+ rectsWS *Workspace
45+}
46+
47+// springing reports whether a camera or rect spring runs on the screen.
48+func (s *screen) springing() bool { return s.mon.springing() || len(s.rects) > 0 }
49+
50+// isSettled reports whether nothing moves the screen's layout: no swipe,
51+// no slide, no rect motion.
52+func (s *screen) isSettled() bool { return s.mon.settled() && len(s.rects) == 0 }
53+
54+// shownLayout is the layout as drawn, for hit-testing and popups; before
55+// the first publish it is the monitor's.
56+func (s *screen) shownLayout() []Placement {
57+ if s.shown != nil {
58+ return s.shown
59+ }
60+ return s.mon.Layout()
3461 }
3562
3663 func (s *screen) name() string { return s.info.Name }
@@ -31,6 +31,33 @@ type screen struct {
31 // last is the scene published for this screen: an identical next one31 // last is the scene published for this screen: an identical next one
32 // keeps its Seq (Scene.SameAs). Zero after a protected publish.32 // keeps its Seq (Scene.SameAs). Zero after a protected publish.
33 last ports.Scene33 last ports.Scene
34+ // settled is the monitor's layout as of the last publish: what configures
35+ // are sized from. shown is the same with the rect motions applied: what is
36+ // drawn and hit-tested. They are the same slice while no rect motion
37+ // runs. Both are built once per publish (refreshShown); shown is nil
38+ // before the first publish, then callers measure the monitor's layout.
39+ settled, shown []Placement
40+ shownBuf []Placement
41+ // rects are the running per-window rect motions (transition.go) of
42+ // rectsWS, the workspace they began on; created lazily.
43+ rects map[WindowID]rectMotion
44+ rectsWS *Workspace
45+}
46+
47+// springing reports whether a camera or rect spring runs on the screen.
48+func (s *screen) springing() bool { return s.mon.springing() || len(s.rects) > 0 }
49+
50+// isSettled reports whether nothing moves the screen's layout: no swipe,
51+// no slide, no rect motion.
52+func (s *screen) isSettled() bool { return s.mon.settled() && len(s.rects) == 0 }
53+
54+// shownLayout is the layout as drawn, for hit-testing and popups; before
55+// the first publish it is the monitor's.
56+func (s *screen) shownLayout() []Placement {
57+ if s.shown != nil {
58+ return s.shown
59+ }
60+ return s.mon.Layout()
34 }61 }
35 62
36 func (s *screen) name() string { return s.info.Name }63 func (s *screen) name() string { return s.info.Name }
M internal/core/security.go +1 -0
@@ -181,6 +181,7 @@ func (c *Core) publishProtected(ctx context.Context) error {
181181 c.seq++
182182 // The desktop scene that follows must not reuse a Seq from before.
183183 sc.last = ports.Scene{}
184+ sc.settled, sc.shown = nil, nil
184185 o := sc.mon.Output()
185186 scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: "#000000"}
186187 if s, ok := c.lockSurface(sc); ok {
@@ -181,6 +181,7 @@ func (c *Core) publishProtected(ctx context.Context) error {
181 c.seq++181 c.seq++
182 // The desktop scene that follows must not reuse a Seq from before.182 // The desktop scene that follows must not reuse a Seq from before.
183 sc.last = ports.Scene{}183 sc.last = ports.Scene{}
184+ sc.settled, sc.shown = nil, nil
184 o := sc.mon.Output()185 o := sc.mon.Output()
185 scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: "#000000"}186 scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: "#000000"}
186 if s, ok := c.lockSurface(sc); ok {187 if s, ok := c.lockSurface(sc); ok {
M internal/core/transition.go +151 -0
@@ -1,6 +1,7 @@
11 package core
22
33 import (
4+ "math"
45 "slices"
56 "time"
67 )
@@ -11,6 +12,12 @@ import (
1112 // settled one; the transition only adds presentation springs over it
1213 // (Workspace.shift, Monitor.switchOff), starting from what was on screen.
1314 // Nothing here runs for events that come from clients or outputs.
15+//
16+// A window's rect transition (option B) is presentation too: configures keep
17+// using the settled rect, so the client resizes once to its final size; what
18+// is drawn and hit-tested is the settled rect plus an offset that a spring
19+// takes to zero. The offsets are relative to the camera-shifted layout, so a
20+// window the view scrolls moves with the camera spring only.
1421
1522 // viewShot is what one screen showed before an action.
1623 type viewShot struct {
@@ -26,6 +33,100 @@ type viewShot struct {
2633 list []*Workspace
2734 pos float64
2835 ok bool
36+ // rects are the rects the screen drew for the windows of ws, hidden
37+ // windows and previews left out. The slice is reused like list.
38+ rects []rectShot
39+}
40+
41+// rectShot is the rect a window was drawn at.
42+type rectShot struct {
43+ id WindowID
44+ rect Rect
45+}
46+
47+// rectMotion takes a window's drawn rect to its settled one: each component
48+// is an offset from the settled value, sprung to 0 (dx, dy, dw, dh hold the
49+// current offsets, updated by advance).
50+type rectMotion struct {
51+ x, y, w, h motion
52+ dx, dy, dw, dh float64
53+}
54+
55+// apply is r moved by the current offsets; a size never drops below 1.
56+func (m *rectMotion) apply(r Rect) Rect {
57+ return Rect{
58+ X: r.X + int(math.Round(m.dx)), Y: r.Y + int(math.Round(m.dy)),
59+ W: max(r.W+int(math.Round(m.dw)), 1), H: max(r.H+int(math.Round(m.dh)), 1),
60+ }
61+}
62+
63+// advance moves the offsets to now; a settled component stops at 0.
64+func (m *rectMotion) advance(now time.Time) {
65+ m.dx = stepComponent(&m.x, m.dx, now)
66+ m.dy = stepComponent(&m.y, m.dy, now)
67+ m.dw = stepComponent(&m.w, m.dw, now)
68+ m.dh = stepComponent(&m.h, m.dh, now)
69+}
70+
71+func stepComponent(m *motion, cur float64, now time.Time) float64 {
72+ if !m.on {
73+ return cur
74+ }
75+ v, done := m.at(now)
76+ if done {
77+ *m = motion{}
78+ return 0
79+ }
80+ return v
81+}
82+
83+// on reports whether any component still runs.
84+func (m *rectMotion) on() bool { return m.x.on || m.y.on || m.w.on || m.h.on }
85+
86+// retargetComponent starts m for an offset of off from its settled value at
87+// now, keeping the speed it has now. A component with nothing to move and no
88+// speed stops.
89+func (c *Core) retargetComponent(m *motion, cur *float64, off float64, now time.Time) {
90+ v := m.velocity()
91+ if !m.on {
92+ v = 0
93+ }
94+ *cur = off
95+ if off == 0 && v == 0 {
96+ *m = motion{}
97+ return
98+ }
99+ *m = c.spring(viewSpring(off, v), now)
100+}
101+
102+// refreshShown builds the layouts of every screen once per publish: the
103+// settled one (what configures are sized from) and the shown one (the same
104+// with the rect motions applied: what is drawn and hit-tested). Rect motions
105+// belong to the workspace they began on and to a closed overview: any other
106+// change drops them.
107+func (c *Core) refreshShown() {
108+ for _, sc := range c.screens {
109+ m := sc.mon
110+ if len(sc.rects) > 0 && (m.ov.open || sc.rectsWS != m.Current()) {
111+ clear(sc.rects)
112+ }
113+ sc.settled = m.Layout()
114+ if len(sc.rects) == 0 {
115+ sc.shown = sc.settled
116+ continue
117+ }
118+ buf := append(sc.shownBuf[:0], sc.settled...)
119+ for i := range buf {
120+ p := &buf[i]
121+ if p.Hidden || p.Preview > 0 {
122+ continue
123+ }
124+ if rm, ok := sc.rects[p.ID]; ok {
125+ p.Rect = rm.apply(p.Rect)
126+ }
127+ }
128+ sc.shownBuf, sc.shown = buf, buf
129+ }
29130 }
30131
31132 // snapshot records what every screen without an open overview shows. The
@@ -39,11 +140,17 @@ func (c *Core) snapshot() []viewShot {
39140 var s viewShot
40141 if len(out) < len(full) {
41142 s.list = full[len(out)].list[:0]
143+ s.rects = full[len(out)].rects[:0]
42144 }
43145 s.sc = sc
44146 if !m.ov.open {
45147 w := m.Current()
46148 s.ws, s.viewX, s.view = w, w.ViewX, float64(w.ViewX)+w.shift
149+ for _, p := range sc.shown {
150+ if !p.Hidden && p.Preview == 0 {
151+ s.rects = append(s.rects, rectShot{p.ID, p.Rect})
152+ }
153+ }
47154 if m.shown == nil {
48155 // A landing slide measures in the list it began on.
49156 list := m.Workspaces
@@ -79,6 +186,7 @@ func (c *Core) transition(before []viewShot, now time.Time) {
79186 continue
80187 }
81188 c.transitionCamera(b, now)
189+ c.transitionRects(b, now)
82190 }
83191 }
84192
@@ -120,6 +228,49 @@ func (c *Core) transitionCamera(b *viewShot, now time.Time) {
120228 }
121229 }
122230
231+// transitionRects starts or retargets the rect motions of the windows whose
232+// layout the action changed. The new layout is measured with the camera as
233+// transitionCamera left it (the view where it was on screen), so a window
234+// the view scrolls differs only by what the layout itself changed. Windows
235+// that were not drawn before (new, or hidden) or are hidden now get none.
236+func (c *Core) transitionRects(b *viewShot, now time.Time) {
237+ sc := b.sc
238+ m := sc.mon
239+ if m.ov.open || m.Current() != b.ws {
240+ clear(sc.rects)
241+ return
242+ }
243+ for _, p := range m.Layout() {
244+ if p.Hidden || p.Preview > 0 {
245+ continue
246+ }
247+ i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID })
248+ if i < 0 {
249+ continue
250+ }
251+ old := b.rects[i].rect
252+ if old == p.Rect {
253+ // Still sliding to the settled rect, or already there.
254+ if _, ok := sc.rects[p.ID]; !ok {
255+ continue
256+ }
257+ }
258+ if sc.rects == nil {
259+ sc.rects = make(map[WindowID]rectMotion)
260+ }
261+ rm := sc.rects[p.ID]
262+ c.retargetComponent(&rm.x, &rm.dx, float64(old.X-p.Rect.X), now)
263+ c.retargetComponent(&rm.y, &rm.dy, float64(old.Y-p.Rect.Y), now)
264+ c.retargetComponent(&rm.w, &rm.dw, float64(old.W-p.Rect.W), now)
265+ c.retargetComponent(&rm.h, &rm.dh, float64(old.H-p.Rect.H), now)
266+ if rm.on() {
267+ sc.rects[p.ID], sc.rectsWS = rm, b.ws
268+ } else {
269+ delete(sc.rects, p.ID)
270+ }
271+ }
272+}
273+
123274 // movedAway reports whether w, the workspace sc showed, went to another
124275 // screen: it gets no motion on either side.
125276 func (c *Core) movedAway(sc *screen, w *Workspace) bool {
@@ -1,6 +1,7 @@
1 package core1 package core
2 2
3 import (3 import (
4+ "math"
4 "slices"5 "slices"
5 "time"6 "time"
6 )7 )
@@ -11,6 +12,12 @@ import (
11 // settled one; the transition only adds presentation springs over it12 // settled one; the transition only adds presentation springs over it
12 // (Workspace.shift, Monitor.switchOff), starting from what was on screen.13 // (Workspace.shift, Monitor.switchOff), starting from what was on screen.
13 // Nothing here runs for events that come from clients or outputs.14 // Nothing here runs for events that come from clients or outputs.
15+//
16+// A window's rect transition (option B) is presentation too: configures keep
17+// using the settled rect, so the client resizes once to its final size; what
18+// is drawn and hit-tested is the settled rect plus an offset that a spring
19+// takes to zero. The offsets are relative to the camera-shifted layout, so a
20+// window the view scrolls moves with the camera spring only.
14 21
15 // viewShot is what one screen showed before an action.22 // viewShot is what one screen showed before an action.
16 type viewShot struct {23 type viewShot struct {
@@ -26,6 +33,100 @@ type viewShot struct {
26 list []*Workspace33 list []*Workspace
27 pos float6434 pos float64
28 ok bool35 ok bool
36+ // rects are the rects the screen drew for the windows of ws, hidden
37+ // windows and previews left out. The slice is reused like list.
38+ rects []rectShot
39+}
40+
41+// rectShot is the rect a window was drawn at.
42+type rectShot struct {
43+ id WindowID
44+ rect Rect
45+}
46+
47+// rectMotion takes a window's drawn rect to its settled one: each component
48+// is an offset from the settled value, sprung to 0 (dx, dy, dw, dh hold the
49+// current offsets, updated by advance).
50+type rectMotion struct {
51+ x, y, w, h motion
52+ dx, dy, dw, dh float64
53+}
54+
55+// apply is r moved by the current offsets; a size never drops below 1.
56+func (m *rectMotion) apply(r Rect) Rect {
57+ return Rect{
58+ X: r.X + int(math.Round(m.dx)), Y: r.Y + int(math.Round(m.dy)),
59+ W: max(r.W+int(math.Round(m.dw)), 1), H: max(r.H+int(math.Round(m.dh)), 1),
60+ }
61+}
62+
63+// advance moves the offsets to now; a settled component stops at 0.
64+func (m *rectMotion) advance(now time.Time) {
65+ m.dx = stepComponent(&m.x, m.dx, now)
66+ m.dy = stepComponent(&m.y, m.dy, now)
67+ m.dw = stepComponent(&m.w, m.dw, now)
68+ m.dh = stepComponent(&m.h, m.dh, now)
69+}
70+
71+func stepComponent(m *motion, cur float64, now time.Time) float64 {
72+ if !m.on {
73+ return cur
74+ }
75+ v, done := m.at(now)
76+ if done {
77+ *m = motion{}
78+ return 0
79+ }
80+ return v
81+}
82+
83+// on reports whether any component still runs.
84+func (m *rectMotion) on() bool { return m.x.on || m.y.on || m.w.on || m.h.on }
85+
86+// retargetComponent starts m for an offset of off from its settled value at
87+// now, keeping the speed it has now. A component with nothing to move and no
88+// speed stops.
89+func (c *Core) retargetComponent(m *motion, cur *float64, off float64, now time.Time) {
90+ v := m.velocity()
91+ if !m.on {
92+ v = 0
93+ }
94+ *cur = off
95+ if off == 0 && v == 0 {
96+ *m = motion{}
97+ return
98+ }
99+ *m = c.spring(viewSpring(off, v), now)
100+}
101+
102+// refreshShown builds the layouts of every screen once per publish: the
103+// settled one (what configures are sized from) and the shown one (the same
104+// with the rect motions applied: what is drawn and hit-tested). Rect motions
105+// belong to the workspace they began on and to a closed overview: any other
106+// change drops them.
107+func (c *Core) refreshShown() {
108+ for _, sc := range c.screens {
109+ m := sc.mon
110+ if len(sc.rects) > 0 && (m.ov.open || sc.rectsWS != m.Current()) {
111+ clear(sc.rects)
112+ }
113+ sc.settled = m.Layout()
114+ if len(sc.rects) == 0 {
115+ sc.shown = sc.settled
116+ continue
117+ }
118+ buf := append(sc.shownBuf[:0], sc.settled...)
119+ for i := range buf {
120+ p := &buf[i]
121+ if p.Hidden || p.Preview > 0 {
122+ continue
123+ }
124+ if rm, ok := sc.rects[p.ID]; ok {
125+ p.Rect = rm.apply(p.Rect)
126+ }
127+ }
128+ sc.shownBuf, sc.shown = buf, buf
129+ }
29 }130 }
30 131
31 // snapshot records what every screen without an open overview shows. The132 // snapshot records what every screen without an open overview shows. The
@@ -39,11 +140,17 @@ func (c *Core) snapshot() []viewShot {
39 var s viewShot140 var s viewShot
40 if len(out) < len(full) {141 if len(out) < len(full) {
41 s.list = full[len(out)].list[:0]142 s.list = full[len(out)].list[:0]
143+ s.rects = full[len(out)].rects[:0]
42 }144 }
43 s.sc = sc145 s.sc = sc
44 if !m.ov.open {146 if !m.ov.open {
45 w := m.Current()147 w := m.Current()
46 s.ws, s.viewX, s.view = w, w.ViewX, float64(w.ViewX)+w.shift148 s.ws, s.viewX, s.view = w, w.ViewX, float64(w.ViewX)+w.shift
149+ for _, p := range sc.shown {
150+ if !p.Hidden && p.Preview == 0 {
151+ s.rects = append(s.rects, rectShot{p.ID, p.Rect})
152+ }
153+ }
47 if m.shown == nil {154 if m.shown == nil {
48 // A landing slide measures in the list it began on.155 // A landing slide measures in the list it began on.
49 list := m.Workspaces156 list := m.Workspaces
@@ -79,6 +186,7 @@ func (c *Core) transition(before []viewShot, now time.Time) {
79 continue186 continue
80 }187 }
81 c.transitionCamera(b, now)188 c.transitionCamera(b, now)
189+ c.transitionRects(b, now)
82 }190 }
83 }191 }
84 192
@@ -120,6 +228,49 @@ func (c *Core) transitionCamera(b *viewShot, now time.Time) {
120 }228 }
121 }229 }
122 230
231+// transitionRects starts or retargets the rect motions of the windows whose
232+// layout the action changed. The new layout is measured with the camera as
233+// transitionCamera left it (the view where it was on screen), so a window
234+// the view scrolls differs only by what the layout itself changed. Windows
235+// that were not drawn before (new, or hidden) or are hidden now get none.
236+func (c *Core) transitionRects(b *viewShot, now time.Time) {
237+ sc := b.sc
238+ m := sc.mon
239+ if m.ov.open || m.Current() != b.ws {
240+ clear(sc.rects)
241+ return
242+ }
243+ for _, p := range m.Layout() {
244+ if p.Hidden || p.Preview > 0 {
245+ continue
246+ }
247+ i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID })
248+ if i < 0 {
249+ continue
250+ }
251+ old := b.rects[i].rect
252+ if old == p.Rect {
253+ // Still sliding to the settled rect, or already there.
254+ if _, ok := sc.rects[p.ID]; !ok {
255+ continue
256+ }
257+ }
258+ if sc.rects == nil {
259+ sc.rects = make(map[WindowID]rectMotion)
260+ }
261+ rm := sc.rects[p.ID]
262+ c.retargetComponent(&rm.x, &rm.dx, float64(old.X-p.Rect.X), now)
263+ c.retargetComponent(&rm.y, &rm.dy, float64(old.Y-p.Rect.Y), now)
264+ c.retargetComponent(&rm.w, &rm.dw, float64(old.W-p.Rect.W), now)
265+ c.retargetComponent(&rm.h, &rm.dh, float64(old.H-p.Rect.H), now)
266+ if rm.on() {
267+ sc.rects[p.ID], sc.rectsWS = rm, b.ws
268+ } else {
269+ delete(sc.rects, p.ID)
270+ }
271+ }
272+}
273+
123 // movedAway reports whether w, the workspace sc showed, went to another274 // movedAway reports whether w, the workspace sc showed, went to another
124 // screen: it gets no motion on either side.275 // screen: it gets no motion on either side.
125 func (c *Core) movedAway(sc *screen, w *Workspace) bool {276 func (c *Core) movedAway(sc *screen, w *Workspace) bool {
M internal/core/transition_test.go +211 -0
@@ -146,3 +146,214 @@ func TestMovedWorkspaceHasNoMotion(t *testing.T) {
146146 r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
147147 r.noFrameScene(t)
148148 }
149+
150+// rectNow is the rect of window id in s; it must be in the scene.
151+func rectNow(t *testing.T, s ports.Scene, id ports.WindowID) ports.Rect {
152+ t.Helper()
153+ r, ok := rectOf(s, id)
154+ if !ok {
155+ t.Fatalf("window %d not shown", id)
156+ }
157+ return r
158+}
159+
160+// twoColumns maps two windows: 1 on the left half, 2 on the right, focused.
161+func twoColumns(t *testing.T, r *swipeRig) ports.Scene {
162+ t.Helper()
163+ r.mapWindow(t, 1)
164+ return r.mapWindow(t, 2)[0]
165+}
166+
167+// Moving a column swaps two windows: the scene right after the key still
168+// shows the old rects, a frame an intermediate one, and the end the final.
169+func TestMoveColumnRectTransition(t *testing.T) {
170+ r := startSwipe(t, nil)
171+ twoColumns(t, r)
172+ s := r.key(t, "Left", ports.ModAlt|ports.ModShift)[0]
173+ if got1, got2 := rectNow(t, s, 1), rectNow(t, s, 2); got1.X != 0 || got2.X != 400 {
174+ t.Fatalf("right after the key 1 at %+v and 2 at %+v, want the old 0 and 400", got1, got2)
175+ }
176+ s = r.frame(t, 16*time.Millisecond)
177+ if got := rectNow(t, s, 1).X; got <= 0 || got >= 400 {
178+ t.Fatalf("window 1 at %d on the first frame, want between 0 and 400", got)
179+ }
180+ if got := rectNow(t, s, 2).X; got <= 0 || got >= 400 {
181+ t.Fatalf("window 2 at %d on the first frame, want between 0 and 400", got)
182+ }
183+ s = r.settle(t)
184+ if got1, got2 := rectNow(t, s, 1), rectNow(t, s, 2); got1.X != 400 || got2.X != 0 {
185+ t.Fatalf("settled 1 at %+v and 2 at %+v, want 400 and 0", got1, got2)
186+ }
187+}
188+
189+// A move that also scrolls the view moves a window by the layout delta only:
190+// window 1 keeps its place on screen while the view and the others slide.
191+func TestMoveColumnThatScrollsKeepsCameraFrame(t *testing.T) {
192+ r := startSwipe(t, nil)
193+ fourColumns(t, r)
194+ r.key(t, "Right", ports.ModAlt|ports.ModShift)
195+ r.settle(t) // order 2 1 3 4, view 0
196+ s := r.key(t, "Right", ports.ModAlt|ports.ModShift)[0]
197+ // Order 2 3 1 4; the view scrolls by 400 to show column 1.
198+ for id, want := range map[ports.WindowID]int{2: 0, 1: 400, 3: 800} {
199+ if got := rectNow(t, s, id).X; got != want {
200+ t.Fatalf("window %d at %d right after the key, want the old %d", id, got, want)
201+ }
202+ }
203+ prev := 800
204+ for i := range 40 {
205+ s = r.frame(t, 16*time.Millisecond)
206+ if i == 39 {
207+ t.Fatal("still moving after 40 frames")
208+ }
209+ if got := rectNow(t, s, 1).X; got < 399 || got > 401 {
210+ t.Fatalf("frame %d: window 1 at %d, want it to keep 400 (the scroll is not a layout move)", i, got)
211+ }
212+ got := rectNow(t, s, 3).X
213+ if got > prev {
214+ t.Fatalf("frame %d: window 3 at %d after %d", i, got, prev)
215+ }
216+ if i == 0 && (got <= 0 || got >= 800) {
217+ t.Fatalf("window 3 at %d on the first frame, want between 0 and 800", got)
218+ }
219+ prev = got
220+ if got == 0 {
221+ break
222+ }
223+ }
224+ for id, want := range map[ports.WindowID]int{2: -400, 3: 0, 1: 400} {
225+ if got := rectNow(t, s, id).X; got != want {
226+ t.Fatalf("window %d at %d settled, want %d", id, got, want)
227+ }
228+ }
229+}
230+
231+// A width change animates the frame while the client gets one configure to
232+// the final size.
233+func TestSetColumnWidthRectTransition(t *testing.T) {
234+ r := startSwipe(t, func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" })
235+ twoColumns(t, r)
236+ r.key(t, "Left", ports.ModAlt) // focus window 1: both columns fit, no scroll
237+ for len(r.commands) > 0 {
238+ <-r.commands
239+ }
240+ mods := ports.ModAlt | ports.ModCtrl
241+ s := r.key(t, "Right", mods)[0]
242+ if got := rectNow(t, s, 1); got.W != 400 {
243+ t.Fatalf("window 1 is %d wide right after the key, want the old 400", got.W)
244+ }
245+ if got := rectNow(t, s, 2); got.X != 400 {
246+ t.Fatalf("window 2 at %d right after the key, want the old 400", got.X)
247+ }
248+ s = r.frame(t, 16*time.Millisecond)
249+ if got := rectNow(t, s, 1).W; got <= 400 || got >= 480 {
250+ t.Fatalf("window 1 is %d wide on the first frame, want between 400 and 480", got)
251+ }
252+ if got := rectNow(t, s, 2).X; got <= 400 || got >= 480 {
253+ t.Fatalf("window 2 at %d on the first frame, want between 400 and 480", got)
254+ }
255+ s = r.settle(t)
256+ if got := rectNow(t, s, 1).W; got != 480 {
257+ t.Fatalf("window 1 is %d wide settled, want 480", got)
258+ }
259+ if got := rectNow(t, s, 2).X; got != 480 {
260+ t.Fatalf("window 2 at %d settled, want 480", got)
261+ }
262+ var widths []int
263+ for len(r.commands) > 0 {
264+ if v, ok := (<-r.commands).(ports.ConfigureWindow); ok && v.ID == 1 {
265+ widths = append(widths, v.Width)
266+ }
267+ }
268+ if len(widths) != 1 || widths[0] != 480 {
269+ t.Fatalf("configures of window 1 carried widths %v, want exactly [480]", widths)
270+ }
271+}
272+
273+// Hit-testing follows what is drawn: a point only the shown rect covers
274+// reaches that window mid-transition.
275+func TestPointerHitsShownRectMidTransition(t *testing.T) {
276+ r := startSwipe(t, nil)
277+ twoColumns(t, r)
278+ // Window 2 (focused) moves to the left half, 1 to the right; drawn, 1
279+ // still holds the left half for now.
280+ s := r.key(t, "Left", ports.ModAlt|ports.ModShift)[0]
281+ if got := rectNow(t, s, 1).X; got != 0 {
282+ t.Fatalf("window 1 at %d right after the key, want the old 0", got)
283+ }
284+ r.input <- ports.PointerMotion{X: 100, Y: 300}
285+ r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
286+ s = scene(t, r.scenes)
287+ for _, w := range s.Windows {
288+ if w.Focused != (w.ID == 1) {
289+ t.Fatalf("window %d focused %t after a click on the shown window 1", w.ID, w.Focused)
290+ }
291+ }
292+ r.input <- ports.PointerButton{Button: 0x110}
293+}
294+
295+// toggle-fullscreen grows the window to the output over the frames.
296+func TestFullscreenRectTransition(t *testing.T) {
297+ r := startSwipe(t, nil)
298+ twoColumns(t, r)
299+ s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]
300+ if got := rectNow(t, s, 2); got.W != 400 {
301+ t.Fatalf("window 2 is %d wide right after the key, want the old 400", got.W)
302+ }
303+ got := rectNow(t, r.frame(t, 16*time.Millisecond), 2)
304+ if got.W <= 400 || got.W >= 800 {
305+ t.Fatalf("window 2 is %d wide on the first frame, want between 400 and 800", got.W)
306+ }
307+ if got := rectNow(t, r.settle(t), 2); got != (ports.Rect{W: 800, H: 600}) {
308+ t.Fatalf("window 2 at %+v settled, want the output", got)
309+ }
310+}
311+
312+// A workspace that moves to another monitor gets no rect or switch motion
313+// on either monitor.
314+func TestMovedWorkspaceGetsNoRectMotion(t *testing.T) {
315+ r := startSwipe(t, nil)
316+ twoColumns(t, r)
317+ other := ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000}
318+ r.plug(t, other)
319+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
320+ for _, o := range []string{wide.Name, other.Name} {
321+ r.advance(16 * time.Millisecond)
322+ r.frames <- ports.OutputFrame{Output: o}
323+ }
324+ select {
325+ case s := <-r.scenes:
326+ t.Fatalf("a flip published a scene (%d outputs): a motion runs", len(s))
327+ case <-time.After(50 * time.Millisecond):
328+ }
329+}
330+
331+// With animations off a layout change lands at once.
332+func TestAnimationsOffRectsLandAtOnce(t *testing.T) {
333+ r := startSwipe(t, animationsOff)
334+ twoColumns(t, r)
335+ s := r.key(t, "Left", ports.ModAlt|ports.ModShift)[0]
336+ if got1, got2 := rectNow(t, s, 1), rectNow(t, s, 2); got1.X != 400 || got2.X != 0 {
337+ t.Fatalf("1 at %+v and 2 at %+v right after the key, want the final 400 and 0", got1, got2)
338+ }
339+ r.noFrameScene(t)
340+}
341+
342+// A second action during a rect transition starts from what is drawn: the
343+// scene right after it shows the rects of the last frame.
344+func TestChainedMovesStartFromShownRects(t *testing.T) {
345+ r := startSwipe(t, nil)
346+ twoColumns(t, r)
347+ r.key(t, "Left", ports.ModAlt|ports.ModShift)
348+ mid := r.frame(t, 16*time.Millisecond)
349+ s := r.key(t, "Right", ports.ModAlt|ports.ModShift)[0]
350+ for _, id := range []ports.WindowID{1, 2} {
351+ if got, want := rectNow(t, s, id), rectNow(t, mid, id); got != want {
352+ t.Fatalf("window %d at %+v right after the second key, want the shown %+v", id, got, want)
353+ }
354+ }
355+ end := r.settle(t)
356+ if got1, got2 := rectNow(t, end, 1), rectNow(t, end, 2); got1.X != 0 || got2.X != 400 {
357+ t.Fatalf("settled 1 at %+v and 2 at %+v, want 0 and 400", got1, got2)
358+ }
359+}
@@ -146,3 +146,214 @@ func TestMovedWorkspaceHasNoMotion(t *testing.T) {
146 r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)146 r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
147 r.noFrameScene(t)147 r.noFrameScene(t)
148 }148 }
149+
150+// rectNow is the rect of window id in s; it must be in the scene.
151+func rectNow(t *testing.T, s ports.Scene, id ports.WindowID) ports.Rect {
152+ t.Helper()
153+ r, ok := rectOf(s, id)
154+ if !ok {
155+ t.Fatalf("window %d not shown", id)
156+ }
157+ return r
158+}
159+
160+// twoColumns maps two windows: 1 on the left half, 2 on the right, focused.
161+func twoColumns(t *testing.T, r *swipeRig) ports.Scene {
162+ t.Helper()
163+ r.mapWindow(t, 1)
164+ return r.mapWindow(t, 2)[0]
165+}
166+
167+// Moving a column swaps two windows: the scene right after the key still
168+// shows the old rects, a frame an intermediate one, and the end the final.
169+func TestMoveColumnRectTransition(t *testing.T) {
170+ r := startSwipe(t, nil)
171+ twoColumns(t, r)
172+ s := r.key(t, "Left", ports.ModAlt|ports.ModShift)[0]
173+ if got1, got2 := rectNow(t, s, 1), rectNow(t, s, 2); got1.X != 0 || got2.X != 400 {
174+ t.Fatalf("right after the key 1 at %+v and 2 at %+v, want the old 0 and 400", got1, got2)
175+ }
176+ s = r.frame(t, 16*time.Millisecond)
177+ if got := rectNow(t, s, 1).X; got <= 0 || got >= 400 {
178+ t.Fatalf("window 1 at %d on the first frame, want between 0 and 400", got)
179+ }
180+ if got := rectNow(t, s, 2).X; got <= 0 || got >= 400 {
181+ t.Fatalf("window 2 at %d on the first frame, want between 0 and 400", got)
182+ }
183+ s = r.settle(t)
184+ if got1, got2 := rectNow(t, s, 1), rectNow(t, s, 2); got1.X != 400 || got2.X != 0 {
185+ t.Fatalf("settled 1 at %+v and 2 at %+v, want 400 and 0", got1, got2)
186+ }
187+}
188+
189+// A move that also scrolls the view moves a window by the layout delta only:
190+// window 1 keeps its place on screen while the view and the others slide.
191+func TestMoveColumnThatScrollsKeepsCameraFrame(t *testing.T) {
192+ r := startSwipe(t, nil)
193+ fourColumns(t, r)
194+ r.key(t, "Right", ports.ModAlt|ports.ModShift)
195+ r.settle(t) // order 2 1 3 4, view 0
196+ s := r.key(t, "Right", ports.ModAlt|ports.ModShift)[0]
197+ // Order 2 3 1 4; the view scrolls by 400 to show column 1.
198+ for id, want := range map[ports.WindowID]int{2: 0, 1: 400, 3: 800} {
199+ if got := rectNow(t, s, id).X; got != want {
200+ t.Fatalf("window %d at %d right after the key, want the old %d", id, got, want)
201+ }
202+ }
203+ prev := 800
204+ for i := range 40 {
205+ s = r.frame(t, 16*time.Millisecond)
206+ if i == 39 {
207+ t.Fatal("still moving after 40 frames")
208+ }
209+ if got := rectNow(t, s, 1).X; got < 399 || got > 401 {
210+ t.Fatalf("frame %d: window 1 at %d, want it to keep 400 (the scroll is not a layout move)", i, got)
211+ }
212+ got := rectNow(t, s, 3).X
213+ if got > prev {
214+ t.Fatalf("frame %d: window 3 at %d after %d", i, got, prev)
215+ }
216+ if i == 0 && (got <= 0 || got >= 800) {
217+ t.Fatalf("window 3 at %d on the first frame, want between 0 and 800", got)
218+ }
219+ prev = got
220+ if got == 0 {
221+ break
222+ }
223+ }
224+ for id, want := range map[ports.WindowID]int{2: -400, 3: 0, 1: 400} {
225+ if got := rectNow(t, s, id).X; got != want {
226+ t.Fatalf("window %d at %d settled, want %d", id, got, want)
227+ }
228+ }
229+}
230+
231+// A width change animates the frame while the client gets one configure to
232+// the final size.
233+func TestSetColumnWidthRectTransition(t *testing.T) {
234+ r := startSwipe(t, func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" })
235+ twoColumns(t, r)
236+ r.key(t, "Left", ports.ModAlt) // focus window 1: both columns fit, no scroll
237+ for len(r.commands) > 0 {
238+ <-r.commands
239+ }
240+ mods := ports.ModAlt | ports.ModCtrl
241+ s := r.key(t, "Right", mods)[0]
242+ if got := rectNow(t, s, 1); got.W != 400 {
243+ t.Fatalf("window 1 is %d wide right after the key, want the old 400", got.W)
244+ }
245+ if got := rectNow(t, s, 2); got.X != 400 {
246+ t.Fatalf("window 2 at %d right after the key, want the old 400", got.X)
247+ }
248+ s = r.frame(t, 16*time.Millisecond)
249+ if got := rectNow(t, s, 1).W; got <= 400 || got >= 480 {
250+ t.Fatalf("window 1 is %d wide on the first frame, want between 400 and 480", got)
251+ }
252+ if got := rectNow(t, s, 2).X; got <= 400 || got >= 480 {
253+ t.Fatalf("window 2 at %d on the first frame, want between 400 and 480", got)
254+ }
255+ s = r.settle(t)
256+ if got := rectNow(t, s, 1).W; got != 480 {
257+ t.Fatalf("window 1 is %d wide settled, want 480", got)
258+ }
259+ if got := rectNow(t, s, 2).X; got != 480 {
260+ t.Fatalf("window 2 at %d settled, want 480", got)
261+ }
262+ var widths []int
263+ for len(r.commands) > 0 {
264+ if v, ok := (<-r.commands).(ports.ConfigureWindow); ok && v.ID == 1 {
265+ widths = append(widths, v.Width)
266+ }
267+ }
268+ if len(widths) != 1 || widths[0] != 480 {
269+ t.Fatalf("configures of window 1 carried widths %v, want exactly [480]", widths)
270+ }
271+}
272+
273+// Hit-testing follows what is drawn: a point only the shown rect covers
274+// reaches that window mid-transition.
275+func TestPointerHitsShownRectMidTransition(t *testing.T) {
276+ r := startSwipe(t, nil)
277+ twoColumns(t, r)
278+ // Window 2 (focused) moves to the left half, 1 to the right; drawn, 1
279+ // still holds the left half for now.
280+ s := r.key(t, "Left", ports.ModAlt|ports.ModShift)[0]
281+ if got := rectNow(t, s, 1).X; got != 0 {
282+ t.Fatalf("window 1 at %d right after the key, want the old 0", got)
283+ }
284+ r.input <- ports.PointerMotion{X: 100, Y: 300}
285+ r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
286+ s = scene(t, r.scenes)
287+ for _, w := range s.Windows {
288+ if w.Focused != (w.ID == 1) {
289+ t.Fatalf("window %d focused %t after a click on the shown window 1", w.ID, w.Focused)
290+ }
291+ }
292+ r.input <- ports.PointerButton{Button: 0x110}
293+}
294+
295+// toggle-fullscreen grows the window to the output over the frames.
296+func TestFullscreenRectTransition(t *testing.T) {
297+ r := startSwipe(t, nil)
298+ twoColumns(t, r)
299+ s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]
300+ if got := rectNow(t, s, 2); got.W != 400 {
301+ t.Fatalf("window 2 is %d wide right after the key, want the old 400", got.W)
302+ }
303+ got := rectNow(t, r.frame(t, 16*time.Millisecond), 2)
304+ if got.W <= 400 || got.W >= 800 {
305+ t.Fatalf("window 2 is %d wide on the first frame, want between 400 and 800", got.W)
306+ }
307+ if got := rectNow(t, r.settle(t), 2); got != (ports.Rect{W: 800, H: 600}) {
308+ t.Fatalf("window 2 at %+v settled, want the output", got)
309+ }
310+}
311+
312+// A workspace that moves to another monitor gets no rect or switch motion
313+// on either monitor.
314+func TestMovedWorkspaceGetsNoRectMotion(t *testing.T) {
315+ r := startSwipe(t, nil)
316+ twoColumns(t, r)
317+ other := ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000}
318+ r.plug(t, other)
319+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
320+ for _, o := range []string{wide.Name, other.Name} {
321+ r.advance(16 * time.Millisecond)
322+ r.frames <- ports.OutputFrame{Output: o}
323+ }
324+ select {
325+ case s := <-r.scenes:
326+ t.Fatalf("a flip published a scene (%d outputs): a motion runs", len(s))
327+ case <-time.After(50 * time.Millisecond):
328+ }
329+}
330+
331+// With animations off a layout change lands at once.
332+func TestAnimationsOffRectsLandAtOnce(t *testing.T) {
333+ r := startSwipe(t, animationsOff)
334+ twoColumns(t, r)
335+ s := r.key(t, "Left", ports.ModAlt|ports.ModShift)[0]
336+ if got1, got2 := rectNow(t, s, 1), rectNow(t, s, 2); got1.X != 400 || got2.X != 0 {
337+ t.Fatalf("1 at %+v and 2 at %+v right after the key, want the final 400 and 0", got1, got2)
338+ }
339+ r.noFrameScene(t)
340+}
341+
342+// A second action during a rect transition starts from what is drawn: the
343+// scene right after it shows the rects of the last frame.
344+func TestChainedMovesStartFromShownRects(t *testing.T) {
345+ r := startSwipe(t, nil)
346+ twoColumns(t, r)
347+ r.key(t, "Left", ports.ModAlt|ports.ModShift)
348+ mid := r.frame(t, 16*time.Millisecond)
349+ s := r.key(t, "Right", ports.ModAlt|ports.ModShift)[0]
350+ for _, id := range []ports.WindowID{1, 2} {
351+ if got, want := rectNow(t, s, id), rectNow(t, mid, id); got != want {
352+ t.Fatalf("window %d at %+v right after the second key, want the shown %+v", id, got, want)
353+ }
354+ }
355+ end := r.settle(t)
356+ if got1, got2 := rectNow(t, end, 1), rectNow(t, end, 2); got1.X != 0 || got2.X != 400 {
357+ t.Fatalf("settled 1 at %+v and 2 at %+v, want 0 and 400", got1, got2)
358+ }
359+}
M internal/core/visibility_test.go +1 -0
@@ -91,6 +91,7 @@ func TestConfigureVisible(t *testing.T) {
9191 t.Fatal("popup of a visible window not on screen")
9292 }
9393 m.AddWindow(4)
94+ publish() // visibility reads the layout of the last publish
9495 if c.visible(1) || c.visible(10) {
9596 t.Fatal("popup of a scrolled-off window still on screen")
9697 }
@@ -91,6 +91,7 @@ func TestConfigureVisible(t *testing.T) {
91 t.Fatal("popup of a visible window not on screen")91 t.Fatal("popup of a visible window not on screen")
92 }92 }
93 m.AddWindow(4)93 m.AddWindow(4)
94+ publish() // visibility reads the layout of the last publish
94 if c.visible(1) || c.visible(10) {95 if c.visible(1) || c.visible(10) {
95 t.Fatal("popup of a scrolled-off window still on screen")96 t.Fatal("popup of a scrolled-off window still on screen")
96 }97 }
M internal/core/workspace_size_test.go +2 -2
@@ -73,7 +73,7 @@ func TestWorkspaceSizeFixedColumnsFillFrame(t *testing.T) {
7373 }
7474
7575 func TestWorkspaceSizeFullscreenFillsFrame(t *testing.T) {
76- r, _ := sizedRig(t, "scroll", nil)
76+ r, _ := sizedRig(t, "scroll", animationsOff)
7777 r.mapWindow(t, 1)
7878 r.mapWindow(t, 2)
7979 s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]
@@ -92,7 +92,7 @@ func TestWorkspaceSizeFullscreenFillsFrame(t *testing.T) {
9292 }
9393
9494 func TestWorkspaceSizeFixedFullscreenKeepsFrame(t *testing.T) {
95- r, _ := sizedRig(t, "fixed", nil)
95+ r, _ := sizedRig(t, "fixed", animationsOff)
9696 r.mapWindow(t, 1)
9797 r.mapWindow(t, 2)
9898 s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]
@@ -73,7 +73,7 @@ func TestWorkspaceSizeFixedColumnsFillFrame(t *testing.T) {
73 }73 }
74 74
75 func TestWorkspaceSizeFullscreenFillsFrame(t *testing.T) {75 func TestWorkspaceSizeFullscreenFillsFrame(t *testing.T) {
76- r, _ := sizedRig(t, "scroll", nil)76+ r, _ := sizedRig(t, "scroll", animationsOff)
77 r.mapWindow(t, 1)77 r.mapWindow(t, 1)
78 r.mapWindow(t, 2)78 r.mapWindow(t, 2)
79 s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]79 s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]
@@ -92,7 +92,7 @@ func TestWorkspaceSizeFullscreenFillsFrame(t *testing.T) {
92 }92 }
93 93
94 func TestWorkspaceSizeFixedFullscreenKeepsFrame(t *testing.T) {94 func TestWorkspaceSizeFixedFullscreenKeepsFrame(t *testing.T) {
95- r, _ := sizedRig(t, "fixed", nil)95+ r, _ := sizedRig(t, "fixed", animationsOff)
96 r.mapWindow(t, 1)96 r.mapWindow(t, 1)
97 r.mapWindow(t, 2)97 r.mapWindow(t, 2)
98 s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]98 s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]

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