feat(core): add focus flash on window, column and output changes
- commit
- 4e6e4493131d9da2afe195d1741cb28868b15444
- author
- brice · · 2026-10-19 04:37 UTC
- parent
- ade9e82
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +700 -7
M internal/core/action.go +71 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "slices" | |
| 4 | 5 | "strconv" | |
| 5 | 6 | "strings" | |
| 6 | 7 | ||
| @@ -213,6 +214,76 @@ type Effect struct { | |||
| 213 | 214 | // else along the layout's workspace axis; everything else applies to the | |
| 214 | 215 | // focused screen's monitor. | |
| 215 | 216 | func (c *Core) applyAction(a Action) Effect { | |
| 217 | + | if !c.flashOn() { | |
| 218 | + | return c.dispatchAction(a) | |
| 219 | + | } | |
| 220 | + | switch a { | |
| 221 | + | case ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveWindowUp, ActionMoveWindowDown, | |
| 222 | + | ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionToggleFloating, | |
| 223 | + | ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, | |
| 224 | + | ActionFocusColumnLeft, ActionFocusColumnRight, ActionMoveColumnLeft, ActionMoveColumnRight, | |
| 225 | + | ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, | |
| 226 | + | ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown: | |
| 227 | + | default: | |
| 228 | + | return c.dispatchAction(a) | |
| 229 | + | } | |
| 230 | + | // Compare the action's visible result, not publication-time focus: pointer, | |
| 231 | + | // activation and mapping changes must never request a flash. | |
| 232 | + | beforeScreen := c.cur() | |
| 233 | + | beforeWS := beforeScreen.mon.Current() | |
| 234 | + | beforeID, _ := beforeScreen.mon.Focused() | |
| 235 | + | before := beforeScreen.mon.Layout() | |
| 236 | + | wasOverview := beforeScreen.mon.ov.open | |
| 237 | + | wasStashed := beforeWS.stashIndex(beforeID) >= 0 | |
| 238 | + | wasShown := beforeWS.stashShown() | |
| 239 | + | defer func() { | |
| 240 | + | sc := c.cur() | |
| 241 | + | id, _ := sc.mon.Focused() | |
| 242 | + | if sc.mon.ov.open { | |
| 243 | + | return | |
| 244 | + | } | |
| 245 | + | if wasOverview { | |
| 246 | + | if a == ActionToggleOverview && id != 0 { | |
| 247 | + | c.requestFlash(flashWindow, id) | |
| 248 | + | } | |
| 249 | + | return | |
| 250 | + | } | |
| 251 | + | level := flashWindow | |
| 252 | + | switch a { | |
| 253 | + | case ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveWindowUp, ActionMoveWindowDown, | |
| 254 | + | ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionToggleFloating: | |
| 255 | + | case ActionToggleWindowStash: | |
| 256 | + | if !wasStashed { | |
| 257 | + | return | |
| 258 | + | } | |
| 259 | + | case ActionToggleStashVisible: | |
| 260 | + | if wasShown || !sc.mon.Current().stashShown() { | |
| 261 | + | return | |
| 262 | + | } | |
| 263 | + | case ActionFocusColumnLeft, ActionFocusColumnRight, ActionMoveColumnLeft, ActionMoveColumnRight: | |
| 264 | + | level = flashColumn | |
| 265 | + | case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, | |
| 266 | + | ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown: | |
| 267 | + | if sc == beforeScreen { | |
| 268 | + | return | |
| 269 | + | } | |
| 270 | + | c.requestFlash(flashMonitor, 0) | |
| 271 | + | return | |
| 272 | + | default: | |
| 273 | + | return | |
| 274 | + | } | |
| 275 | + | after := sc.mon.Layout() | |
| 276 | + | 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 | |
| 278 | + | }) | |
| 279 | + | if changed && id != 0 { | |
| 280 | + | c.requestFlash(level, id) | |
| 281 | + | } | |
| 282 | + | }() | |
| 283 | + | return c.dispatchAction(a) | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func (c *Core) dispatchAction(a Action) Effect { | |
| 216 | 287 | if e, ok := c.cur().mon.overviewAction(a); ok { | |
| 217 | 288 | return e | |
| 218 | 289 | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "slices" | |||
| 4 | "strconv" | 5 | "strconv" | ||
| 5 | "strings" | 6 | "strings" | ||
| 6 | 7 | ||||
| @@ -213,6 +214,76 @@ type Effect struct { | |||||
| 213 | // else along the layout's workspace axis; everything else applies to the | 214 | // else along the layout's workspace axis; everything else applies to the | ||
| 214 | // focused screen's monitor. | 215 | // focused screen's monitor. | ||
| 215 | func (c *Core) applyAction(a Action) Effect { | 216 | func (c *Core) applyAction(a Action) Effect { | ||
| 217 | + | if !c.flashOn() { | |||
| 218 | + | return c.dispatchAction(a) | |||
| 219 | + | } | |||
| 220 | + | switch a { | |||
| 221 | + | case ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveWindowUp, ActionMoveWindowDown, | |||
| 222 | + | ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionToggleFloating, | |||
| 223 | + | ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, | |||
| 224 | + | ActionFocusColumnLeft, ActionFocusColumnRight, ActionMoveColumnLeft, ActionMoveColumnRight, | |||
| 225 | + | ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, | |||
| 226 | + | ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown: | |||
| 227 | + | default: | |||
| 228 | + | return c.dispatchAction(a) | |||
| 229 | + | } | |||
| 230 | + | // Compare the action's visible result, not publication-time focus: pointer, | |||
| 231 | + | // activation and mapping changes must never request a flash. | |||
| 232 | + | beforeScreen := c.cur() | |||
| 233 | + | beforeWS := beforeScreen.mon.Current() | |||
| 234 | + | beforeID, _ := beforeScreen.mon.Focused() | |||
| 235 | + | before := beforeScreen.mon.Layout() | |||
| 236 | + | wasOverview := beforeScreen.mon.ov.open | |||
| 237 | + | wasStashed := beforeWS.stashIndex(beforeID) >= 0 | |||
| 238 | + | wasShown := beforeWS.stashShown() | |||
| 239 | + | defer func() { | |||
| 240 | + | sc := c.cur() | |||
| 241 | + | id, _ := sc.mon.Focused() | |||
| 242 | + | if sc.mon.ov.open { | |||
| 243 | + | return | |||
| 244 | + | } | |||
| 245 | + | if wasOverview { | |||
| 246 | + | if a == ActionToggleOverview && id != 0 { | |||
| 247 | + | c.requestFlash(flashWindow, id) | |||
| 248 | + | } | |||
| 249 | + | return | |||
| 250 | + | } | |||
| 251 | + | level := flashWindow | |||
| 252 | + | switch a { | |||
| 253 | + | case ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveWindowUp, ActionMoveWindowDown, | |||
| 254 | + | ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionToggleFloating: | |||
| 255 | + | case ActionToggleWindowStash: | |||
| 256 | + | if !wasStashed { | |||
| 257 | + | return | |||
| 258 | + | } | |||
| 259 | + | case ActionToggleStashVisible: | |||
| 260 | + | if wasShown || !sc.mon.Current().stashShown() { | |||
| 261 | + | return | |||
| 262 | + | } | |||
| 263 | + | case ActionFocusColumnLeft, ActionFocusColumnRight, ActionMoveColumnLeft, ActionMoveColumnRight: | |||
| 264 | + | level = flashColumn | |||
| 265 | + | case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, | |||
| 266 | + | ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown: | |||
| 267 | + | if sc == beforeScreen { | |||
| 268 | + | return | |||
| 269 | + | } | |||
| 270 | + | c.requestFlash(flashMonitor, 0) | |||
| 271 | + | return | |||
| 272 | + | default: | |||
| 273 | + | return | |||
| 274 | + | } | |||
| 275 | + | after := sc.mon.Layout() | |||
| 276 | + | 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 | |||
| 278 | + | }) | |||
| 279 | + | if changed && id != 0 { | |||
| 280 | + | c.requestFlash(level, id) | |||
| 281 | + | } | |||
| 282 | + | }() | |||
| 283 | + | return c.dispatchAction(a) | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | func (c *Core) dispatchAction(a Action) Effect { | |||
| 216 | if e, ok := c.cur().mon.overviewAction(a); ok { | 287 | if e, ok := c.cur().mon.overviewAction(a); ok { | ||
| 217 | return e | 288 | return e | ||
| 218 | } | 289 | } | ||
M internal/core/core.go +13 -2
| @@ -155,7 +155,8 @@ type Core struct { | |||
| 155 | 155 | motionTime time.Duration | |
| 156 | 156 | pointerAt [2]float64 | |
| 157 | 157 | // capt is the capture state (capture.go, capindicator.go). | |
| 158 | - | capt captureState | |
| 158 | + | capt captureState | |
| 159 | + | flash focusFlash | |
| 159 | 160 | // switcher is the column switcher in progress: the screen it is on and | |
| 160 | 161 | // the timer that shows its cards (switcher.go). | |
| 161 | 162 | switcher struct { | |
| @@ -674,6 +675,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||
| 674 | 675 | return err | |
| 675 | 676 | } | |
| 676 | 677 | focus := c.keyboardFocus() | |
| 678 | + | c.advanceFlash(only) | |
| 677 | 679 | scenes := make([]ports.Scene, 0, len(c.screens)) | |
| 678 | 680 | // Before the first output (and after the last is unplugged) the | |
| 679 | 681 | // placeholder's scene has no output name: no renderer draws it. | |
| @@ -742,7 +744,7 @@ func (c *Core) step(ctx context.Context, only *screen) error { | |||
| 742 | 744 | // other than only. | |
| 743 | 745 | func (c *Core) animatingOther(only *screen) bool { | |
| 744 | 746 | for _, sc := range c.screens { | |
| 745 | - | if sc != only && sc.springing() { | |
| 747 | + | if sc != only && (sc.springing() || c.flashing(sc)) { | |
| 746 | 748 | return true | |
| 747 | 749 | } | |
| 748 | 750 | } | |
| @@ -1090,6 +1092,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1090 | 1092 | c.syncSecurity() | |
| 1091 | 1093 | defer c.stopFrame() | |
| 1092 | 1094 | defer c.stopCaptureTimer() | |
| 1095 | + | defer c.stopFlashTimer() | |
| 1093 | 1096 | defer c.cancelSwitcher() | |
| 1094 | 1097 | defer c.stopRepeat() | |
| 1095 | 1098 | // Startup commands run once per session. Full launcher queues retain a | |
| @@ -1159,6 +1162,14 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1159 | 1162 | return nil | |
| 1160 | 1163 | } | |
| 1161 | 1164 | continue | |
| 1165 | + | case <-c.flash.timerC: | |
| 1166 | + | if c.securityCheckpoint(ctx) != nil { | |
| 1167 | + | return nil | |
| 1168 | + | } | |
| 1169 | + | if c.flashTick() && c.publish(ctx) != nil { | |
| 1170 | + | return nil | |
| 1171 | + | } | |
| 1172 | + | continue | |
| 1162 | 1173 | case <-c.switcher.timerC: | |
| 1163 | 1174 | if c.securityCheckpoint(ctx) != nil { | |
| 1164 | 1175 | return nil | |
| @@ -155,7 +155,8 @@ type Core struct { | |||||
| 155 | motionTime time.Duration | 155 | motionTime time.Duration | ||
| 156 | pointerAt [2]float64 | 156 | pointerAt [2]float64 | ||
| 157 | // capt is the capture state (capture.go, capindicator.go). | 157 | // capt is the capture state (capture.go, capindicator.go). | ||
| 158 | - | capt captureState | 158 | + | capt captureState |
| 159 | + | flash focusFlash | |||
| 159 | // switcher is the column switcher in progress: the screen it is on and | 160 | // switcher is the column switcher in progress: the screen it is on and | ||
| 160 | // the timer that shows its cards (switcher.go). | 161 | // the timer that shows its cards (switcher.go). | ||
| 161 | switcher struct { | 162 | switcher struct { | ||
| @@ -674,6 +675,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||||
| 674 | return err | 675 | return err | ||
| 675 | } | 676 | } | ||
| 676 | focus := c.keyboardFocus() | 677 | focus := c.keyboardFocus() | ||
| 678 | + | c.advanceFlash(only) | |||
| 677 | scenes := make([]ports.Scene, 0, len(c.screens)) | 679 | scenes := make([]ports.Scene, 0, len(c.screens)) | ||
| 678 | // Before the first output (and after the last is unplugged) the | 680 | // Before the first output (and after the last is unplugged) the | ||
| 679 | // placeholder's scene has no output name: no renderer draws it. | 681 | // placeholder's scene has no output name: no renderer draws it. | ||
| @@ -742,7 +744,7 @@ func (c *Core) step(ctx context.Context, only *screen) error { | |||||
| 742 | // other than only. | 744 | // other than only. | ||
| 743 | func (c *Core) animatingOther(only *screen) bool { | 745 | func (c *Core) animatingOther(only *screen) bool { | ||
| 744 | for _, sc := range c.screens { | 746 | for _, sc := range c.screens { | ||
| 745 | - | if sc != only && sc.springing() { | 747 | + | if sc != only && (sc.springing() || c.flashing(sc)) { |
| 746 | return true | 748 | return true | ||
| 747 | } | 749 | } | ||
| 748 | } | 750 | } | ||
| @@ -1090,6 +1092,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1090 | c.syncSecurity() | 1092 | c.syncSecurity() | ||
| 1091 | defer c.stopFrame() | 1093 | defer c.stopFrame() | ||
| 1092 | defer c.stopCaptureTimer() | 1094 | defer c.stopCaptureTimer() | ||
| 1095 | + | defer c.stopFlashTimer() | |||
| 1093 | defer c.cancelSwitcher() | 1096 | defer c.cancelSwitcher() | ||
| 1094 | defer c.stopRepeat() | 1097 | defer c.stopRepeat() | ||
| 1095 | // Startup commands run once per session. Full launcher queues retain a | 1098 | // Startup commands run once per session. Full launcher queues retain a | ||
| @@ -1159,6 +1162,14 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1159 | return nil | 1162 | return nil | ||
| 1160 | } | 1163 | } | ||
| 1161 | continue | 1164 | continue | ||
| 1165 | + | case <-c.flash.timerC: | |||
| 1166 | + | if c.securityCheckpoint(ctx) != nil { | |||
| 1167 | + | return nil | |||
| 1168 | + | } | |||
| 1169 | + | if c.flashTick() && c.publish(ctx) != nil { | |||
| 1170 | + | return nil | |||
| 1171 | + | } | |||
| 1172 | + | continue | |||
| 1162 | case <-c.switcher.timerC: | 1173 | case <-c.switcher.timerC: | ||
| 1163 | if c.securityCheckpoint(ctx) != nil { | 1174 | if c.securityCheckpoint(ctx) != nil { | ||
| 1164 | return nil | 1175 | return nil | ||
M internal/core/drag.go +1 -0
| @@ -243,6 +243,7 @@ func (c *Core) endDrag(ctx context.Context) error { | |||
| 243 | 243 | now := c.now() | |
| 244 | 244 | shots := c.snapshot(now) | |
| 245 | 245 | c.drop(d.id, t) | |
| 246 | + | c.requestFlash(flashWindow, d.id) | |
| 246 | 247 | c.transition(shots, now) | |
| 247 | 248 | // Slots and guests follow the windows, as after a bind. | |
| 248 | 249 | if err := c.workspaceVisible(ctx, false); err != nil { | |
| @@ -243,6 +243,7 @@ func (c *Core) endDrag(ctx context.Context) error { | |||||
| 243 | now := c.now() | 243 | now := c.now() | ||
| 244 | shots := c.snapshot(now) | 244 | shots := c.snapshot(now) | ||
| 245 | c.drop(d.id, t) | 245 | c.drop(d.id, t) | ||
| 246 | + | c.requestFlash(flashWindow, d.id) | |||
| 246 | c.transition(shots, now) | 247 | c.transition(shots, now) | ||
| 247 | // Slots and guests follow the windows, as after a bind. | 248 | // Slots and guests follow the windows, as after a bind. | ||
| 248 | if err := c.workspaceVisible(ctx, false); err != nil { | 249 | if err := c.workspaceVisible(ctx, false); err != nil { | ||
A internal/core/flash.go +185 -0
| @@ -0,0 +1,185 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type flashLevel uint8 | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | flashWindow flashLevel = iota | |
| 14 | + | flashColumn | |
| 15 | + | flashMonitor | |
| 16 | + | flashSettle = 150 * time.Millisecond | |
| 17 | + | flashRecheck = 50 * time.Millisecond | |
| 18 | + | flashCooldown = time.Second | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | type flashTarget struct { | |
| 22 | + | level flashLevel | |
| 23 | + | window WindowID | |
| 24 | + | output string | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | type focusFlash struct { | |
| 28 | + | pending *flashTarget | |
| 29 | + | since time.Time | |
| 30 | + | timerC <-chan time.Time | |
| 31 | + | timerStop func() bool | |
| 32 | + | running *flashTarget | |
| 33 | + | rect Rect | |
| 34 | + | start time.Time | |
| 35 | + | value float64 // Retained between this output's own flips. | |
| 36 | + | last flashTarget | |
| 37 | + | lastAt time.Time | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (c *Core) flashOn() bool { return c.animOn() && c.cfg.Focus.Flash && !c.protectionRequested() } | |
| 41 | + | ||
| 42 | + | func (c *Core) requestFlash(level flashLevel, window WindowID) { | |
| 43 | + | if !c.flashOn() { | |
| 44 | + | return | |
| 45 | + | } | |
| 46 | + | c.stopFlash() | |
| 47 | + | f := &c.flash | |
| 48 | + | f.pending = &flashTarget{level: level, window: window, output: c.cur().name()} | |
| 49 | + | f.since = c.now() | |
| 50 | + | f.timerC, f.timerStop = newTimer(c.opts.Clock, flashSettle) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (c *Core) flashTick() bool { | |
| 54 | + | f := &c.flash | |
| 55 | + | f.timerC, f.timerStop = nil, nil | |
| 56 | + | if f.pending == nil { | |
| 57 | + | return false | |
| 58 | + | } | |
| 59 | + | target := *f.pending | |
| 60 | + | f.pending = nil | |
| 61 | + | if !c.flashOn() { | |
| 62 | + | return false | |
| 63 | + | } | |
| 64 | + | i := c.screenIndex(target.output) | |
| 65 | + | if i < 0 { | |
| 66 | + | return false | |
| 67 | + | } | |
| 68 | + | sc := c.screens[i] | |
| 69 | + | if sc.mon.ov.open { | |
| 70 | + | return false | |
| 71 | + | } | |
| 72 | + | now := c.now() | |
| 73 | + | if target.level != flashMonitor { | |
| 74 | + | owner, _ := c.screenOf(target.window) | |
| 75 | + | record := c.windows.lookup(target.window) | |
| 76 | + | if owner != sc || record.client.ID == 0 || f.since.Sub(record.mappedAt) < flashSettle { | |
| 77 | + | return false | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | wait := flashSettle - now.Sub(f.since) | |
| 81 | + | if wait > 0 || !sc.settled() { | |
| 82 | + | if wait <= 0 { | |
| 83 | + | wait = flashRecheck | |
| 84 | + | } | |
| 85 | + | f.pending = &target | |
| 86 | + | f.timerC, f.timerStop = newTimer(c.opts.Clock, wait) | |
| 87 | + | return false | |
| 88 | + | } | |
| 89 | + | layout := sc.mon.Layout() | |
| 90 | + | var rect Rect | |
| 91 | + | if target.level == flashMonitor { | |
| 92 | + | o := sc.mon.Output() | |
| 93 | + | rect = Rect{W: o.W, H: o.H} | |
| 94 | + | } else { | |
| 95 | + | at := slices.IndexFunc(layout, func(p Placement) bool { return p.ID == target.window && !p.Hidden && p.Preview == 0 }) | |
| 96 | + | if at < 0 || layout[at].Fullscreen { | |
| 97 | + | return false | |
| 98 | + | } | |
| 99 | + | rect = layout[at].Rect | |
| 100 | + | if target.level == flashColumn { | |
| 101 | + | w := sc.mon.Current() | |
| 102 | + | col := w.columnOf(target.window) | |
| 103 | + | if col < 0 { | |
| 104 | + | return false | |
| 105 | + | } | |
| 106 | + | for _, p := range layout { | |
| 107 | + | if p.Hidden || !slices.Contains(w.Columns[col].Windows, p.ID) { | |
| 108 | + | continue | |
| 109 | + | } | |
| 110 | + | x, y := min(rect.X, p.Rect.X), min(rect.Y, p.Rect.Y) | |
| 111 | + | 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} | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | if target == f.last && now.Sub(f.lastAt) < flashCooldown { | |
| 116 | + | return false | |
| 117 | + | } | |
| 118 | + | f.running, f.rect, f.start, f.last, f.lastAt = &target, rect, now, target, now | |
| 119 | + | f.value = c.cfg.Focus.FlashOpacity | |
| 120 | + | return true | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (c *Core) advanceFlash(only *screen) { | |
| 124 | + | f := &c.flash | |
| 125 | + | if f.running == nil { | |
| 126 | + | return | |
| 127 | + | } | |
| 128 | + | if !c.flashOn() || c.screenIndex(f.running.output) < 0 { | |
| 129 | + | c.stopFlash() | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | if only != nil && only.name() != f.running.output { | |
| 133 | + | return | |
| 134 | + | } | |
| 135 | + | d := c.cfg.Focus.FlashDuration | |
| 136 | + | elapsed := c.now().Sub(f.start) | |
| 137 | + | if d <= 0 || elapsed >= d { | |
| 138 | + | f.running = nil | |
| 139 | + | f.value = 0 | |
| 140 | + | return | |
| 141 | + | } | |
| 142 | + | f.value = c.cfg.Focus.FlashOpacity * (1 - float64(max(elapsed, 0))/float64(d)) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func (c *Core) flashFor(sc *screen) *ports.FocusFlash { | |
| 146 | + | f := &c.flash | |
| 147 | + | if !c.flashing(sc) { | |
| 148 | + | return nil | |
| 149 | + | } | |
| 150 | + | color := c.cfg.Focus.FlashColor | |
| 151 | + | if color == "" { | |
| 152 | + | color = c.cfg.Border.Active | |
| 153 | + | } | |
| 154 | + | return &ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Color: color, Alpha: f.value} | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func (c *Core) flashing(sc *screen) bool { | |
| 158 | + | return c.flash.running != nil && c.flash.running.output == sc.name() | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func (c *Core) stopFlashTimer() { | |
| 162 | + | if c.flash.timerStop != nil { | |
| 163 | + | c.flash.timerStop() | |
| 164 | + | } | |
| 165 | + | c.flash.timerC, c.flash.timerStop = nil, nil | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func (c *Core) stopFlash() { | |
| 169 | + | c.stopFlashTimer() | |
| 170 | + | c.flash.pending, c.flash.running, c.flash.value = nil, nil, 0 | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func (m *Monitor) settled() bool { | |
| 174 | + | if m.switchView.busy() { | |
| 175 | + | return false | |
| 176 | + | } | |
| 177 | + | for w := range m.all() { | |
| 178 | + | if w.view.busy() || w.stashView.busy() { | |
| 179 | + | return false | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | return true | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func (s *screen) settled() bool { return s.mon.settled() && len(s.rects) == 0 } | |
| @@ -0,0 +1,185 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "slices" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | type flashLevel uint8 | |||
| 11 | + | ||||
| 12 | + | const ( | |||
| 13 | + | flashWindow flashLevel = iota | |||
| 14 | + | flashColumn | |||
| 15 | + | flashMonitor | |||
| 16 | + | flashSettle = 150 * time.Millisecond | |||
| 17 | + | flashRecheck = 50 * time.Millisecond | |||
| 18 | + | flashCooldown = time.Second | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | type flashTarget struct { | |||
| 22 | + | level flashLevel | |||
| 23 | + | window WindowID | |||
| 24 | + | output string | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | type focusFlash struct { | |||
| 28 | + | pending *flashTarget | |||
| 29 | + | since time.Time | |||
| 30 | + | timerC <-chan time.Time | |||
| 31 | + | timerStop func() bool | |||
| 32 | + | running *flashTarget | |||
| 33 | + | rect Rect | |||
| 34 | + | start time.Time | |||
| 35 | + | value float64 // Retained between this output's own flips. | |||
| 36 | + | last flashTarget | |||
| 37 | + | lastAt time.Time | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func (c *Core) flashOn() bool { return c.animOn() && c.cfg.Focus.Flash && !c.protectionRequested() } | |||
| 41 | + | ||||
| 42 | + | func (c *Core) requestFlash(level flashLevel, window WindowID) { | |||
| 43 | + | if !c.flashOn() { | |||
| 44 | + | return | |||
| 45 | + | } | |||
| 46 | + | c.stopFlash() | |||
| 47 | + | f := &c.flash | |||
| 48 | + | f.pending = &flashTarget{level: level, window: window, output: c.cur().name()} | |||
| 49 | + | f.since = c.now() | |||
| 50 | + | f.timerC, f.timerStop = newTimer(c.opts.Clock, flashSettle) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func (c *Core) flashTick() bool { | |||
| 54 | + | f := &c.flash | |||
| 55 | + | f.timerC, f.timerStop = nil, nil | |||
| 56 | + | if f.pending == nil { | |||
| 57 | + | return false | |||
| 58 | + | } | |||
| 59 | + | target := *f.pending | |||
| 60 | + | f.pending = nil | |||
| 61 | + | if !c.flashOn() { | |||
| 62 | + | return false | |||
| 63 | + | } | |||
| 64 | + | i := c.screenIndex(target.output) | |||
| 65 | + | if i < 0 { | |||
| 66 | + | return false | |||
| 67 | + | } | |||
| 68 | + | sc := c.screens[i] | |||
| 69 | + | if sc.mon.ov.open { | |||
| 70 | + | return false | |||
| 71 | + | } | |||
| 72 | + | now := c.now() | |||
| 73 | + | if target.level != flashMonitor { | |||
| 74 | + | owner, _ := c.screenOf(target.window) | |||
| 75 | + | record := c.windows.lookup(target.window) | |||
| 76 | + | if owner != sc || record.client.ID == 0 || f.since.Sub(record.mappedAt) < flashSettle { | |||
| 77 | + | return false | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | wait := flashSettle - now.Sub(f.since) | |||
| 81 | + | if wait > 0 || !sc.settled() { | |||
| 82 | + | if wait <= 0 { | |||
| 83 | + | wait = flashRecheck | |||
| 84 | + | } | |||
| 85 | + | f.pending = &target | |||
| 86 | + | f.timerC, f.timerStop = newTimer(c.opts.Clock, wait) | |||
| 87 | + | return false | |||
| 88 | + | } | |||
| 89 | + | layout := sc.mon.Layout() | |||
| 90 | + | var rect Rect | |||
| 91 | + | if target.level == flashMonitor { | |||
| 92 | + | o := sc.mon.Output() | |||
| 93 | + | rect = Rect{W: o.W, H: o.H} | |||
| 94 | + | } else { | |||
| 95 | + | at := slices.IndexFunc(layout, func(p Placement) bool { return p.ID == target.window && !p.Hidden && p.Preview == 0 }) | |||
| 96 | + | if at < 0 || layout[at].Fullscreen { | |||
| 97 | + | return false | |||
| 98 | + | } | |||
| 99 | + | rect = layout[at].Rect | |||
| 100 | + | if target.level == flashColumn { | |||
| 101 | + | w := sc.mon.Current() | |||
| 102 | + | col := w.columnOf(target.window) | |||
| 103 | + | if col < 0 { | |||
| 104 | + | return false | |||
| 105 | + | } | |||
| 106 | + | for _, p := range layout { | |||
| 107 | + | if p.Hidden || !slices.Contains(w.Columns[col].Windows, p.ID) { | |||
| 108 | + | continue | |||
| 109 | + | } | |||
| 110 | + | x, y := min(rect.X, p.Rect.X), min(rect.Y, p.Rect.Y) | |||
| 111 | + | 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} | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | if target == f.last && now.Sub(f.lastAt) < flashCooldown { | |||
| 116 | + | return false | |||
| 117 | + | } | |||
| 118 | + | f.running, f.rect, f.start, f.last, f.lastAt = &target, rect, now, target, now | |||
| 119 | + | f.value = c.cfg.Focus.FlashOpacity | |||
| 120 | + | return true | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func (c *Core) advanceFlash(only *screen) { | |||
| 124 | + | f := &c.flash | |||
| 125 | + | if f.running == nil { | |||
| 126 | + | return | |||
| 127 | + | } | |||
| 128 | + | if !c.flashOn() || c.screenIndex(f.running.output) < 0 { | |||
| 129 | + | c.stopFlash() | |||
| 130 | + | return | |||
| 131 | + | } | |||
| 132 | + | if only != nil && only.name() != f.running.output { | |||
| 133 | + | return | |||
| 134 | + | } | |||
| 135 | + | d := c.cfg.Focus.FlashDuration | |||
| 136 | + | elapsed := c.now().Sub(f.start) | |||
| 137 | + | if d <= 0 || elapsed >= d { | |||
| 138 | + | f.running = nil | |||
| 139 | + | f.value = 0 | |||
| 140 | + | return | |||
| 141 | + | } | |||
| 142 | + | f.value = c.cfg.Focus.FlashOpacity * (1 - float64(max(elapsed, 0))/float64(d)) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func (c *Core) flashFor(sc *screen) *ports.FocusFlash { | |||
| 146 | + | f := &c.flash | |||
| 147 | + | if !c.flashing(sc) { | |||
| 148 | + | return nil | |||
| 149 | + | } | |||
| 150 | + | color := c.cfg.Focus.FlashColor | |||
| 151 | + | if color == "" { | |||
| 152 | + | color = c.cfg.Border.Active | |||
| 153 | + | } | |||
| 154 | + | return &ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Color: color, Alpha: f.value} | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func (c *Core) flashing(sc *screen) bool { | |||
| 158 | + | return c.flash.running != nil && c.flash.running.output == sc.name() | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func (c *Core) stopFlashTimer() { | |||
| 162 | + | if c.flash.timerStop != nil { | |||
| 163 | + | c.flash.timerStop() | |||
| 164 | + | } | |||
| 165 | + | c.flash.timerC, c.flash.timerStop = nil, nil | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func (c *Core) stopFlash() { | |||
| 169 | + | c.stopFlashTimer() | |||
| 170 | + | c.flash.pending, c.flash.running, c.flash.value = nil, nil, 0 | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func (m *Monitor) settled() bool { | |||
| 174 | + | if m.switchView.busy() { | |||
| 175 | + | return false | |||
| 176 | + | } | |||
| 177 | + | for w := range m.all() { | |||
| 178 | + | if w.view.busy() || w.stashView.busy() { | |||
| 179 | + | return false | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | return true | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func (s *screen) settled() bool { return s.mon.settled() && len(s.rects) == 0 } | |||
A internal/core/flash_test.go +399 -0
| @@ -0,0 +1,399 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func flashCore(t *testing.T) (*Core, *indicatorClock) { | |
| 13 | + | t.Helper() | |
| 14 | + | c, clock := indicatorCore(t) | |
| 15 | + | c.cfg.Animations.On = true | |
| 16 | + | c.cfg.Focus.Flash = true | |
| 17 | + | c.cfg.Focus.FlashWidth = 3 | |
| 18 | + | c.cfg.Focus.FlashOpacity = 0.8 | |
| 19 | + | c.cfg.Focus.FlashDuration = 400 * time.Millisecond | |
| 20 | + | c.cfg.Border.Active = "#abcdef" | |
| 21 | + | for id := WindowID(1); id <= 3; id++ { | |
| 22 | + | c.windows.mapped(ports.WindowMapped{ID: id}, clock.now.Add(-time.Minute)) | |
| 23 | + | c.cur().mon.AddWindow(id) | |
| 24 | + | } | |
| 25 | + | return c, clock | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func startFlash(t *testing.T, c *Core, clock *indicatorClock, level flashLevel, id WindowID) { | |
| 29 | + | t.Helper() | |
| 30 | + | c.requestFlash(level, id) | |
| 31 | + | clock.now = clock.now.Add(flashSettle) | |
| 32 | + | if !c.flashTick() { | |
| 33 | + | t.Fatal("flash did not start") | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestFlashFadeAndOutputFrames(t *testing.T) { | |
| 38 | + | c, clock := flashCore(t) | |
| 39 | + | a := c.cur() | |
| 40 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300}) | |
| 41 | + | b := c.screens[1] | |
| 42 | + | startFlash(t, c, clock, flashWindow, 3) | |
| 43 | + | first := stepScenes(t, c, a) | |
| 44 | + | f := first["A"].Flash | |
| 45 | + | if f == nil || f.Alpha != 0.8 || f.Color != c.cfg.Border.Active || f.Width != 3 { | |
| 46 | + | t.Fatalf("flash %+v", f) | |
| 47 | + | } | |
| 48 | + | if first["B"].Flash != nil || !first["A"].Animating || !c.animating() { | |
| 49 | + | t.Fatal("output/animation routing") | |
| 50 | + | } | |
| 51 | + | clock.now = clock.now.Add(200 * time.Millisecond) | |
| 52 | + | other := stepScenes(t, c, b) | |
| 53 | + | if other["A"].Seq != first["A"].Seq || *other["A"].Flash != *f { | |
| 54 | + | t.Fatal("other output advanced flash") | |
| 55 | + | } | |
| 56 | + | half := stepScenes(t, c, a) | |
| 57 | + | if half["A"].Flash.Alpha != 0.4 { | |
| 58 | + | t.Fatalf("half alpha %v", half["A"].Flash.Alpha) | |
| 59 | + | } | |
| 60 | + | clock.now = clock.now.Add(200 * time.Millisecond) | |
| 61 | + | end := stepScenes(t, c, a) | |
| 62 | + | if end["A"].Flash != nil || end["A"].Animating || c.animating() { | |
| 63 | + | t.Fatal("flash did not stop frames") | |
| 64 | + | } | |
| 65 | + | if end["A"].Seq == half["A"].Seq { | |
| 66 | + | t.Fatal("end retained animating sequence") | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestFlashDebounceCooldownAndReplacement(t *testing.T) { | |
| 71 | + | c, clock := flashCore(t) | |
| 72 | + | c.requestFlash(flashWindow, 1) | |
| 73 | + | clock.now = clock.now.Add(100 * time.Millisecond) | |
| 74 | + | c.requestFlash(flashWindow, 2) | |
| 75 | + | clock.now = clock.now.Add(50 * time.Millisecond) | |
| 76 | + | if c.flashTick() || c.flash.pending == nil { | |
| 77 | + | t.Fatal("early stale timer started flash") | |
| 78 | + | } | |
| 79 | + | clock.now = clock.now.Add(100 * time.Millisecond) | |
| 80 | + | if !c.flashTick() || c.flash.running.window != 2 { | |
| 81 | + | t.Fatal("last request did not win") | |
| 82 | + | } | |
| 83 | + | c.requestFlash(flashWindow, 2) | |
| 84 | + | if c.flash.running != nil { | |
| 85 | + | t.Fatal("request did not replace running flash") | |
| 86 | + | } | |
| 87 | + | clock.now = clock.now.Add(flashSettle) | |
| 88 | + | if c.flashTick() { | |
| 89 | + | t.Fatal("cooldown ignored") | |
| 90 | + | } | |
| 91 | + | clock.now = clock.now.Add(time.Second) | |
| 92 | + | startFlash(t, c, clock, flashWindow, 2) | |
| 93 | + | c.requestFlash(flashColumn, 2) | |
| 94 | + | clock.now = clock.now.Add(flashSettle) | |
| 95 | + | if !c.flashTick() || c.flash.running.level != flashColumn { | |
| 96 | + | t.Fatal("level not part of cooldown key") | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestFlashSettlesAndResolvesFinalRect(t *testing.T) { | |
| 101 | + | for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} { | |
| 102 | + | t.Run(string(rune('0'+level)), func(t *testing.T) { | |
| 103 | + | c, clock := flashCore(t) | |
| 104 | + | w := c.cur().mon.Current() | |
| 105 | + | c.requestFlash(level, 3) | |
| 106 | + | w.view.off = 20 | |
| 107 | + | clock.now = clock.now.Add(flashSettle) | |
| 108 | + | if c.flashTick() || c.flash.timerC == nil { | |
| 109 | + | t.Fatal("did not wait for layout") | |
| 110 | + | } | |
| 111 | + | w.view.stop() | |
| 112 | + | w.MoveColumn(-1) | |
| 113 | + | clock.now = clock.now.Add(flashRecheck) | |
| 114 | + | if !c.flashTick() { | |
| 115 | + | t.Fatal("did not start after settling") | |
| 116 | + | } | |
| 117 | + | want := Rect{W: 300, H: 200} | |
| 118 | + | if level != flashMonitor { | |
| 119 | + | for _, p := range c.cur().mon.Layout() { | |
| 120 | + | if p.ID == 3 { | |
| 121 | + | want = p.Rect | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | if c.flash.rect != want { | |
| 126 | + | t.Fatalf("rect %+v want %+v", c.flash.rect, want) | |
| 127 | + | } | |
| 128 | + | }) | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestFlashSkipsIneligibleTargets(t *testing.T) { | |
| 133 | + | for _, test := range []struct { | |
| 134 | + | name string | |
| 135 | + | change func(*Core, *indicatorClock) | |
| 136 | + | }{ | |
| 137 | + | {"disabled", func(c *Core, _ *indicatorClock) { c.cfg.Focus.Flash = false }}, | |
| 138 | + | {"animations off", func(c *Core, _ *indicatorClock) { c.cfg.Animations.On = false }}, | |
| 139 | + | {"protected", func(c *Core, _ *indicatorClock) { c.security.Protected = true }}, | |
| 140 | + | {"overview", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleOverview() }}, | |
| 141 | + | {"fullscreen", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleFullscreen() }}, | |
| 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 | + | } { | |
| 145 | + | t.Run(test.name, func(t *testing.T) { | |
| 146 | + | c, clock := flashCore(t) | |
| 147 | + | test.change(c, clock) | |
| 148 | + | c.requestFlash(flashWindow, 3) | |
| 149 | + | clock.now = clock.now.Add(flashSettle) | |
| 150 | + | if c.flashTick() || c.flash.running != nil { | |
| 151 | + | t.Fatal("ineligible flash") | |
| 152 | + | } | |
| 153 | + | }) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func TestFlashStopAndColor(t *testing.T) { | |
| 158 | + | c, clock := flashCore(t) | |
| 159 | + | c.cfg.Focus.FlashColor = "#123456" | |
| 160 | + | c.cfg.Animations.Slowdown = 10 | |
| 161 | + | startFlash(t, c, clock, flashWindow, 3) | |
| 162 | + | if s := indicatorScene(t, c); s.Flash.Color != "#123456" { | |
| 163 | + | t.Fatal("configured color ignored") | |
| 164 | + | } | |
| 165 | + | clock.now = clock.now.Add(c.cfg.Focus.FlashDuration) | |
| 166 | + | if indicatorScene(t, c).Flash != nil { | |
| 167 | + | t.Fatal("duration scaled by slowdown") | |
| 168 | + | } | |
| 169 | + | c.requestFlash(flashWindow, 2) | |
| 170 | + | c.stopFlash() | |
| 171 | + | if c.flash.timerC != nil || c.flash.pending != nil || c.flash.running != nil { | |
| 172 | + | t.Fatal("stop retained state") | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestFlashActionTriggers(t *testing.T) { | |
| 177 | + | for _, test := range []struct { | |
| 178 | + | action Action | |
| 179 | + | level flashLevel | |
| 180 | + | setup func(*Core) | |
| 181 | + | }{ | |
| 182 | + | {ActionFocusColumnLeft, flashColumn, nil}, | |
| 183 | + | {ActionFocusColumnRight, flashColumn, func(c *Core) { c.cur().mon.Current().FocusID(1) }}, | |
| 184 | + | {ActionMoveColumnLeft, flashColumn, nil}, | |
| 185 | + | {ActionMoveColumnRight, flashColumn, func(c *Core) { c.cur().mon.Current().FocusID(1) }}, | |
| 186 | + | {ActionConsumeOrExpelLeft, flashWindow, nil}, | |
| 187 | + | {ActionConsumeOrExpelRight, flashWindow, func(c *Core) { c.cur().mon.Current().FocusID(1) }}, | |
| 188 | + | {ActionToggleFloating, flashWindow, nil}, | |
| 189 | + | {ActionFocusWindowUp, flashWindow, func(c *Core) { c.cur().mon.Current().ConsumeOrExpel(-1) }}, | |
| 190 | + | {ActionFocusWindowDown, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ConsumeOrExpel(-1); w.FocusWindow(-1) }}, | |
| 191 | + | {ActionMoveWindowUp, flashWindow, func(c *Core) { c.cur().mon.Current().ConsumeOrExpel(-1) }}, | |
| 192 | + | {ActionMoveWindowDown, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ConsumeOrExpel(-1); w.FocusWindow(-1) }}, | |
| 193 | + | {ActionToggleWindowStash, flashWindow, func(c *Core) { c.cur().mon.Current().ToggleWindowStash() }}, | |
| 194 | + | {ActionToggleStashVisible, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ToggleWindowStash(); w.ToggleStashVisible() }}, | |
| 195 | + | {ActionToggleOverview, flashWindow, func(c *Core) { c.cur().mon.ToggleOverview() }}, | |
| 196 | + | } { | |
| 197 | + | t.Run(string(test.action), func(t *testing.T) { | |
| 198 | + | c, clock := flashCore(t) | |
| 199 | + | if test.setup != nil { | |
| 200 | + | test.setup(c) | |
| 201 | + | } | |
| 202 | + | c.applyAction(test.action) | |
| 203 | + | if c.flash.pending == nil || c.flash.pending.level != test.level { | |
| 204 | + | t.Fatalf("pending %+v want level %v", c.flash.pending, test.level) | |
| 205 | + | } | |
| 206 | + | target := *c.flash.pending | |
| 207 | + | clock.now = clock.now.Add(flashSettle) | |
| 208 | + | if !c.flashTick() { | |
| 209 | + | t.Fatal("action flash did not start") | |
| 210 | + | } | |
| 211 | + | var want Rect | |
| 212 | + | for _, p := range c.cur().mon.Layout() { | |
| 213 | + | if p.ID == target.window { | |
| 214 | + | want = p.Rect | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | if c.flash.rect != want { | |
| 218 | + | t.Fatalf("action rect %+v want %+v", c.flash.rect, want) | |
| 219 | + | } | |
| 220 | + | }) | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func TestFlashExcludedActions(t *testing.T) { | |
| 225 | + | for _, action := range []Action{ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveWindowToWorkspaceNext, ActionMoveColumnToWorkspaceNext, ActionToggleFullscreen, ActionMaximizeColumn, ActionCycleColumnWidth, ActionFocusColumnRight, ActionMoveColumnRight, ActionMoveWindowUp} { | |
| 226 | + | t.Run(string(action), func(t *testing.T) { | |
| 227 | + | c, _ := flashCore(t) | |
| 228 | + | c.applyAction(action) | |
| 229 | + | if c.flash.pending != nil { | |
| 230 | + | t.Fatalf("unexpected flash %+v", c.flash.pending) | |
| 231 | + | } | |
| 232 | + | }) | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func TestFlashMonitorTriggers(t *testing.T) { | |
| 237 | + | for _, action := range []Action{ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown} { | |
| 238 | + | t.Run(string(action), func(t *testing.T) { | |
| 239 | + | c, clock := flashCore(t) | |
| 240 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300}) | |
| 241 | + | a, b := c.screens[0], c.screens[1] | |
| 242 | + | a.x, a.y = 0, 0 | |
| 243 | + | b.x, b.y = 300, 0 | |
| 244 | + | switch monitorDirection(action) { | |
| 245 | + | case dirLeft: | |
| 246 | + | b.x = -400 | |
| 247 | + | case dirUp: | |
| 248 | + | b.x, b.y = 0, -300 | |
| 249 | + | case dirDown: | |
| 250 | + | b.x, b.y = 0, 200 | |
| 251 | + | } | |
| 252 | + | c.applyAction(action) | |
| 253 | + | if c.flash.pending == nil || c.flash.pending.level != flashMonitor || c.flash.pending.output != "B" { | |
| 254 | + | t.Fatalf("destination request %+v", c.flash.pending) | |
| 255 | + | } | |
| 256 | + | clock.now = clock.now.Add(flashSettle) | |
| 257 | + | if !c.flashTick() || c.flash.rect != (Rect{W: 400, H: 300}) { | |
| 258 | + | t.Fatalf("monitor rect %+v", c.flash.rect) | |
| 259 | + | } | |
| 260 | + | }) | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func TestFlashColumnUnion(t *testing.T) { | |
| 265 | + | c, clock := flashCore(t) | |
| 266 | + | w := c.cur().mon.Current() | |
| 267 | + | w.ConsumeOrExpel(-1) | |
| 268 | + | startFlash(t, c, clock, flashColumn, 3) | |
| 269 | + | col := w.Columns[w.columnOf(3)] | |
| 270 | + | var area int | |
| 271 | + | for _, p := range c.cur().mon.Layout() { | |
| 272 | + | for _, id := range col.Windows { | |
| 273 | + | if id == p.ID { | |
| 274 | + | area += p.Rect.W * p.Rect.H | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | if c.flash.rect.W*c.flash.rect.H != area { | |
| 279 | + | t.Fatalf("union %+v does not cover column area %d", c.flash.rect, area) | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func TestFlashGestureLanding(t *testing.T) { | |
| 284 | + | c, _ := flashCore(t) | |
| 285 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |
| 286 | + | c.swipeUpdate(ports.SwipeUpdate{DX: -800, Time: 50 * time.Millisecond}) | |
| 287 | + | c.swipeEnd(ports.SwipeEnd{Time: 60 * time.Millisecond}) | |
| 288 | + | if c.flash.pending == nil || c.flash.pending.level != flashColumn { | |
| 289 | + | t.Fatalf("landing request %+v", c.flash.pending) | |
| 290 | + | } | |
| 291 | + | c.stopFlash() | |
| 292 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3, Time: 100 * time.Millisecond}) | |
| 293 | + | c.swipeUpdate(ports.SwipeUpdate{DX: 800, Time: 150 * time.Millisecond}) | |
| 294 | + | c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: 160 * time.Millisecond}) | |
| 295 | + | if c.flash.pending != nil { | |
| 296 | + | t.Fatal("cancelled gesture flashed") | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | func TestFlashLockClearsPendingAndRunning(t *testing.T) { | |
| 301 | + | for _, running := range []bool{false, true} { | |
| 302 | + | c, clock := flashCore(t) | |
| 303 | + | state := ports.SecurityState{} | |
| 304 | + | gate := portsmocks.NewMockSessionSecurity(t) | |
| 305 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }) | |
| 306 | + | c.opts.Security = gate | |
| 307 | + | if running { | |
| 308 | + | startFlash(t, c, clock, flashWindow, 3) | |
| 309 | + | } else { | |
| 310 | + | c.requestFlash(flashWindow, 3) | |
| 311 | + | } | |
| 312 | + | state = ports.SecurityState{Generation: 1, Protected: true} | |
| 313 | + | if !c.syncSecurity() { | |
| 314 | + | t.Fatal("security did not change") | |
| 315 | + | } | |
| 316 | + | if c.flash.pending != nil || c.flash.running != nil || c.flash.timerC != nil { | |
| 317 | + | t.Fatal("lock retained flash") | |
| 318 | + | } | |
| 319 | + | state = ports.SecurityState{Generation: 2} | |
| 320 | + | c.syncSecurity() | |
| 321 | + | if indicatorScene(t, c).Flash != nil || c.flash.pending != nil { | |
| 322 | + | t.Fatal("unlock requested flash") | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | func TestFlashOverviewClickAndKeyboard(t *testing.T) { | |
| 328 | + | for _, click := range []bool{true, false} { | |
| 329 | + | c, _ := flashCore(t) | |
| 330 | + | c.cur().mon.ToggleOverview() | |
| 331 | + | scene := indicatorScene(t, c) | |
| 332 | + | if click { | |
| 333 | + | var rect Rect | |
| 334 | + | for _, p := range scene.Windows { | |
| 335 | + | if p.ID == 2 { | |
| 336 | + | rect = p.Rect | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | c.cursorX, c.cursorY = float64(rect.X+rect.W/2), float64(rect.Y+rect.H/2) | |
| 340 | + | picked, err := c.overviewClick(context.Background()) | |
| 341 | + | if err != nil || !picked { | |
| 342 | + | t.Fatalf("click %v %v", picked, err) | |
| 343 | + | } | |
| 344 | + | } else { | |
| 345 | + | if err := c.keyEvent(context.Background(), ports.KeyEvent{Keysym: "Return", Pressed: true}); err != nil { | |
| 346 | + | t.Fatal(err) | |
| 347 | + | } | |
| 348 | + | } | |
| 349 | + | if c.flash.pending == nil || c.flash.pending.level != flashWindow { | |
| 350 | + | t.Fatalf("overview request %+v", c.flash.pending) | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | func TestFlashTileDrop(t *testing.T) { | |
| 356 | + | c, _ := flashCore(t) | |
| 357 | + | scene := indicatorScene(t, c) | |
| 358 | + | var rect Rect | |
| 359 | + | for _, p := range scene.Windows { | |
| 360 | + | if p.ID == 2 { | |
| 361 | + | rect = p.Rect | |
| 362 | + | } | |
| 363 | + | } | |
| 364 | + | c.cursorX, c.cursorY = float64(rect.X+rect.W/2), float64(rect.Y+rect.H/2) | |
| 365 | + | c.drag = &dragState{id: 3} | |
| 366 | + | if err := c.endDrag(context.Background()); err != nil { | |
| 367 | + | t.Fatal(err) | |
| 368 | + | } | |
| 369 | + | if c.flash.pending == nil || c.flash.pending.level != flashWindow || c.flash.pending.window != 3 { | |
| 370 | + | t.Fatalf("drop request %+v", c.flash.pending) | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | func TestFlashNoImplicitFocusTriggers(t *testing.T) { | |
| 375 | + | c, _ := flashCore(t) | |
| 376 | + | ctx := context.Background() | |
| 377 | + | indicatorScene(t, c) | |
| 378 | + | if c.flash.pending != nil { | |
| 379 | + | t.Fatal("startup publication flashed") | |
| 380 | + | } | |
| 381 | + | for _, ev := range []ports.ClientEvent{ports.WindowMapped{ID: 4}, ports.WindowActivate{ID: 1}} { | |
| 382 | + | if _, stop := c.clientEvent(ctx, ev); stop { | |
| 383 | + | t.Fatalf("event %T stopped", ev) | |
| 384 | + | } | |
| 385 | + | if c.flash.pending != nil { | |
| 386 | + | t.Fatalf("event %T flashed", ev) | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | indicatorScene(t, c) | |
| 390 | + | if err := c.pointerMotion(ctx, ports.PointerMotion{X: 50, Y: 50}); err != nil { | |
| 391 | + | t.Fatal(err) | |
| 392 | + | } | |
| 393 | + | if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil { | |
| 394 | + | t.Fatal(err) | |
| 395 | + | } | |
| 396 | + | if c.flash.pending != nil { | |
| 397 | + | t.Fatal("pointer focus flashed") | |
| 398 | + | } | |
| 399 | + | } | |
| @@ -0,0 +1,399 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func flashCore(t *testing.T) (*Core, *indicatorClock) { | |||
| 13 | + | t.Helper() | |||
| 14 | + | c, clock := indicatorCore(t) | |||
| 15 | + | c.cfg.Animations.On = true | |||
| 16 | + | c.cfg.Focus.Flash = true | |||
| 17 | + | c.cfg.Focus.FlashWidth = 3 | |||
| 18 | + | c.cfg.Focus.FlashOpacity = 0.8 | |||
| 19 | + | c.cfg.Focus.FlashDuration = 400 * time.Millisecond | |||
| 20 | + | c.cfg.Border.Active = "#abcdef" | |||
| 21 | + | for id := WindowID(1); id <= 3; id++ { | |||
| 22 | + | c.windows.mapped(ports.WindowMapped{ID: id}, clock.now.Add(-time.Minute)) | |||
| 23 | + | c.cur().mon.AddWindow(id) | |||
| 24 | + | } | |||
| 25 | + | return c, clock | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func startFlash(t *testing.T, c *Core, clock *indicatorClock, level flashLevel, id WindowID) { | |||
| 29 | + | t.Helper() | |||
| 30 | + | c.requestFlash(level, id) | |||
| 31 | + | clock.now = clock.now.Add(flashSettle) | |||
| 32 | + | if !c.flashTick() { | |||
| 33 | + | t.Fatal("flash did not start") | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestFlashFadeAndOutputFrames(t *testing.T) { | |||
| 38 | + | c, clock := flashCore(t) | |||
| 39 | + | a := c.cur() | |||
| 40 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300}) | |||
| 41 | + | b := c.screens[1] | |||
| 42 | + | startFlash(t, c, clock, flashWindow, 3) | |||
| 43 | + | first := stepScenes(t, c, a) | |||
| 44 | + | f := first["A"].Flash | |||
| 45 | + | if f == nil || f.Alpha != 0.8 || f.Color != c.cfg.Border.Active || f.Width != 3 { | |||
| 46 | + | t.Fatalf("flash %+v", f) | |||
| 47 | + | } | |||
| 48 | + | if first["B"].Flash != nil || !first["A"].Animating || !c.animating() { | |||
| 49 | + | t.Fatal("output/animation routing") | |||
| 50 | + | } | |||
| 51 | + | clock.now = clock.now.Add(200 * time.Millisecond) | |||
| 52 | + | other := stepScenes(t, c, b) | |||
| 53 | + | if other["A"].Seq != first["A"].Seq || *other["A"].Flash != *f { | |||
| 54 | + | t.Fatal("other output advanced flash") | |||
| 55 | + | } | |||
| 56 | + | half := stepScenes(t, c, a) | |||
| 57 | + | if half["A"].Flash.Alpha != 0.4 { | |||
| 58 | + | t.Fatalf("half alpha %v", half["A"].Flash.Alpha) | |||
| 59 | + | } | |||
| 60 | + | clock.now = clock.now.Add(200 * time.Millisecond) | |||
| 61 | + | end := stepScenes(t, c, a) | |||
| 62 | + | if end["A"].Flash != nil || end["A"].Animating || c.animating() { | |||
| 63 | + | t.Fatal("flash did not stop frames") | |||
| 64 | + | } | |||
| 65 | + | if end["A"].Seq == half["A"].Seq { | |||
| 66 | + | t.Fatal("end retained animating sequence") | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestFlashDebounceCooldownAndReplacement(t *testing.T) { | |||
| 71 | + | c, clock := flashCore(t) | |||
| 72 | + | c.requestFlash(flashWindow, 1) | |||
| 73 | + | clock.now = clock.now.Add(100 * time.Millisecond) | |||
| 74 | + | c.requestFlash(flashWindow, 2) | |||
| 75 | + | clock.now = clock.now.Add(50 * time.Millisecond) | |||
| 76 | + | if c.flashTick() || c.flash.pending == nil { | |||
| 77 | + | t.Fatal("early stale timer started flash") | |||
| 78 | + | } | |||
| 79 | + | clock.now = clock.now.Add(100 * time.Millisecond) | |||
| 80 | + | if !c.flashTick() || c.flash.running.window != 2 { | |||
| 81 | + | t.Fatal("last request did not win") | |||
| 82 | + | } | |||
| 83 | + | c.requestFlash(flashWindow, 2) | |||
| 84 | + | if c.flash.running != nil { | |||
| 85 | + | t.Fatal("request did not replace running flash") | |||
| 86 | + | } | |||
| 87 | + | clock.now = clock.now.Add(flashSettle) | |||
| 88 | + | if c.flashTick() { | |||
| 89 | + | t.Fatal("cooldown ignored") | |||
| 90 | + | } | |||
| 91 | + | clock.now = clock.now.Add(time.Second) | |||
| 92 | + | startFlash(t, c, clock, flashWindow, 2) | |||
| 93 | + | c.requestFlash(flashColumn, 2) | |||
| 94 | + | clock.now = clock.now.Add(flashSettle) | |||
| 95 | + | if !c.flashTick() || c.flash.running.level != flashColumn { | |||
| 96 | + | t.Fatal("level not part of cooldown key") | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestFlashSettlesAndResolvesFinalRect(t *testing.T) { | |||
| 101 | + | for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} { | |||
| 102 | + | t.Run(string(rune('0'+level)), func(t *testing.T) { | |||
| 103 | + | c, clock := flashCore(t) | |||
| 104 | + | w := c.cur().mon.Current() | |||
| 105 | + | c.requestFlash(level, 3) | |||
| 106 | + | w.view.off = 20 | |||
| 107 | + | clock.now = clock.now.Add(flashSettle) | |||
| 108 | + | if c.flashTick() || c.flash.timerC == nil { | |||
| 109 | + | t.Fatal("did not wait for layout") | |||
| 110 | + | } | |||
| 111 | + | w.view.stop() | |||
| 112 | + | w.MoveColumn(-1) | |||
| 113 | + | clock.now = clock.now.Add(flashRecheck) | |||
| 114 | + | if !c.flashTick() { | |||
| 115 | + | t.Fatal("did not start after settling") | |||
| 116 | + | } | |||
| 117 | + | want := Rect{W: 300, H: 200} | |||
| 118 | + | if level != flashMonitor { | |||
| 119 | + | for _, p := range c.cur().mon.Layout() { | |||
| 120 | + | if p.ID == 3 { | |||
| 121 | + | want = p.Rect | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | if c.flash.rect != want { | |||
| 126 | + | t.Fatalf("rect %+v want %+v", c.flash.rect, want) | |||
| 127 | + | } | |||
| 128 | + | }) | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestFlashSkipsIneligibleTargets(t *testing.T) { | |||
| 133 | + | for _, test := range []struct { | |||
| 134 | + | name string | |||
| 135 | + | change func(*Core, *indicatorClock) | |||
| 136 | + | }{ | |||
| 137 | + | {"disabled", func(c *Core, _ *indicatorClock) { c.cfg.Focus.Flash = false }}, | |||
| 138 | + | {"animations off", func(c *Core, _ *indicatorClock) { c.cfg.Animations.On = false }}, | |||
| 139 | + | {"protected", func(c *Core, _ *indicatorClock) { c.security.Protected = true }}, | |||
| 140 | + | {"overview", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleOverview() }}, | |||
| 141 | + | {"fullscreen", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleFullscreen() }}, | |||
| 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 | + | } { | |||
| 145 | + | t.Run(test.name, func(t *testing.T) { | |||
| 146 | + | c, clock := flashCore(t) | |||
| 147 | + | test.change(c, clock) | |||
| 148 | + | c.requestFlash(flashWindow, 3) | |||
| 149 | + | clock.now = clock.now.Add(flashSettle) | |||
| 150 | + | if c.flashTick() || c.flash.running != nil { | |||
| 151 | + | t.Fatal("ineligible flash") | |||
| 152 | + | } | |||
| 153 | + | }) | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestFlashStopAndColor(t *testing.T) { | |||
| 158 | + | c, clock := flashCore(t) | |||
| 159 | + | c.cfg.Focus.FlashColor = "#123456" | |||
| 160 | + | c.cfg.Animations.Slowdown = 10 | |||
| 161 | + | startFlash(t, c, clock, flashWindow, 3) | |||
| 162 | + | if s := indicatorScene(t, c); s.Flash.Color != "#123456" { | |||
| 163 | + | t.Fatal("configured color ignored") | |||
| 164 | + | } | |||
| 165 | + | clock.now = clock.now.Add(c.cfg.Focus.FlashDuration) | |||
| 166 | + | if indicatorScene(t, c).Flash != nil { | |||
| 167 | + | t.Fatal("duration scaled by slowdown") | |||
| 168 | + | } | |||
| 169 | + | c.requestFlash(flashWindow, 2) | |||
| 170 | + | c.stopFlash() | |||
| 171 | + | if c.flash.timerC != nil || c.flash.pending != nil || c.flash.running != nil { | |||
| 172 | + | t.Fatal("stop retained state") | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func TestFlashActionTriggers(t *testing.T) { | |||
| 177 | + | for _, test := range []struct { | |||
| 178 | + | action Action | |||
| 179 | + | level flashLevel | |||
| 180 | + | setup func(*Core) | |||
| 181 | + | }{ | |||
| 182 | + | {ActionFocusColumnLeft, flashColumn, nil}, | |||
| 183 | + | {ActionFocusColumnRight, flashColumn, func(c *Core) { c.cur().mon.Current().FocusID(1) }}, | |||
| 184 | + | {ActionMoveColumnLeft, flashColumn, nil}, | |||
| 185 | + | {ActionMoveColumnRight, flashColumn, func(c *Core) { c.cur().mon.Current().FocusID(1) }}, | |||
| 186 | + | {ActionConsumeOrExpelLeft, flashWindow, nil}, | |||
| 187 | + | {ActionConsumeOrExpelRight, flashWindow, func(c *Core) { c.cur().mon.Current().FocusID(1) }}, | |||
| 188 | + | {ActionToggleFloating, flashWindow, nil}, | |||
| 189 | + | {ActionFocusWindowUp, flashWindow, func(c *Core) { c.cur().mon.Current().ConsumeOrExpel(-1) }}, | |||
| 190 | + | {ActionFocusWindowDown, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ConsumeOrExpel(-1); w.FocusWindow(-1) }}, | |||
| 191 | + | {ActionMoveWindowUp, flashWindow, func(c *Core) { c.cur().mon.Current().ConsumeOrExpel(-1) }}, | |||
| 192 | + | {ActionMoveWindowDown, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ConsumeOrExpel(-1); w.FocusWindow(-1) }}, | |||
| 193 | + | {ActionToggleWindowStash, flashWindow, func(c *Core) { c.cur().mon.Current().ToggleWindowStash() }}, | |||
| 194 | + | {ActionToggleStashVisible, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ToggleWindowStash(); w.ToggleStashVisible() }}, | |||
| 195 | + | {ActionToggleOverview, flashWindow, func(c *Core) { c.cur().mon.ToggleOverview() }}, | |||
| 196 | + | } { | |||
| 197 | + | t.Run(string(test.action), func(t *testing.T) { | |||
| 198 | + | c, clock := flashCore(t) | |||
| 199 | + | if test.setup != nil { | |||
| 200 | + | test.setup(c) | |||
| 201 | + | } | |||
| 202 | + | c.applyAction(test.action) | |||
| 203 | + | if c.flash.pending == nil || c.flash.pending.level != test.level { | |||
| 204 | + | t.Fatalf("pending %+v want level %v", c.flash.pending, test.level) | |||
| 205 | + | } | |||
| 206 | + | target := *c.flash.pending | |||
| 207 | + | clock.now = clock.now.Add(flashSettle) | |||
| 208 | + | if !c.flashTick() { | |||
| 209 | + | t.Fatal("action flash did not start") | |||
| 210 | + | } | |||
| 211 | + | var want Rect | |||
| 212 | + | for _, p := range c.cur().mon.Layout() { | |||
| 213 | + | if p.ID == target.window { | |||
| 214 | + | want = p.Rect | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | if c.flash.rect != want { | |||
| 218 | + | t.Fatalf("action rect %+v want %+v", c.flash.rect, want) | |||
| 219 | + | } | |||
| 220 | + | }) | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func TestFlashExcludedActions(t *testing.T) { | |||
| 225 | + | for _, action := range []Action{ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveWindowToWorkspaceNext, ActionMoveColumnToWorkspaceNext, ActionToggleFullscreen, ActionMaximizeColumn, ActionCycleColumnWidth, ActionFocusColumnRight, ActionMoveColumnRight, ActionMoveWindowUp} { | |||
| 226 | + | t.Run(string(action), func(t *testing.T) { | |||
| 227 | + | c, _ := flashCore(t) | |||
| 228 | + | c.applyAction(action) | |||
| 229 | + | if c.flash.pending != nil { | |||
| 230 | + | t.Fatalf("unexpected flash %+v", c.flash.pending) | |||
| 231 | + | } | |||
| 232 | + | }) | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func TestFlashMonitorTriggers(t *testing.T) { | |||
| 237 | + | for _, action := range []Action{ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown} { | |||
| 238 | + | t.Run(string(action), func(t *testing.T) { | |||
| 239 | + | c, clock := flashCore(t) | |||
| 240 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300}) | |||
| 241 | + | a, b := c.screens[0], c.screens[1] | |||
| 242 | + | a.x, a.y = 0, 0 | |||
| 243 | + | b.x, b.y = 300, 0 | |||
| 244 | + | switch monitorDirection(action) { | |||
| 245 | + | case dirLeft: | |||
| 246 | + | b.x = -400 | |||
| 247 | + | case dirUp: | |||
| 248 | + | b.x, b.y = 0, -300 | |||
| 249 | + | case dirDown: | |||
| 250 | + | b.x, b.y = 0, 200 | |||
| 251 | + | } | |||
| 252 | + | c.applyAction(action) | |||
| 253 | + | if c.flash.pending == nil || c.flash.pending.level != flashMonitor || c.flash.pending.output != "B" { | |||
| 254 | + | t.Fatalf("destination request %+v", c.flash.pending) | |||
| 255 | + | } | |||
| 256 | + | clock.now = clock.now.Add(flashSettle) | |||
| 257 | + | if !c.flashTick() || c.flash.rect != (Rect{W: 400, H: 300}) { | |||
| 258 | + | t.Fatalf("monitor rect %+v", c.flash.rect) | |||
| 259 | + | } | |||
| 260 | + | }) | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func TestFlashColumnUnion(t *testing.T) { | |||
| 265 | + | c, clock := flashCore(t) | |||
| 266 | + | w := c.cur().mon.Current() | |||
| 267 | + | w.ConsumeOrExpel(-1) | |||
| 268 | + | startFlash(t, c, clock, flashColumn, 3) | |||
| 269 | + | col := w.Columns[w.columnOf(3)] | |||
| 270 | + | var area int | |||
| 271 | + | for _, p := range c.cur().mon.Layout() { | |||
| 272 | + | for _, id := range col.Windows { | |||
| 273 | + | if id == p.ID { | |||
| 274 | + | area += p.Rect.W * p.Rect.H | |||
| 275 | + | } | |||
| 276 | + | } | |||
| 277 | + | } | |||
| 278 | + | if c.flash.rect.W*c.flash.rect.H != area { | |||
| 279 | + | t.Fatalf("union %+v does not cover column area %d", c.flash.rect, area) | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | func TestFlashGestureLanding(t *testing.T) { | |||
| 284 | + | c, _ := flashCore(t) | |||
| 285 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |||
| 286 | + | c.swipeUpdate(ports.SwipeUpdate{DX: -800, Time: 50 * time.Millisecond}) | |||
| 287 | + | c.swipeEnd(ports.SwipeEnd{Time: 60 * time.Millisecond}) | |||
| 288 | + | if c.flash.pending == nil || c.flash.pending.level != flashColumn { | |||
| 289 | + | t.Fatalf("landing request %+v", c.flash.pending) | |||
| 290 | + | } | |||
| 291 | + | c.stopFlash() | |||
| 292 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3, Time: 100 * time.Millisecond}) | |||
| 293 | + | c.swipeUpdate(ports.SwipeUpdate{DX: 800, Time: 150 * time.Millisecond}) | |||
| 294 | + | c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: 160 * time.Millisecond}) | |||
| 295 | + | if c.flash.pending != nil { | |||
| 296 | + | t.Fatal("cancelled gesture flashed") | |||
| 297 | + | } | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | func TestFlashLockClearsPendingAndRunning(t *testing.T) { | |||
| 301 | + | for _, running := range []bool{false, true} { | |||
| 302 | + | c, clock := flashCore(t) | |||
| 303 | + | state := ports.SecurityState{} | |||
| 304 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 305 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }) | |||
| 306 | + | c.opts.Security = gate | |||
| 307 | + | if running { | |||
| 308 | + | startFlash(t, c, clock, flashWindow, 3) | |||
| 309 | + | } else { | |||
| 310 | + | c.requestFlash(flashWindow, 3) | |||
| 311 | + | } | |||
| 312 | + | state = ports.SecurityState{Generation: 1, Protected: true} | |||
| 313 | + | if !c.syncSecurity() { | |||
| 314 | + | t.Fatal("security did not change") | |||
| 315 | + | } | |||
| 316 | + | if c.flash.pending != nil || c.flash.running != nil || c.flash.timerC != nil { | |||
| 317 | + | t.Fatal("lock retained flash") | |||
| 318 | + | } | |||
| 319 | + | state = ports.SecurityState{Generation: 2} | |||
| 320 | + | c.syncSecurity() | |||
| 321 | + | if indicatorScene(t, c).Flash != nil || c.flash.pending != nil { | |||
| 322 | + | t.Fatal("unlock requested flash") | |||
| 323 | + | } | |||
| 324 | + | } | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | func TestFlashOverviewClickAndKeyboard(t *testing.T) { | |||
| 328 | + | for _, click := range []bool{true, false} { | |||
| 329 | + | c, _ := flashCore(t) | |||
| 330 | + | c.cur().mon.ToggleOverview() | |||
| 331 | + | scene := indicatorScene(t, c) | |||
| 332 | + | if click { | |||
| 333 | + | var rect Rect | |||
| 334 | + | for _, p := range scene.Windows { | |||
| 335 | + | if p.ID == 2 { | |||
| 336 | + | rect = p.Rect | |||
| 337 | + | } | |||
| 338 | + | } | |||
| 339 | + | c.cursorX, c.cursorY = float64(rect.X+rect.W/2), float64(rect.Y+rect.H/2) | |||
| 340 | + | picked, err := c.overviewClick(context.Background()) | |||
| 341 | + | if err != nil || !picked { | |||
| 342 | + | t.Fatalf("click %v %v", picked, err) | |||
| 343 | + | } | |||
| 344 | + | } else { | |||
| 345 | + | if err := c.keyEvent(context.Background(), ports.KeyEvent{Keysym: "Return", Pressed: true}); err != nil { | |||
| 346 | + | t.Fatal(err) | |||
| 347 | + | } | |||
| 348 | + | } | |||
| 349 | + | if c.flash.pending == nil || c.flash.pending.level != flashWindow { | |||
| 350 | + | t.Fatalf("overview request %+v", c.flash.pending) | |||
| 351 | + | } | |||
| 352 | + | } | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | func TestFlashTileDrop(t *testing.T) { | |||
| 356 | + | c, _ := flashCore(t) | |||
| 357 | + | scene := indicatorScene(t, c) | |||
| 358 | + | var rect Rect | |||
| 359 | + | for _, p := range scene.Windows { | |||
| 360 | + | if p.ID == 2 { | |||
| 361 | + | rect = p.Rect | |||
| 362 | + | } | |||
| 363 | + | } | |||
| 364 | + | c.cursorX, c.cursorY = float64(rect.X+rect.W/2), float64(rect.Y+rect.H/2) | |||
| 365 | + | c.drag = &dragState{id: 3} | |||
| 366 | + | if err := c.endDrag(context.Background()); err != nil { | |||
| 367 | + | t.Fatal(err) | |||
| 368 | + | } | |||
| 369 | + | if c.flash.pending == nil || c.flash.pending.level != flashWindow || c.flash.pending.window != 3 { | |||
| 370 | + | t.Fatalf("drop request %+v", c.flash.pending) | |||
| 371 | + | } | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | func TestFlashNoImplicitFocusTriggers(t *testing.T) { | |||
| 375 | + | c, _ := flashCore(t) | |||
| 376 | + | ctx := context.Background() | |||
| 377 | + | indicatorScene(t, c) | |||
| 378 | + | if c.flash.pending != nil { | |||
| 379 | + | t.Fatal("startup publication flashed") | |||
| 380 | + | } | |||
| 381 | + | for _, ev := range []ports.ClientEvent{ports.WindowMapped{ID: 4}, ports.WindowActivate{ID: 1}} { | |||
| 382 | + | if _, stop := c.clientEvent(ctx, ev); stop { | |||
| 383 | + | t.Fatalf("event %T stopped", ev) | |||
| 384 | + | } | |||
| 385 | + | if c.flash.pending != nil { | |||
| 386 | + | t.Fatalf("event %T flashed", ev) | |||
| 387 | + | } | |||
| 388 | + | } | |||
| 389 | + | indicatorScene(t, c) | |||
| 390 | + | if err := c.pointerMotion(ctx, ports.PointerMotion{X: 50, Y: 50}); err != nil { | |||
| 391 | + | t.Fatal(err) | |||
| 392 | + | } | |||
| 393 | + | if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil { | |||
| 394 | + | t.Fatal(err) | |||
| 395 | + | } | |||
| 396 | + | if c.flash.pending != nil { | |||
| 397 | + | t.Fatal("pointer focus flashed") | |||
| 398 | + | } | |||
| 399 | + | } | |||
M internal/core/gesture.go +19 -4
| @@ -315,12 +315,17 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool, spawn *Workspace) { | |||
| 315 | 315 | if focus != w.Focus && w.policy().maximizeHides() { | |
| 316 | 316 | w.unmaximize() | |
| 317 | 317 | } | |
| 318 | + | changed := w.Focus != focus | |
| 318 | 319 | w.Focus = focus | |
| 319 | 320 | w.View = view | |
| 320 | 321 | // A column wider than the view aligns as focus moves align it. | |
| 321 | 322 | w.scroll() | |
| 322 | 323 | c.focusScreen = c.screenIndex(g.screen.name()) | |
| 323 | 324 | c.keyboard.takeBack() | |
| 325 | + | if changed { | |
| 326 | + | id, _ := w.Focused() | |
| 327 | + | c.requestFlash(flashColumn, id) | |
| 328 | + | } | |
| 324 | 329 | } | |
| 325 | 330 | w.view.off = shown - float64(w.View) | |
| 326 | 331 | if c.animOn() { | |
| @@ -399,10 +404,17 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool, spawn *Workspace) { | |||
| 399 | 404 | } | |
| 400 | 405 | before := m.Current() | |
| 401 | 406 | shots := c.snapshot(now) | |
| 407 | + | closing := m.ov.open | |
| 402 | 408 | m.ToggleOverview() | |
| 403 | 409 | c.focusScreen = c.screenIndex(g.screen.name()) | |
| 404 | 410 | c.keyboard.takeBack() | |
| 405 | 411 | c.transition(shots, now) | |
| 412 | + | if closing { | |
| 413 | + | id, _ := m.Focused() | |
| 414 | + | if id != 0 { | |
| 415 | + | c.requestFlash(flashWindow, id) | |
| 416 | + | } | |
| 417 | + | } | |
| 406 | 418 | return m.Current() != before, nil | |
| 407 | 419 | case swipeDiscrete: | |
| 408 | 420 | step := g.snap.step(e.Cancelled, e.Time) | |
| @@ -574,14 +586,17 @@ func (c *Core) animate(now time.Time, only *screen) { | |||
| 574 | 586 | } | |
| 575 | 587 | } | |
| 576 | 588 | ||
| 577 | - | // sliding reports whether a spring runs on the named output. | |
| 589 | + | // sliding reports whether a spring or focus flash runs on the named output. | |
| 578 | 590 | func (c *Core) sliding(output string) bool { | |
| 579 | 591 | i := c.screenIndex(output) | |
| 580 | - | return i >= 0 && c.screens[i].springing() | |
| 592 | + | return i >= 0 && (c.screens[i].springing() || c.flashing(c.screens[i])) | |
| 581 | 593 | } | |
| 582 | 594 | ||
| 583 | - | // animating reports whether a spring runs on any output. | |
| 595 | + | // animating reports whether a spring or focus flash runs on any output. | |
| 584 | 596 | func (c *Core) animating() bool { | |
| 597 | + | if c.flash.running != nil { | |
| 598 | + | return true | |
| 599 | + | } | |
| 585 | 600 | for _, sc := range c.screens { | |
| 586 | 601 | if sc.springing() { | |
| 587 | 602 | return true | |
| @@ -607,7 +622,7 @@ func (m *Monitor) springing() bool { | |||
| 607 | 622 | func (c *Core) frameFallback() time.Duration { | |
| 608 | 623 | refresh := 60000 | |
| 609 | 624 | for _, sc := range c.screens { | |
| 610 | - | if r := sc.info.RefreshMilli; r > 0 && sc.springing() { | |
| 625 | + | if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.flashing(sc)) { | |
| 611 | 626 | refresh = min(refresh, r) | |
| 612 | 627 | } | |
| 613 | 628 | } | |
| @@ -315,12 +315,17 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool, spawn *Workspace) { | |||||
| 315 | if focus != w.Focus && w.policy().maximizeHides() { | 315 | if focus != w.Focus && w.policy().maximizeHides() { | ||
| 316 | w.unmaximize() | 316 | w.unmaximize() | ||
| 317 | } | 317 | } | ||
| 318 | + | changed := w.Focus != focus | |||
| 318 | w.Focus = focus | 319 | w.Focus = focus | ||
| 319 | w.View = view | 320 | w.View = view | ||
| 320 | // A column wider than the view aligns as focus moves align it. | 321 | // A column wider than the view aligns as focus moves align it. | ||
| 321 | w.scroll() | 322 | w.scroll() | ||
| 322 | c.focusScreen = c.screenIndex(g.screen.name()) | 323 | c.focusScreen = c.screenIndex(g.screen.name()) | ||
| 323 | c.keyboard.takeBack() | 324 | c.keyboard.takeBack() | ||
| 325 | + | if changed { | |||
| 326 | + | id, _ := w.Focused() | |||
| 327 | + | c.requestFlash(flashColumn, id) | |||
| 328 | + | } | |||
| 324 | } | 329 | } | ||
| 325 | w.view.off = shown - float64(w.View) | 330 | w.view.off = shown - float64(w.View) | ||
| 326 | if c.animOn() { | 331 | if c.animOn() { | ||
| @@ -399,10 +404,17 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool, spawn *Workspace) { | |||||
| 399 | } | 404 | } | ||
| 400 | before := m.Current() | 405 | before := m.Current() | ||
| 401 | shots := c.snapshot(now) | 406 | shots := c.snapshot(now) | ||
| 407 | + | closing := m.ov.open | |||
| 402 | m.ToggleOverview() | 408 | m.ToggleOverview() | ||
| 403 | c.focusScreen = c.screenIndex(g.screen.name()) | 409 | c.focusScreen = c.screenIndex(g.screen.name()) | ||
| 404 | c.keyboard.takeBack() | 410 | c.keyboard.takeBack() | ||
| 405 | c.transition(shots, now) | 411 | c.transition(shots, now) | ||
| 412 | + | if closing { | |||
| 413 | + | id, _ := m.Focused() | |||
| 414 | + | if id != 0 { | |||
| 415 | + | c.requestFlash(flashWindow, id) | |||
| 416 | + | } | |||
| 417 | + | } | |||
| 406 | return m.Current() != before, nil | 418 | return m.Current() != before, nil | ||
| 407 | case swipeDiscrete: | 419 | case swipeDiscrete: | ||
| 408 | step := g.snap.step(e.Cancelled, e.Time) | 420 | step := g.snap.step(e.Cancelled, e.Time) | ||
| @@ -574,14 +586,17 @@ func (c *Core) animate(now time.Time, only *screen) { | |||||
| 574 | } | 586 | } | ||
| 575 | } | 587 | } | ||
| 576 | 588 | ||||
| 577 | - | // sliding reports whether a spring runs on the named output. | 589 | + | // sliding reports whether a spring or focus flash runs on the named output. |
| 578 | func (c *Core) sliding(output string) bool { | 590 | func (c *Core) sliding(output string) bool { | ||
| 579 | i := c.screenIndex(output) | 591 | i := c.screenIndex(output) | ||
| 580 | - | return i >= 0 && c.screens[i].springing() | 592 | + | return i >= 0 && (c.screens[i].springing() || c.flashing(c.screens[i])) |
| 581 | } | 593 | } | ||
| 582 | 594 | ||||
| 583 | - | // animating reports whether a spring runs on any output. | 595 | + | // animating reports whether a spring or focus flash runs on any output. |
| 584 | func (c *Core) animating() bool { | 596 | func (c *Core) animating() bool { | ||
| 597 | + | if c.flash.running != nil { | |||
| 598 | + | return true | |||
| 599 | + | } | |||
| 585 | for _, sc := range c.screens { | 600 | for _, sc := range c.screens { | ||
| 586 | if sc.springing() { | 601 | if sc.springing() { | ||
| 587 | return true | 602 | return true | ||
| @@ -607,7 +622,7 @@ func (m *Monitor) springing() bool { | |||||
| 607 | func (c *Core) frameFallback() time.Duration { | 622 | func (c *Core) frameFallback() time.Duration { | ||
| 608 | refresh := 60000 | 623 | refresh := 60000 | ||
| 609 | for _, sc := range c.screens { | 624 | for _, sc := range c.screens { | ||
| 610 | - | if r := sc.info.RefreshMilli; r > 0 && sc.springing() { | 625 | + | if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.flashing(sc)) { |
| 611 | refresh = min(refresh, r) | 626 | refresh = min(refresh, r) | ||
| 612 | } | 627 | } | ||
| 613 | } | 628 | } | ||
M internal/core/input.go +6 -0
| @@ -287,6 +287,12 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | |||
| 287 | 287 | shots = c.snapshot(now) | |
| 288 | 288 | } | |
| 289 | 289 | if key.Pressed && mon.overviewKey(key) { | |
| 290 | + | if !mon.ov.open && keyName(key.Keysym) != "Escape" { | |
| 291 | + | id, _ := mon.Focused() | |
| 292 | + | if id != 0 { | |
| 293 | + | c.requestFlash(flashWindow, id) | |
| 294 | + | } | |
| 295 | + | } | |
| 290 | 296 | c.pressed[heldKey(key)] = true | |
| 291 | 297 | if mon.ov.open && overviewRepeats(key) { | |
| 292 | 298 | c.startRepeat(key, "") | |
| @@ -287,6 +287,12 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | |||||
| 287 | shots = c.snapshot(now) | 287 | shots = c.snapshot(now) | ||
| 288 | } | 288 | } | ||
| 289 | if key.Pressed && mon.overviewKey(key) { | 289 | if key.Pressed && mon.overviewKey(key) { | ||
| 290 | + | if !mon.ov.open && keyName(key.Keysym) != "Escape" { | |||
| 291 | + | id, _ := mon.Focused() | |||
| 292 | + | if id != 0 { | |||
| 293 | + | c.requestFlash(flashWindow, id) | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 290 | c.pressed[heldKey(key)] = true | 296 | c.pressed[heldKey(key)] = true | ||
| 291 | if mon.ov.open && overviewRepeats(key) { | 297 | if mon.ov.open && overviewRepeats(key) { | ||
| 292 | c.startRepeat(key, "") | 298 | c.startRepeat(key, "") | ||
M internal/core/overview.go +1 -0
| @@ -1476,6 +1476,7 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) { | |||
| 1476 | 1476 | sc.mon.OverviewPick(id) | |
| 1477 | 1477 | c.transition(shots, now) | |
| 1478 | 1478 | c.focusScreen = i | |
| 1479 | + | c.requestFlash(flashWindow, id) | |
| 1479 | 1480 | c.keyboard.takeBack() | |
| 1480 | 1481 | if err := c.workspaceVisible(ctx, true); err != nil { | |
| 1481 | 1482 | return true, err | |
| @@ -1476,6 +1476,7 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) { | |||||
| 1476 | sc.mon.OverviewPick(id) | 1476 | sc.mon.OverviewPick(id) | ||
| 1477 | c.transition(shots, now) | 1477 | c.transition(shots, now) | ||
| 1478 | c.focusScreen = i | 1478 | c.focusScreen = i | ||
| 1479 | + | c.requestFlash(flashWindow, id) | |||
| 1479 | c.keyboard.takeBack() | 1480 | c.keyboard.takeBack() | ||
| 1480 | if err := c.workspaceVisible(ctx, true); err != nil { | 1481 | if err := c.workspaceVisible(ctx, true); err != nil { | ||
| 1481 | return true, err | 1482 | return true, err | ||
M internal/core/scene.go +4 -1
| @@ -121,6 +121,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||
| 121 | 121 | // The drag icon is over everything, popups and layers included. | |
| 122 | 122 | scene.Windows = append(scene.Windows, icon) | |
| 123 | 123 | } | |
| 124 | + | scene.Flash = c.flashFor(sc) | |
| 124 | 125 | scene.CaptureIndicators = c.captureIndicators(sc) | |
| 125 | 126 | scene.Capture = c.captureSceneFor(sc, capture) | |
| 126 | 127 | // The springs of this frame are advanced: a scene shown while one still | |
| @@ -137,7 +138,9 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||
| 137 | 138 | // while it draws the same, so an idle output is not recomposed. | |
| 138 | 139 | withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr) | |
| 139 | 140 | animating := scene.Animating && (only == nil || only == sc) | |
| 140 | - | if !withCapture && !animating && sc.last.Animating == scene.Animating && sc.last.Seq != 0 && scene.SameAs(sc.last) { | |
| 141 | + | // Ports' scene equality does not yet compare the phase-3 overlay. | |
| 142 | + | flashSame := scene.Flash == nil && sc.last.Flash == nil || scene.Flash != nil && sc.last.Flash != nil && *scene.Flash == *sc.last.Flash | |
| 143 | + | if !withCapture && !animating && flashSame && sc.last.Animating == scene.Animating && sc.last.Seq != 0 && scene.SameAs(sc.last) { | |
| 141 | 144 | scene.Seq = sc.last.Seq | |
| 142 | 145 | } else { | |
| 143 | 146 | c.seq++ | |
| @@ -121,6 +121,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||||
| 121 | // The drag icon is over everything, popups and layers included. | 121 | // The drag icon is over everything, popups and layers included. | ||
| 122 | scene.Windows = append(scene.Windows, icon) | 122 | scene.Windows = append(scene.Windows, icon) | ||
| 123 | } | 123 | } | ||
| 124 | + | scene.Flash = c.flashFor(sc) | |||
| 124 | scene.CaptureIndicators = c.captureIndicators(sc) | 125 | scene.CaptureIndicators = c.captureIndicators(sc) | ||
| 125 | scene.Capture = c.captureSceneFor(sc, capture) | 126 | scene.Capture = c.captureSceneFor(sc, capture) | ||
| 126 | // The springs of this frame are advanced: a scene shown while one still | 127 | // The springs of this frame are advanced: a scene shown while one still | ||
| @@ -137,7 +138,9 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||||
| 137 | // while it draws the same, so an idle output is not recomposed. | 138 | // while it draws the same, so an idle output is not recomposed. | ||
| 138 | withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr) | 139 | withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr) | ||
| 139 | animating := scene.Animating && (only == nil || only == sc) | 140 | animating := scene.Animating && (only == nil || only == sc) | ||
| 140 | - | if !withCapture && !animating && sc.last.Animating == scene.Animating && sc.last.Seq != 0 && scene.SameAs(sc.last) { | 141 | + | // Ports' scene equality does not yet compare the phase-3 overlay. |
| 142 | + | flashSame := scene.Flash == nil && sc.last.Flash == nil || scene.Flash != nil && sc.last.Flash != nil && *scene.Flash == *sc.last.Flash | |||
| 143 | + | if !withCapture && !animating && flashSame && sc.last.Animating == scene.Animating && sc.last.Seq != 0 && scene.SameAs(sc.last) { | |||
| 141 | scene.Seq = sc.last.Seq | 144 | scene.Seq = sc.last.Seq | ||
| 142 | } else { | 145 | } else { | ||
| 143 | c.seq++ | 146 | c.seq++ | ||
M internal/core/security.go +1 -0
| @@ -47,6 +47,7 @@ func (c *Core) syncSecurity() bool { | |||
| 47 | 47 | // protected epoch clears that focus. | |
| 48 | 48 | c.gesture = clientGesture{} | |
| 49 | 49 | c.stopAnimations() | |
| 50 | + | c.stopFlash() | |
| 50 | 51 | c.cancelSwitcher() | |
| 51 | 52 | c.stopRepeat() | |
| 52 | 53 | if state.Protected { | |
| @@ -47,6 +47,7 @@ func (c *Core) syncSecurity() bool { | |||||
| 47 | // protected epoch clears that focus. | 47 | // protected epoch clears that focus. | ||
| 48 | c.gesture = clientGesture{} | 48 | c.gesture = clientGesture{} | ||
| 49 | c.stopAnimations() | 49 | c.stopAnimations() | ||
| 50 | + | c.stopFlash() | |||
| 50 | c.cancelSwitcher() | 51 | c.cancelSwitcher() | ||
| 51 | c.stopRepeat() | 52 | c.stopRepeat() | ||
| 52 | if state.Protected { | 53 | if state.Protected { | ||