detached 4e6e449

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +700 -7
M internal/core/action.go +71 -0
@@ -1,6 +1,7 @@
11 package core
22
33 import (
4+ "slices"
45 "strconv"
56 "strings"
67
@@ -213,6 +214,76 @@ type Effect struct {
213214 // else along the layout's workspace axis; everything else applies to the
214215 // focused screen's monitor.
215216 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 {
216287 if e, ok := c.cur().mon.overviewAction(a); ok {
217288 return e
218289 }
@@ -1,6 +1,7 @@
1 package core1 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 the214 // 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 e288 return e
218 }289 }
M internal/core/core.go +13 -2
@@ -155,7 +155,8 @@ type Core struct {
155155 motionTime time.Duration
156156 pointerAt [2]float64
157157 // capt is the capture state (capture.go, capindicator.go).
158- capt captureState
158+ capt captureState
159+ flash focusFlash
159160 // switcher is the column switcher in progress: the screen it is on and
160161 // the timer that shows its cards (switcher.go).
161162 switcher struct {
@@ -674,6 +675,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
674675 return err
675676 }
676677 focus := c.keyboardFocus()
678+ c.advanceFlash(only)
677679 scenes := make([]ports.Scene, 0, len(c.screens))
678680 // Before the first output (and after the last is unplugged) the
679681 // 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 {
742744 // other than only.
743745 func (c *Core) animatingOther(only *screen) bool {
744746 for _, sc := range c.screens {
745- if sc != only && sc.springing() {
747+ if sc != only && (sc.springing() || c.flashing(sc)) {
746748 return true
747749 }
748750 }
@@ -1090,6 +1092,7 @@ func (c *Core) Run(ctx context.Context) error {
10901092 c.syncSecurity()
10911093 defer c.stopFrame()
10921094 defer c.stopCaptureTimer()
1095+ defer c.stopFlashTimer()
10931096 defer c.cancelSwitcher()
10941097 defer c.stopRepeat()
10951098 // Startup commands run once per session. Full launcher queues retain a
@@ -1159,6 +1162,14 @@ func (c *Core) Run(ctx context.Context) error {
11591162 return nil
11601163 }
11611164 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
11621173 case <-c.switcher.timerC:
11631174 if c.securityCheckpoint(ctx) != nil {
11641175 return nil
@@ -155,7 +155,8 @@ type Core struct {
155 motionTime time.Duration155 motionTime time.Duration
156 pointerAt [2]float64156 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 captureState158+ capt captureState
159+ flash focusFlash
159 // switcher is the column switcher in progress: the screen it is on and160 // 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 err675 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) the680 // 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 true748 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 a1098 // 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 nil1162 return nil
1160 }1163 }
1161 continue1164 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 nil1175 return nil
M internal/core/drag.go +1 -0
@@ -243,6 +243,7 @@ func (c *Core) endDrag(ctx context.Context) error {
243243 now := c.now()
244244 shots := c.snapshot(now)
245245 c.drop(d.id, t)
246+ c.requestFlash(flashWindow, d.id)
246247 c.transition(shots, now)
247248 // Slots and guests follow the windows, as after a bind.
248249 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) {
315315 if focus != w.Focus && w.policy().maximizeHides() {
316316 w.unmaximize()
317317 }
318+ changed := w.Focus != focus
318319 w.Focus = focus
319320 w.View = view
320321 // A column wider than the view aligns as focus moves align it.
321322 w.scroll()
322323 c.focusScreen = c.screenIndex(g.screen.name())
323324 c.keyboard.takeBack()
325+ if changed {
326+ id, _ := w.Focused()
327+ c.requestFlash(flashColumn, id)
328+ }
324329 }
325330 w.view.off = shown - float64(w.View)
326331 if c.animOn() {
@@ -399,10 +404,17 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool, spawn *Workspace) {
399404 }
400405 before := m.Current()
401406 shots := c.snapshot(now)
407+ closing := m.ov.open
402408 m.ToggleOverview()
403409 c.focusScreen = c.screenIndex(g.screen.name())
404410 c.keyboard.takeBack()
405411 c.transition(shots, now)
412+ if closing {
413+ id, _ := m.Focused()
414+ if id != 0 {
415+ c.requestFlash(flashWindow, id)
416+ }
417+ }
406418 return m.Current() != before, nil
407419 case swipeDiscrete:
408420 step := g.snap.step(e.Cancelled, e.Time)
@@ -574,14 +586,17 @@ func (c *Core) animate(now time.Time, only *screen) {
574586 }
575587 }
576588
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.
578590 func (c *Core) sliding(output string) bool {
579591 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]))
581593 }
582594
583-// animating reports whether a spring runs on any output.
595+// animating reports whether a spring or focus flash runs on any output.
584596 func (c *Core) animating() bool {
597+ if c.flash.running != nil {
598+ return true
599+ }
585600 for _, sc := range c.screens {
586601 if sc.springing() {
587602 return true
@@ -607,7 +622,7 @@ func (m *Monitor) springing() bool {
607622 func (c *Core) frameFallback() time.Duration {
608623 refresh := 60000
609624 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)) {
611626 refresh = min(refresh, r)
612627 }
613628 }
@@ -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 = focus319 w.Focus = focus
319 w.View = view320 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, nil418 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 true602 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 := 60000623 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 {
287287 shots = c.snapshot(now)
288288 }
289289 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+ }
290296 c.pressed[heldKey(key)] = true
291297 if mon.ov.open && overviewRepeats(key) {
292298 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)] = true296 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) {
14761476 sc.mon.OverviewPick(id)
14771477 c.transition(shots, now)
14781478 c.focusScreen = i
1479+ c.requestFlash(flashWindow, id)
14791480 c.keyboard.takeBack()
14801481 if err := c.workspaceVisible(ctx, true); err != nil {
14811482 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 = i1478 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, err1482 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
121121 // The drag icon is over everything, popups and layers included.
122122 scene.Windows = append(scene.Windows, icon)
123123 }
124+ scene.Flash = c.flashFor(sc)
124125 scene.CaptureIndicators = c.captureIndicators(sc)
125126 scene.Capture = c.captureSceneFor(sc, capture)
126127 // 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
137138 // while it draws the same, so an idle output is not recomposed.
138139 withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr)
139140 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) {
141144 scene.Seq = sc.last.Seq
142145 } else {
143146 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 still127 // 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.Seq144 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 {
4747 // protected epoch clears that focus.
4848 c.gesture = clientGesture{}
4949 c.stopAnimations()
50+ c.stopFlash()
5051 c.cancelSwitcher()
5152 c.stopRepeat()
5253 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 {

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close