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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 15 | 15 | t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) { | |
| 16 | 16 | rng := rand.New(rand.NewSource(20260928)) | |
| 17 | 17 | r := startMulti(t, func(c *ports.Config) { | |
| 18 | + | c.Animations.On = false | |
| 18 | 19 | c.Border.Width = 2 | |
| 19 | 20 | c.Layout.MaxColumns = 3 | |
| 20 | 21 | 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 = 2 | 19 | c.Border.Width = 2 | ||
| 19 | c.Layout.MaxColumns = 3 | 20 | c.Layout.MaxColumns = 3 | ||
| 20 | c.Layout.Overflow = overflow | 21 | c.Layout.Overflow = overflow | ||
M internal/core/core.go +12 -7
| @@ -511,6 +511,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 511 | 511 | sc.mon.Current().settleLeft() | |
| 512 | 512 | } | |
| 513 | 513 | } | |
| 514 | + | c.refreshShown() | |
| 514 | 515 | c.captureExpire() | |
| 515 | 516 | capture, err := c.captureEvaluate(ctx) | |
| 516 | 517 | if err != nil { | |
| @@ -559,7 +560,9 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 559 | 560 | clip = frame | |
| 560 | 561 | } | |
| 561 | 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 | 566 | var real map[WindowID]Placement | |
| 564 | 567 | if sc.mon.ov.open { | |
| 565 | 568 | real = make(map[WindowID]Placement) | |
| @@ -587,7 +590,8 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 587 | 590 | } | |
| 588 | 591 | // A window alone on screen needs no pulse to show it has the focus. | |
| 589 | 592 | 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 | 595 | // Only the focused output has an activated window. | |
| 592 | 596 | focused := p.Focused && i == c.focusScreen | |
| 593 | 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 | 608 | t := configureTarget{output: sc.name(), area: frame, focused: focused, captured: capture != nil && capture.window == p.ID} | |
| 605 | 609 | if !p.Hidden && p.Preview == 0 { | |
| 606 | 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 | 612 | } else if t.captured && p.Hidden { | |
| 609 | 613 | // A captured hidden window is sized like its capture. | |
| 610 | 614 | t.client = capture.windowSz | |
| @@ -614,8 +618,8 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 614 | 618 | t.client = c.clientRect(rp) | |
| 615 | 619 | } | |
| 616 | 620 | } | |
| 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 { | |
| 619 | 623 | if err := c.command(ctx, v); err != nil { | |
| 620 | 624 | return err | |
| 621 | 625 | } | |
| @@ -812,7 +816,7 @@ func (c *Core) shownClient(id WindowID) (full, visible Rect, ok bool) { | |||
| 812 | 816 | if s == nil { | |
| 813 | 817 | return Rect{}, Rect{}, false | |
| 814 | 818 | } | |
| 815 | - | for _, p := range s.mon.Layout() { | |
| 819 | + | for _, p := range s.shownLayout() { | |
| 816 | 820 | if p.ID != id || p.Hidden || p.Preview > 0 { | |
| 817 | 821 | continue | |
| 818 | 822 | } | |
| @@ -962,7 +966,7 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||
| 962 | 966 | } | |
| 963 | 967 | var id WindowID | |
| 964 | 968 | var sx, sy float64 | |
| 965 | - | for _, p := range sc.mon.Layout() { | |
| 969 | + | for _, p := range sc.shownLayout() { | |
| 966 | 970 | r := c.clientRect(p) | |
| 967 | 971 | // A peek is clickable wherever it shows, border included: it may | |
| 968 | 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 | 1107 | if s, _ := c.screenOf(v.ID); s != nil { | |
| 1104 | 1108 | s.mon.RemoveWindow(v.ID) | |
| 1109 | + | delete(s.rects, v.ID) | |
| 1105 | 1110 | } | |
| 1106 | 1111 | c.releaseSlots() | |
| 1107 | 1112 | 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 = frame | 560 | 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]Placement | 566 | 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) == 1 | 592 | 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.focusScreen | 596 | 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.windowSz | 614 | 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 err | 624 | 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{}, false | 817 | 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 | continue | 821 | continue | ||
| 818 | } | 822 | } | ||
| @@ -962,7 +966,7 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||||
| 962 | } | 966 | } | ||
| 963 | var id WindowID | 967 | var id WindowID | ||
| 964 | var sx, sy float64 | 968 | 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 may | 971 | // 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) { | |||
| 333 | 333 | } | |
| 334 | 334 | ||
| 335 | 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 | 337 | for id := ports.WindowID(1); id <= 3; id++ { | |
| 338 | 338 | r.client <- ports.WindowMapped{ID: id} | |
| 339 | 339 | 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 | |||
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | 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 | 25 | r.mapWindow(t, 1) | |
| 26 | 26 | r.mapWindow(t, 2) | |
| 27 | 27 | 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 { | |||
| 340 | 340 | // stopAnimations settles every running spring where it is going and stops | |
| 341 | 341 | // the frame timer: a session lock or a switch to animations = off. A swipe | |
| 342 | 342 | // 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 | 344 | func (c *Core) stopAnimations() { | |
| 345 | 345 | for _, sc := range c.screens { | |
| 346 | 346 | if sc.mon.switchMotion.on { | |
| @@ -351,6 +351,7 @@ func (c *Core) stopAnimations() { | |||
| 351 | 351 | w.stopSlide() | |
| 352 | 352 | } | |
| 353 | 353 | } | |
| 354 | + | clear(sc.rects) | |
| 354 | 355 | } | |
| 355 | 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 | 394 | // sliding reports whether a spring runs on the named output. | |
| 386 | 395 | func (c *Core) sliding(output string) bool { | |
| 387 | 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 | 400 | // animating reports whether a spring or the focus pulse runs on any output. | |
| @@ -394,7 +403,7 @@ func (c *Core) animating() bool { | |||
| 394 | 403 | return true | |
| 395 | 404 | } | |
| 396 | 405 | for _, sc := range c.screens { | |
| 397 | - | if sc.mon.springing() { | |
| 406 | + | if sc.springing() { | |
| 398 | 407 | return true | |
| 399 | 408 | } | |
| 400 | 409 | } | |
| @@ -418,7 +427,7 @@ func (m *Monitor) springing() bool { | |||
| 418 | 427 | func (c *Core) frameFallback() time.Duration { | |
| 419 | 428 | refresh := 60000 | |
| 420 | 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 | 431 | refresh = min(refresh, r) | |
| 423 | 432 | } | |
| 424 | 433 | } | |
| @@ -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 stops | 340 | // 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 swipe | 341 | // 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-window | 342 | // 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 true | 403 | 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 true | 407 | 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 := 60000 | 428 | 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) { | |||
| 144 | 144 | return sc, l.Rect, true | |
| 145 | 145 | } | |
| 146 | 146 | for _, sc := range c.screens { | |
| 147 | - | for _, pl := range sc.mon.Layout() { | |
| 147 | + | for _, pl := range sc.shownLayout() { | |
| 148 | 148 | if pl.ID == id { | |
| 149 | 149 | // Hidden or scrolled off: not on screen, like its popups. | |
| 150 | 150 | // A preview takes no input and shows no popups. | |
| @@ -161,7 +161,7 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | |||
| 161 | 161 | // peeking reports whether id is a stashed window peeking in on screen. | |
| 162 | 162 | func (c *Core) peeking(id WindowID) bool { | |
| 163 | 163 | for _, sc := range c.screens { | |
| 164 | - | for _, pl := range sc.mon.Layout() { | |
| 164 | + | for _, pl := range sc.shownLayout() { | |
| 165 | 165 | if pl.ID == id { | |
| 166 | 166 | return pl.Peek | |
| 167 | 167 | } | |
| @@ -144,7 +144,7 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | |||||
| 144 | return sc, l.Rect, true | 144 | 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.Peek | 166 | return pl.Peek | ||
| 167 | } | 167 | } | ||
M internal/core/pulse.go +3 -2
| @@ -104,7 +104,7 @@ func (c *Core) pulseTick() bool { | |||
| 104 | 104 | // A swipe or its landing slide still shows the neighbours: decide on | |
| 105 | 105 | // the settled layout, or a window left alone would start a pulse cut | |
| 106 | 106 | // short. | |
| 107 | - | if !c.cur().mon.settled() { | |
| 107 | + | if !c.cur().isSettled() { | |
| 108 | 108 | p.timerC, p.timerStop = newTimer(c.ch.Clock, pulseRecheck) | |
| 109 | 109 | return false | |
| 110 | 110 | } | |
| @@ -137,7 +137,8 @@ func (c *Core) advancePulse(now time.Time) float64 { | |||
| 137 | 137 | 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 | 142 | func (m *Monitor) settled() bool { | |
| 142 | 143 | if m.switchMotion.on || m.switchOff != 0 { | |
| 143 | 144 | return false | |
| @@ -104,7 +104,7 @@ func (c *Core) pulseTick() bool { | |||||
| 104 | // A swipe or its landing slide still shows the neighbours: decide on | 104 | // 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 cut | 105 | // 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 false | 109 | return false | ||
| 110 | } | 110 | } | ||
| @@ -137,7 +137,8 @@ func (c *Core) advancePulse(now time.Time) float64 { | |||||
| 137 | return 0 | 137 | 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 false | 144 | return false | ||
M internal/core/screens.go +27 -0
| @@ -31,6 +31,33 @@ type screen struct { | |||
| 31 | 31 | // last is the scene published for this screen: an identical next one | |
| 32 | 32 | // keeps its Seq (Scene.SameAs). Zero after a protected publish. | |
| 33 | 33 | 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 | 63 | 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 one | 31 | // 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.Scene | 33 | 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 { | |||
| 181 | 181 | c.seq++ | |
| 182 | 182 | // The desktop scene that follows must not reuse a Seq from before. | |
| 183 | 183 | sc.last = ports.Scene{} | |
| 184 | + | sc.settled, sc.shown = nil, nil | |
| 184 | 185 | o := sc.mon.Output() | |
| 185 | 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 | 187 | 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 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "math" | |
| 4 | 5 | "slices" | |
| 5 | 6 | "time" | |
| 6 | 7 | ) | |
| @@ -11,6 +12,12 @@ import ( | |||
| 11 | 12 | // settled one; the transition only adds presentation springs over it | |
| 12 | 13 | // (Workspace.shift, Monitor.switchOff), starting from what was on screen. | |
| 13 | 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 | 22 | // viewShot is what one screen showed before an action. | |
| 16 | 23 | type viewShot struct { | |
| @@ -26,6 +33,100 @@ type viewShot struct { | |||
| 26 | 33 | list []*Workspace | |
| 27 | 34 | pos float64 | |
| 28 | 35 | 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 | 132 | // snapshot records what every screen without an open overview shows. The | |
| @@ -39,11 +140,17 @@ func (c *Core) snapshot() []viewShot { | |||
| 39 | 140 | var s viewShot | |
| 40 | 141 | if len(out) < len(full) { | |
| 41 | 142 | s.list = full[len(out)].list[:0] | |
| 143 | + | s.rects = full[len(out)].rects[:0] | |
| 42 | 144 | } | |
| 43 | 145 | s.sc = sc | |
| 44 | 146 | if !m.ov.open { | |
| 45 | 147 | w := m.Current() | |
| 46 | 148 | 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 | 154 | if m.shown == nil { | |
| 48 | 155 | // A landing slide measures in the list it began on. | |
| 49 | 156 | list := m.Workspaces | |
| @@ -79,6 +186,7 @@ func (c *Core) transition(before []viewShot, now time.Time) { | |||
| 79 | 186 | continue | |
| 80 | 187 | } | |
| 81 | 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 | 274 | // movedAway reports whether w, the workspace sc showed, went to another | |
| 124 | 275 | // screen: it gets no motion on either side. | |
| 125 | 276 | func (c *Core) movedAway(sc *screen, w *Workspace) bool { | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package core | 1 | 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 it | 12 | // 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 []*Workspace | 33 | list []*Workspace | ||
| 27 | pos float64 | 34 | pos float64 | ||
| 28 | ok bool | 35 | 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. The | 132 | // snapshot records what every screen without an open overview shows. The | ||
| @@ -39,11 +140,17 @@ func (c *Core) snapshot() []viewShot { | |||||
| 39 | var s viewShot | 140 | 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 = sc | 145 | 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.shift | 148 | 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.Workspaces | 156 | list := m.Workspaces | ||
| @@ -79,6 +186,7 @@ func (c *Core) transition(before []viewShot, now time.Time) { | |||||
| 79 | continue | 186 | 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 another | 274 | // 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) { | |||
| 146 | 146 | r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift) | |
| 147 | 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 | + | } | |
| @@ -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) { | |||
| 91 | 91 | t.Fatal("popup of a visible window not on screen") | |
| 92 | 92 | } | |
| 93 | 93 | m.AddWindow(4) | |
| 94 | + | publish() // visibility reads the layout of the last publish | |
| 94 | 95 | if c.visible(1) || c.visible(10) { | |
| 95 | 96 | t.Fatal("popup of a scrolled-off window still on screen") | |
| 96 | 97 | } | |
| @@ -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) { | |||
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | 75 | func TestWorkspaceSizeFullscreenFillsFrame(t *testing.T) { | |
| 76 | - | r, _ := sizedRig(t, "scroll", nil) | |
| 76 | + | r, _ := sizedRig(t, "scroll", animationsOff) | |
| 77 | 77 | r.mapWindow(t, 1) | |
| 78 | 78 | r.mapWindow(t, 2) | |
| 79 | 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 | 94 | func TestWorkspaceSizeFixedFullscreenKeepsFrame(t *testing.T) { | |
| 95 | - | r, _ := sizedRig(t, "fixed", nil) | |
| 95 | + | r, _ := sizedRig(t, "fixed", animationsOff) | |
| 96 | 96 | r.mapWindow(t, 1) | |
| 97 | 97 | r.mapWindow(t, 2) | |
| 98 | 98 | 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] | ||