fix(core): skip focus flash on edge workspace switches
- commit
- 97aa477c59835e0c9ddeb6b4ee3e93dd78d3a6f8
- author
- brice · · 2026-10-19 05:11 UTC
- parent
- ad49c58
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +146 -6
M internal/core/action.go +3 -0
| @@ -272,6 +272,9 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 272 | 272 | default: | |
| 273 | 273 | return | |
| 274 | 274 | } | |
| 275 | + | if sc == beforeScreen && sc.mon.Current() != beforeWS { | |
| 276 | + | return | |
| 277 | + | } | |
| 275 | 278 | after := sc.mon.Layout() | |
| 276 | 279 | changed := sc != beforeScreen || sc.mon.Current() != beforeWS || id != beforeID || !slices.EqualFunc(before, after, func(a, b Placement) bool { | |
| 277 | 280 | return a.ID == b.ID && a.Rect == b.Rect && a.Focused == b.Focused && a.Hidden == b.Hidden && a.Floating == b.Floating | |
| @@ -272,6 +272,9 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 272 | default: | 272 | default: | ||
| 273 | return | 273 | return | ||
| 274 | } | 274 | } | ||
| 275 | + | if sc == beforeScreen && sc.mon.Current() != beforeWS { | |||
| 276 | + | return | |||
| 277 | + | } | |||
| 275 | after := sc.mon.Layout() | 278 | after := sc.mon.Layout() | ||
| 276 | changed := sc != beforeScreen || sc.mon.Current() != beforeWS || id != beforeID || !slices.EqualFunc(before, after, func(a, b Placement) bool { | 279 | changed := sc != beforeScreen || sc.mon.Current() != beforeWS || id != beforeID || !slices.EqualFunc(before, after, func(a, b Placement) bool { | ||
| 277 | return a.ID == b.ID && a.Rect == b.Rect && a.Focused == b.Focused && a.Hidden == b.Hidden && a.Floating == b.Floating | 280 | return a.ID == b.ID && a.Rect == b.Rect && a.Focused == b.Focused && a.Hidden == b.Hidden && a.Floating == b.Floating | ||
M internal/core/flash.go +11 -5
| @@ -34,6 +34,8 @@ type focusFlash struct { | |||
| 34 | 34 | start time.Time | |
| 35 | 35 | value float64 // Retained between this output's own flips. | |
| 36 | 36 | lastStarts map[flashTarget]time.Time | |
| 37 | + | layout []Placement // Owner scratch, never shared with a published scene. | |
| 38 | + | published *ports.FocusFlash // Immutable last sample, reused when unchanged. | |
| 37 | 39 | } | |
| 38 | 40 | ||
| 39 | 41 | func (c *Core) flashOn() bool { return c.animOn() && c.cfg.Focus.Flash && !c.protectionRequested() } | |
| @@ -72,7 +74,7 @@ func (c *Core) flashTick() bool { | |||
| 72 | 74 | if target.level != flashMonitor { | |
| 73 | 75 | owner, _ := c.screenOf(target.window) | |
| 74 | 76 | record := c.windows.lookup(target.window) | |
| 75 | - | if owner != sc || record.client.ID == 0 || f.since.Sub(record.mappedAt) < flashSettle { | |
| 77 | + | if owner != sc || record.client.ID == 0 { | |
| 76 | 78 | return false | |
| 77 | 79 | } | |
| 78 | 80 | } | |
| @@ -106,8 +108,7 @@ func (c *Core) flashTick() bool { | |||
| 106 | 108 | if p.Hidden || !slices.Contains(w.Columns[col].Windows, p.ID) { | |
| 107 | 109 | continue | |
| 108 | 110 | } | |
| 109 | - | x, y := min(rect.X, p.Rect.X), min(rect.Y, p.Rect.Y) | |
| 110 | - | rect = Rect{X: x, Y: y, W: max(rect.X+rect.W, p.Rect.X+p.Rect.W) - x, H: max(rect.Y+rect.H, p.Rect.Y+p.Rect.H) - y} | |
| 111 | + | rect = union(rect, p.Rect) | |
| 111 | 112 | } | |
| 112 | 113 | } | |
| 113 | 114 | } | |
| @@ -169,7 +170,8 @@ func (c *Core) flashVisible(target flashTarget, sc *screen) bool { | |||
| 169 | 170 | return false | |
| 170 | 171 | } | |
| 171 | 172 | view := sc.mon.viewport() | |
| 172 | - | for _, p := range sc.mon.Layout() { | |
| 173 | + | c.flash.layout = sc.mon.layoutInto(c.flash.layout) | |
| 174 | + | for _, p := range c.flash.layout { | |
| 173 | 175 | if p.ID == target.window { | |
| 174 | 176 | return !p.Fullscreen && p.Preview == 0 && view.shows(p) | |
| 175 | 177 | } | |
| @@ -186,7 +188,11 @@ func (c *Core) flashFor(sc *screen) *ports.FocusFlash { | |||
| 186 | 188 | if color == "" { | |
| 187 | 189 | color = c.cfg.Border.Active | |
| 188 | 190 | } | |
| 189 | - | return &ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Color: color, Alpha: f.value} | |
| 191 | + | sample := ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Color: color, Alpha: f.value} | |
| 192 | + | if f.published == nil || *f.published != sample { | |
| 193 | + | f.published = &ports.FocusFlash{Rect: sample.Rect, Width: sample.Width, Color: sample.Color, Alpha: sample.Alpha} | |
| 194 | + | } | |
| 195 | + | return f.published | |
| 190 | 196 | } | |
| 191 | 197 | ||
| 192 | 198 | func (c *Core) flashing(sc *screen) bool { | |
| @@ -34,6 +34,8 @@ type focusFlash struct { | |||||
| 34 | start time.Time | 34 | start time.Time | ||
| 35 | value float64 // Retained between this output's own flips. | 35 | value float64 // Retained between this output's own flips. | ||
| 36 | lastStarts map[flashTarget]time.Time | 36 | lastStarts map[flashTarget]time.Time | ||
| 37 | + | layout []Placement // Owner scratch, never shared with a published scene. | |||
| 38 | + | published *ports.FocusFlash // Immutable last sample, reused when unchanged. | |||
| 37 | } | 39 | } | ||
| 38 | 40 | ||||
| 39 | func (c *Core) flashOn() bool { return c.animOn() && c.cfg.Focus.Flash && !c.protectionRequested() } | 41 | func (c *Core) flashOn() bool { return c.animOn() && c.cfg.Focus.Flash && !c.protectionRequested() } | ||
| @@ -72,7 +74,7 @@ func (c *Core) flashTick() bool { | |||||
| 72 | if target.level != flashMonitor { | 74 | if target.level != flashMonitor { | ||
| 73 | owner, _ := c.screenOf(target.window) | 75 | owner, _ := c.screenOf(target.window) | ||
| 74 | record := c.windows.lookup(target.window) | 76 | record := c.windows.lookup(target.window) | ||
| 75 | - | if owner != sc || record.client.ID == 0 || f.since.Sub(record.mappedAt) < flashSettle { | 77 | + | if owner != sc || record.client.ID == 0 { |
| 76 | return false | 78 | return false | ||
| 77 | } | 79 | } | ||
| 78 | } | 80 | } | ||
| @@ -106,8 +108,7 @@ func (c *Core) flashTick() bool { | |||||
| 106 | if p.Hidden || !slices.Contains(w.Columns[col].Windows, p.ID) { | 108 | if p.Hidden || !slices.Contains(w.Columns[col].Windows, p.ID) { | ||
| 107 | continue | 109 | continue | ||
| 108 | } | 110 | } | ||
| 109 | - | x, y := min(rect.X, p.Rect.X), min(rect.Y, p.Rect.Y) | 111 | + | rect = union(rect, p.Rect) |
| 110 | - | rect = Rect{X: x, Y: y, W: max(rect.X+rect.W, p.Rect.X+p.Rect.W) - x, H: max(rect.Y+rect.H, p.Rect.Y+p.Rect.H) - y} | |||
| 111 | } | 112 | } | ||
| 112 | } | 113 | } | ||
| 113 | } | 114 | } | ||
| @@ -169,7 +170,8 @@ func (c *Core) flashVisible(target flashTarget, sc *screen) bool { | |||||
| 169 | return false | 170 | return false | ||
| 170 | } | 171 | } | ||
| 171 | view := sc.mon.viewport() | 172 | view := sc.mon.viewport() | ||
| 172 | - | for _, p := range sc.mon.Layout() { | 173 | + | c.flash.layout = sc.mon.layoutInto(c.flash.layout) |
| 174 | + | for _, p := range c.flash.layout { | |||
| 173 | if p.ID == target.window { | 175 | if p.ID == target.window { | ||
| 174 | return !p.Fullscreen && p.Preview == 0 && view.shows(p) | 176 | return !p.Fullscreen && p.Preview == 0 && view.shows(p) | ||
| 175 | } | 177 | } | ||
| @@ -186,7 +188,11 @@ func (c *Core) flashFor(sc *screen) *ports.FocusFlash { | |||||
| 186 | if color == "" { | 188 | if color == "" { | ||
| 187 | color = c.cfg.Border.Active | 189 | color = c.cfg.Border.Active | ||
| 188 | } | 190 | } | ||
| 189 | - | return &ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Color: color, Alpha: f.value} | 191 | + | sample := ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Color: color, Alpha: f.value} |
| 192 | + | if f.published == nil || *f.published != sample { | |||
| 193 | + | f.published = &ports.FocusFlash{Rect: sample.Rect, Width: sample.Width, Color: sample.Color, Alpha: sample.Alpha} | |||
| 194 | + | } | |||
| 195 | + | return f.published | |||
| 190 | } | 196 | } | ||
| 191 | 197 | ||||
| 192 | func (c *Core) flashing(sc *screen) bool { | 198 | func (c *Core) flashing(sc *screen) bool { | ||
M internal/core/flash_test.go +132 -1
| @@ -140,7 +140,6 @@ func TestFlashSkipsIneligibleTargets(t *testing.T) { | |||
| 140 | 140 | {"overview", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleOverview() }}, | |
| 141 | 141 | {"fullscreen", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleFullscreen() }}, | |
| 142 | 142 | {"gone", func(c *Core, _ *indicatorClock) { c.cur().mon.RemoveWindow(3) }}, | |
| 143 | - | {"just mapped", func(c *Core, clock *indicatorClock) { c.windows.mapped(ports.WindowMapped{ID: 3}, clock.now) }}, | |
| 144 | 143 | } { | |
| 145 | 144 | t.Run(test.name, func(t *testing.T) { | |
| 146 | 145 | c, clock := flashCore(t) | |
| @@ -467,3 +466,135 @@ func TestFlashCooldownRemembersEveryTarget(t *testing.T) { | |||
| 467 | 466 | t.Fatalf("expired cooldown entries retained: %v", c.flash.lastStarts) | |
| 468 | 467 | } | |
| 469 | 468 | } | |
| 469 | + | ||
| 470 | + | // BenchmarkFlashSceneBuild includes revalidation and immutable scene creation. | |
| 471 | + | func BenchmarkFlashSceneBuild(b *testing.B) { | |
| 472 | + | var cfg ports.Config | |
| 473 | + | cfg.Layout.MaxColumns = 2 | |
| 474 | + | cfg.Keyboard.CmdKey = "super" | |
| 475 | + | cfg.Animations.On = true | |
| 476 | + | cfg.Focus.Flash = true | |
| 477 | + | cfg.Focus.FlashOpacity = 0.8 | |
| 478 | + | cfg.Focus.FlashDuration = time.Hour | |
| 479 | + | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{}) | |
| 480 | + | if err != nil { | |
| 481 | + | b.Fatal(err) | |
| 482 | + | } | |
| 483 | + | c.addScreen(ports.OutputInfo{Name: "A", Width: 1000, Height: 600}) | |
| 484 | + | c.windows.mapped(ports.WindowMapped{ID: 1}, time.Now()) | |
| 485 | + | c.cur().mon.AddWindow(1) | |
| 486 | + | c.flash.running = &flashTarget{level: flashWindow, window: 1, output: "A"} | |
| 487 | + | c.flash.start = time.Now() | |
| 488 | + | c.flash.rect = Rect{W: 1000, H: 600} | |
| 489 | + | c.refreshShown() | |
| 490 | + | ctx := context.Background() | |
| 491 | + | b.ReportAllocs() | |
| 492 | + | b.ResetTimer() | |
| 493 | + | for range b.N { | |
| 494 | + | c.refreshShown() | |
| 495 | + | c.advanceFlash(nil) | |
| 496 | + | scene, err := c.sceneFor(ctx, 0, c.cur(), nil, nil) | |
| 497 | + | if err != nil { | |
| 498 | + | b.Fatal(err) | |
| 499 | + | } | |
| 500 | + | c.cur().last = scene | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | func TestFlashEdgeWorkspaceSwitchDoesNotFlash(t *testing.T) { | |
| 505 | + | for _, tc := range []struct { | |
| 506 | + | name string | |
| 507 | + | overflow Overflow | |
| 508 | + | action Action | |
| 509 | + | }{ | |
| 510 | + | {"vertical", OverflowScroll, ActionFocusWindowDown}, | |
| 511 | + | {"horizontal", OverflowCascade, ActionFocusColumnRight}, | |
| 512 | + | } { | |
| 513 | + | t.Run(tc.name, func(t *testing.T) { | |
| 514 | + | c, _ := flashCore(t) | |
| 515 | + | m := c.cur().mon | |
| 516 | + | m.SetOverflow(tc.overflow) | |
| 517 | + | before := m.Current() | |
| 518 | + | m.Focus(1) | |
| 519 | + | m.AddWindow(4) | |
| 520 | + | m.Focus(0) | |
| 521 | + | c.applyAction(tc.action) | |
| 522 | + | if m.Current() == before { | |
| 523 | + | t.Fatal("setup: edge did not switch workspace") | |
| 524 | + | } | |
| 525 | + | if id, _ := m.Focused(); id != 4 { | |
| 526 | + | t.Fatalf("did not land on occupied workspace: %d", id) | |
| 527 | + | } | |
| 528 | + | if c.flash.pending != nil || c.flash.running != nil { | |
| 529 | + | t.Fatal("workspace switch flashed") | |
| 530 | + | } | |
| 531 | + | }) | |
| 532 | + | } | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | func TestFlashExplicitActionOnFreshlyMappedWindow(t *testing.T) { | |
| 536 | + | c, clock := flashCore(t) | |
| 537 | + | c.clientEvent(context.Background(), ports.WindowMapped{ID: 4}) | |
| 538 | + | if c.flash.pending != nil { | |
| 539 | + | t.Fatal("mapping requested flash") | |
| 540 | + | } | |
| 541 | + | c.applyAction(ActionToggleFloating) | |
| 542 | + | if c.flash.pending == nil || c.flash.pending.window != 4 { | |
| 543 | + | t.Fatal("explicit action did not request flash") | |
| 544 | + | } | |
| 545 | + | c.stopAnimations() | |
| 546 | + | clock.now = clock.now.Add(flashSettle) | |
| 547 | + | if !c.flashTick() { | |
| 548 | + | t.Fatal("freshly mapped target rejected") | |
| 549 | + | } | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | func TestFlashPendingTimerStopped(t *testing.T) { | |
| 553 | + | for _, reason := range []string{"replacement", "security", "run exit"} { | |
| 554 | + | t.Run(reason, func(t *testing.T) { | |
| 555 | + | c, clock := flashCore(t) | |
| 556 | + | timer := portsmocks.NewMockTimer(t) | |
| 557 | + | timer.EXPECT().C().Return(make(chan time.Time)).Once() | |
| 558 | + | timer.EXPECT().Stop().Return(true).Times(1) | |
| 559 | + | strictClock := portsmocks.NewMockClock(t) | |
| 560 | + | strictClock.EXPECT().Now().Return(clock.now).Maybe() | |
| 561 | + | strictClock.EXPECT().NewTimer(flashSettle).Return(timer).Once() | |
| 562 | + | c.opts.Clock = strictClock | |
| 563 | + | c.requestFlash(flashWindow, 3) | |
| 564 | + | // Only the pending flash timer belongs to the strict clock. | |
| 565 | + | c.opts.Clock = nil | |
| 566 | + | switch reason { | |
| 567 | + | case "replacement": | |
| 568 | + | c.requestFlash(flashWindow, 2) | |
| 569 | + | c.stopFlash() | |
| 570 | + | case "security": | |
| 571 | + | gate := portsmocks.NewMockSessionSecurity(t) | |
| 572 | + | gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}) | |
| 573 | + | c.opts.Security = gate | |
| 574 | + | c.syncSecurity() | |
| 575 | + | case "run exit": | |
| 576 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 577 | + | cancel() | |
| 578 | + | if err := c.Run(ctx); err != nil { | |
| 579 | + | t.Fatal(err) | |
| 580 | + | } | |
| 581 | + | } | |
| 582 | + | }) | |
| 583 | + | } | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | func TestFlashUnchangedSampleIsReused(t *testing.T) { | |
| 587 | + | c, clock := flashCore(t) | |
| 588 | + | startFlash(t, c, clock, flashWindow, 3) | |
| 589 | + | first := indicatorScene(t, c).Flash | |
| 590 | + | if first == nil { | |
| 591 | + | t.Fatal("missing flash") | |
| 592 | + | } | |
| 593 | + | if next := indicatorScene(t, c).Flash; next != first { | |
| 594 | + | t.Fatal("unchanged immutable sample not reused") | |
| 595 | + | } | |
| 596 | + | clock.now = clock.now.Add(time.Millisecond) | |
| 597 | + | if next := indicatorScene(t, c).Flash; next == first || first.Alpha != c.cfg.Focus.FlashOpacity { | |
| 598 | + | t.Fatal("changed sample mutated prior scene") | |
| 599 | + | } | |
| 600 | + | } | |
| @@ -140,7 +140,6 @@ func TestFlashSkipsIneligibleTargets(t *testing.T) { | |||||
| 140 | {"overview", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleOverview() }}, | 140 | {"overview", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleOverview() }}, | ||
| 141 | {"fullscreen", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleFullscreen() }}, | 141 | {"fullscreen", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleFullscreen() }}, | ||
| 142 | {"gone", func(c *Core, _ *indicatorClock) { c.cur().mon.RemoveWindow(3) }}, | 142 | {"gone", func(c *Core, _ *indicatorClock) { c.cur().mon.RemoveWindow(3) }}, | ||
| 143 | - | {"just mapped", func(c *Core, clock *indicatorClock) { c.windows.mapped(ports.WindowMapped{ID: 3}, clock.now) }}, | |||
| 144 | } { | 143 | } { | ||
| 145 | t.Run(test.name, func(t *testing.T) { | 144 | t.Run(test.name, func(t *testing.T) { | ||
| 146 | c, clock := flashCore(t) | 145 | c, clock := flashCore(t) | ||
| @@ -467,3 +466,135 @@ func TestFlashCooldownRemembersEveryTarget(t *testing.T) { | |||||
| 467 | t.Fatalf("expired cooldown entries retained: %v", c.flash.lastStarts) | 466 | t.Fatalf("expired cooldown entries retained: %v", c.flash.lastStarts) | ||
| 468 | } | 467 | } | ||
| 469 | } | 468 | } | ||
| 469 | + | ||||
| 470 | + | // BenchmarkFlashSceneBuild includes revalidation and immutable scene creation. | |||
| 471 | + | func BenchmarkFlashSceneBuild(b *testing.B) { | |||
| 472 | + | var cfg ports.Config | |||
| 473 | + | cfg.Layout.MaxColumns = 2 | |||
| 474 | + | cfg.Keyboard.CmdKey = "super" | |||
| 475 | + | cfg.Animations.On = true | |||
| 476 | + | cfg.Focus.Flash = true | |||
| 477 | + | cfg.Focus.FlashOpacity = 0.8 | |||
| 478 | + | cfg.Focus.FlashDuration = time.Hour | |||
| 479 | + | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{}) | |||
| 480 | + | if err != nil { | |||
| 481 | + | b.Fatal(err) | |||
| 482 | + | } | |||
| 483 | + | c.addScreen(ports.OutputInfo{Name: "A", Width: 1000, Height: 600}) | |||
| 484 | + | c.windows.mapped(ports.WindowMapped{ID: 1}, time.Now()) | |||
| 485 | + | c.cur().mon.AddWindow(1) | |||
| 486 | + | c.flash.running = &flashTarget{level: flashWindow, window: 1, output: "A"} | |||
| 487 | + | c.flash.start = time.Now() | |||
| 488 | + | c.flash.rect = Rect{W: 1000, H: 600} | |||
| 489 | + | c.refreshShown() | |||
| 490 | + | ctx := context.Background() | |||
| 491 | + | b.ReportAllocs() | |||
| 492 | + | b.ResetTimer() | |||
| 493 | + | for range b.N { | |||
| 494 | + | c.refreshShown() | |||
| 495 | + | c.advanceFlash(nil) | |||
| 496 | + | scene, err := c.sceneFor(ctx, 0, c.cur(), nil, nil) | |||
| 497 | + | if err != nil { | |||
| 498 | + | b.Fatal(err) | |||
| 499 | + | } | |||
| 500 | + | c.cur().last = scene | |||
| 501 | + | } | |||
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | func TestFlashEdgeWorkspaceSwitchDoesNotFlash(t *testing.T) { | |||
| 505 | + | for _, tc := range []struct { | |||
| 506 | + | name string | |||
| 507 | + | overflow Overflow | |||
| 508 | + | action Action | |||
| 509 | + | }{ | |||
| 510 | + | {"vertical", OverflowScroll, ActionFocusWindowDown}, | |||
| 511 | + | {"horizontal", OverflowCascade, ActionFocusColumnRight}, | |||
| 512 | + | } { | |||
| 513 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 514 | + | c, _ := flashCore(t) | |||
| 515 | + | m := c.cur().mon | |||
| 516 | + | m.SetOverflow(tc.overflow) | |||
| 517 | + | before := m.Current() | |||
| 518 | + | m.Focus(1) | |||
| 519 | + | m.AddWindow(4) | |||
| 520 | + | m.Focus(0) | |||
| 521 | + | c.applyAction(tc.action) | |||
| 522 | + | if m.Current() == before { | |||
| 523 | + | t.Fatal("setup: edge did not switch workspace") | |||
| 524 | + | } | |||
| 525 | + | if id, _ := m.Focused(); id != 4 { | |||
| 526 | + | t.Fatalf("did not land on occupied workspace: %d", id) | |||
| 527 | + | } | |||
| 528 | + | if c.flash.pending != nil || c.flash.running != nil { | |||
| 529 | + | t.Fatal("workspace switch flashed") | |||
| 530 | + | } | |||
| 531 | + | }) | |||
| 532 | + | } | |||
| 533 | + | } | |||
| 534 | + | ||||
| 535 | + | func TestFlashExplicitActionOnFreshlyMappedWindow(t *testing.T) { | |||
| 536 | + | c, clock := flashCore(t) | |||
| 537 | + | c.clientEvent(context.Background(), ports.WindowMapped{ID: 4}) | |||
| 538 | + | if c.flash.pending != nil { | |||
| 539 | + | t.Fatal("mapping requested flash") | |||
| 540 | + | } | |||
| 541 | + | c.applyAction(ActionToggleFloating) | |||
| 542 | + | if c.flash.pending == nil || c.flash.pending.window != 4 { | |||
| 543 | + | t.Fatal("explicit action did not request flash") | |||
| 544 | + | } | |||
| 545 | + | c.stopAnimations() | |||
| 546 | + | clock.now = clock.now.Add(flashSettle) | |||
| 547 | + | if !c.flashTick() { | |||
| 548 | + | t.Fatal("freshly mapped target rejected") | |||
| 549 | + | } | |||
| 550 | + | } | |||
| 551 | + | ||||
| 552 | + | func TestFlashPendingTimerStopped(t *testing.T) { | |||
| 553 | + | for _, reason := range []string{"replacement", "security", "run exit"} { | |||
| 554 | + | t.Run(reason, func(t *testing.T) { | |||
| 555 | + | c, clock := flashCore(t) | |||
| 556 | + | timer := portsmocks.NewMockTimer(t) | |||
| 557 | + | timer.EXPECT().C().Return(make(chan time.Time)).Once() | |||
| 558 | + | timer.EXPECT().Stop().Return(true).Times(1) | |||
| 559 | + | strictClock := portsmocks.NewMockClock(t) | |||
| 560 | + | strictClock.EXPECT().Now().Return(clock.now).Maybe() | |||
| 561 | + | strictClock.EXPECT().NewTimer(flashSettle).Return(timer).Once() | |||
| 562 | + | c.opts.Clock = strictClock | |||
| 563 | + | c.requestFlash(flashWindow, 3) | |||
| 564 | + | // Only the pending flash timer belongs to the strict clock. | |||
| 565 | + | c.opts.Clock = nil | |||
| 566 | + | switch reason { | |||
| 567 | + | case "replacement": | |||
| 568 | + | c.requestFlash(flashWindow, 2) | |||
| 569 | + | c.stopFlash() | |||
| 570 | + | case "security": | |||
| 571 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 572 | + | gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}) | |||
| 573 | + | c.opts.Security = gate | |||
| 574 | + | c.syncSecurity() | |||
| 575 | + | case "run exit": | |||
| 576 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 577 | + | cancel() | |||
| 578 | + | if err := c.Run(ctx); err != nil { | |||
| 579 | + | t.Fatal(err) | |||
| 580 | + | } | |||
| 581 | + | } | |||
| 582 | + | }) | |||
| 583 | + | } | |||
| 584 | + | } | |||
| 585 | + | ||||
| 586 | + | func TestFlashUnchangedSampleIsReused(t *testing.T) { | |||
| 587 | + | c, clock := flashCore(t) | |||
| 588 | + | startFlash(t, c, clock, flashWindow, 3) | |||
| 589 | + | first := indicatorScene(t, c).Flash | |||
| 590 | + | if first == nil { | |||
| 591 | + | t.Fatal("missing flash") | |||
| 592 | + | } | |||
| 593 | + | if next := indicatorScene(t, c).Flash; next != first { | |||
| 594 | + | t.Fatal("unchanged immutable sample not reused") | |||
| 595 | + | } | |||
| 596 | + | clock.now = clock.now.Add(time.Millisecond) | |||
| 597 | + | if next := indicatorScene(t, c).Flash; next == first || first.Alpha != c.cfg.Focus.FlashOpacity { | |||
| 598 | + | t.Fatal("changed sample mutated prior scene") | |||
| 599 | + | } | |||
| 600 | + | } | |||