detached 23a67a3

Merge 2117e03a08536cead35d1280676b7a8517276e54 into dc5dff61334a51096a0e2c54e3d44b3b0aa6540c

commit
23a67a34e9770e4a724ccfb154b615329b58eec7
author
ermite · · 2026-10-19 05:13 UTC
merge of
dc5dff6 + 2117e03 · diff against the first parent
signature
unsigned
49 files · +1246 -834
M README.md +8 -3
@@ -225,9 +225,10 @@ cursor.hide-after = 5s
225225 focus.follow-move = off
226226 animations = off
227227 animations.speed = normal
228-focus.animation = pulse
229-focus.effect = screen
230-focus.strength = 0.04
228+focus.flash = on
229+focus.flash-width = 2
230+focus.flash-opacity = 0.6
231+focus.flash-duration = 200ms
231232
232233 # Rendering and logging
233234 render.direct-scanout = on
@@ -324,6 +325,10 @@ bind.cmd+code:13 = scale-up
324325 bind.cmd+code:12 = scale-down
325326 ```
326327
328+The focus flash marks the window, column or output an action changes with a brief
329+inner border. Workspace switches and mouse focus do not trigger a flash.
330+It runs when `animations = on`.
331+
327332 Per-output defaults (replace `<name>` with a connector such as `DP-1`):
328333
329334 ```text
@@ -225,9 +225,10 @@ cursor.hide-after = 5s
225 focus.follow-move = off225 focus.follow-move = off
226 animations = off226 animations = off
227 animations.speed = normal227 animations.speed = normal
228-focus.animation = pulse228+focus.flash = on
229-focus.effect = screen229+focus.flash-width = 2
230-focus.strength = 0.04230+focus.flash-opacity = 0.6
231+focus.flash-duration = 200ms
231 232
232 # Rendering and logging233 # Rendering and logging
233 render.direct-scanout = on234 render.direct-scanout = on
@@ -324,6 +325,10 @@ bind.cmd+code:13 = scale-up
324 bind.cmd+code:12 = scale-down325 bind.cmd+code:12 = scale-down
325 ```326 ```
326 327
328+The focus flash marks the window, column or output an action changes with a brief
329+inner border. Workspace switches and mouse focus do not trigger a flash.
330+It runs when `animations = on`.
331+
327 Per-output defaults (replace `<name>` with a connector such as `DP-1`):332 Per-output defaults (replace `<name>` with a connector such as `DP-1`):
328 333
329 ```text334 ```text
M docs/config.md +9 -2
@@ -50,8 +50,11 @@ See [examples/config](../examples/config) for every key with its default.
5050 | `animations` | `off` | **Experimental.** Animate layout changes and make swipes follow the fingers |
5151 | `animations.speed` | `normal` | `slow`, `normal`, `fast`, or a factor from `0.1` to `10` |
5252 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
53-| `focus.animation` | `pulse` | Brief brightening of a newly focused window, or `off`. Needs `animations = on` |
54-| `focus.strength` | `0.04` | Strength of the focus pulse, `0.01` to `0.2` |
53+| `focus.flash` | `on` | Briefly draw an inner border on the window, column or output an action changed |
54+| `focus.flash-width` | `2` | Flash border width in logical pixels, `1` to `8` |
55+| `focus.flash-opacity` | `0.6` | Starting opacity of the flash, `0.1` to `1` |
56+| `focus.flash-duration` | `200ms` | Fade duration, `50ms` to `1s`; not scaled by `animations.speed` |
57+| `focus.flash-color` | `border.active` | Flash color `#rrggbb`; empty uses `border.active` |
5558 | `workspace.<name>.monitor` | focused output | Home monitor of a named workspace |
5659 | `workspace.<name>.size` | `inherit` | [Smaller viewport](#workspace-size), e.g. `1920x1080` |
5760 | `workspace.<name>.max-columns`, `.overflow` | output values | Per-workspace layout |
@@ -74,6 +77,10 @@ See [examples/config](../examples/config) for every key with its default.
7477
7578 `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`).
7679
80+The focus flash marks the window, column or output an action changes with a brief
81+inner border. Workspace switches and mouse focus do not trigger a flash.
82+It runs when `animations = on`.
83+
7784 ## Window rules
7885
7986 Rules place an app's windows when they first open.
@@ -50,8 +50,11 @@ See [examples/config](../examples/config) for every key with its default.
50 | `animations` | `off` | **Experimental.** Animate layout changes and make swipes follow the fingers |50 | `animations` | `off` | **Experimental.** Animate layout changes and make swipes follow the fingers |
51 | `animations.speed` | `normal` | `slow`, `normal`, `fast`, or a factor from `0.1` to `10` |51 | `animations.speed` | `normal` | `slow`, `normal`, `fast`, or a factor from `0.1` to `10` |
52 | `focus.follow-move` | `off` | Follow a column moved to another workspace |52 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
53-| `focus.animation` | `pulse` | Brief brightening of a newly focused window, or `off`. Needs `animations = on` |53+| `focus.flash` | `on` | Briefly draw an inner border on the window, column or output an action changed |
54-| `focus.strength` | `0.04` | Strength of the focus pulse, `0.01` to `0.2` |54+| `focus.flash-width` | `2` | Flash border width in logical pixels, `1` to `8` |
55+| `focus.flash-opacity` | `0.6` | Starting opacity of the flash, `0.1` to `1` |
56+| `focus.flash-duration` | `200ms` | Fade duration, `50ms` to `1s`; not scaled by `animations.speed` |
57+| `focus.flash-color` | `border.active` | Flash color `#rrggbb`; empty uses `border.active` |
55 | `workspace.<name>.monitor` | focused output | Home monitor of a named workspace |58 | `workspace.<name>.monitor` | focused output | Home monitor of a named workspace |
56 | `workspace.<name>.size` | `inherit` | [Smaller viewport](#workspace-size), e.g. `1920x1080` |59 | `workspace.<name>.size` | `inherit` | [Smaller viewport](#workspace-size), e.g. `1920x1080` |
57 | `workspace.<name>.max-columns`, `.overflow` | output values | Per-workspace layout |60 | `workspace.<name>.max-columns`, `.overflow` | output values | Per-workspace layout |
@@ -74,6 +77,10 @@ See [examples/config](../examples/config) for every key with its default.
74 77
75 `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`).78 `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`).
76 79
80+The focus flash marks the window, column or output an action changes with a brief
81+inner border. Workspace switches and mouse focus do not trigger a flash.
82+It runs when `animations = on`.
83+
77 ## Window rules84 ## Window rules
78 85
79 Rules place an app's windows when they first open.86 Rules place an app's windows when they first open.
M docs/headless.md +2 -0
@@ -8,6 +8,8 @@ WAYLAND_DISPLAY=<name from the log> foot
88 ```
99
1010 - `latest.png` shows the current frame. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows.
11+- `--size 640x360,640x360` creates two outputs, arranged left to right,
12+ for testing `focus-monitor-left/right` and moving columns between outputs.
1113 - With several outputs, each writes to `<dir>/HEADLESS-<n>/`.
1214 - `--timeout 5s` stops NeferWL after 5 seconds.
1315 - `grim` and recorders work too; see [Screen capture](capture.md).
@@ -8,6 +8,8 @@ WAYLAND_DISPLAY=<name from the log> foot
8 ```8 ```
9 9
10 - `latest.png` shows the current frame. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows.10 - `latest.png` shows the current frame. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows.
11+- `--size 640x360,640x360` creates two outputs, arranged left to right,
12+ for testing `focus-monitor-left/right` and moving columns between outputs.
11 - With several outputs, each writes to `<dir>/HEADLESS-<n>/`.13 - With several outputs, each writes to `<dir>/HEADLESS-<n>/`.
12 - `--timeout 5s` stops NeferWL after 5 seconds.14 - `--timeout 5s` stops NeferWL after 5 seconds.
13 - `grim` and recorders work too; see [Screen capture](capture.md).15 - `grim` and recorders work too; see [Screen capture](capture.md).
M examples/config +9 -6
@@ -85,12 +85,15 @@ focus.follow-move = off
8585 # (10x faster).
8686 animations = off
8787 animations.speed = normal
88-# Focus indicator on the newly focused window: an animation (pulse or off)
89-# of an effect (screen: a slight brightening), peaking at strength
90-# (0.01 to 0.2; 0.04: black reaches about 10/255).
91-focus.animation = pulse
92-focus.effect = screen
93-focus.strength = 0.04
88+# Focus flash: a brief inner border on the window, column or output an
89+# action just changed. Width in logical pixels (1 to 8), starting opacity
90+# (0.1 to 1), fade duration (50ms to 1s, not scaled by animations.speed),
91+# color #rrggbb (empty: border.active).
92+focus.flash = on
93+focus.flash-width = 2
94+focus.flash-opacity = 0.6
95+focus.flash-duration = 200ms
96+focus.flash-color =
9497
9598 # Named workspaces are outside the numbered list and require a bind. The bind
9699 # shows one; pressing it again returns to the previous workspace.
@@ -85,12 +85,15 @@ focus.follow-move = off
85 # (10x faster).85 # (10x faster).
86 animations = off86 animations = off
87 animations.speed = normal87 animations.speed = normal
88-# Focus indicator on the newly focused window: an animation (pulse or off)88+# Focus flash: a brief inner border on the window, column or output an
89-# of an effect (screen: a slight brightening), peaking at strength89+# action just changed. Width in logical pixels (1 to 8), starting opacity
90-# (0.01 to 0.2; 0.04: black reaches about 10/255).90+# (0.1 to 1), fade duration (50ms to 1s, not scaled by animations.speed),
91-focus.animation = pulse91+# color #rrggbb (empty: border.active).
92-focus.effect = screen92+focus.flash = on
93-focus.strength = 0.0493+focus.flash-width = 2
94+focus.flash-opacity = 0.6
95+focus.flash-duration = 200ms
96+focus.flash-color =
94 97
95 # Named workspaces are outside the numbered list and require a bind. The bind98 # Named workspaces are outside the numbered list and require a bind. The bind
96 # shows one; pressing it again returns to the previous workspace.99 # shows one; pressing it again returns to the previous workspace.
M internal/adapters/config/config.go +31 -18
@@ -184,9 +184,10 @@ func Defaults() ports.Config {
184184 // Animations are experimental: off unless the config turns them on.
185185 c.Animations.On = false
186186 c.Animations.Slowdown = normalSlowdown
187- c.Focus.Animation = ports.FocusAnimationPulse
188- c.Focus.Effect = ports.FocusEffectScreen
189- c.Focus.Strength = 0.04
187+ c.Focus.Flash = true
188+ c.Focus.FlashWidth = 2
189+ c.Focus.FlashOpacity = 0.6
190+ c.Focus.FlashDuration = 200 * time.Millisecond
190191 c.Binds = map[string]string{}
191192 for _, b := range defaultBinds {
192193 combos := []string{b.combo}
@@ -840,23 +841,35 @@ func set(c *ports.Config, key, v string) error {
840841 return err
841842 }
842843 c.Focus.FollowMove = b
843- case "focus.animation":
844- if v != ports.FocusAnimationPulse && v != ports.FocusAnimationOff {
845- return fmt.Errorf("must be pulse or off")
846- }
847- c.Focus.Animation = v
848- case "focus.effect":
849- if v != ports.FocusEffectScreen {
850- return fmt.Errorf("must be screen")
851- }
852- c.Focus.Effect = v
853- case "focus.strength":
854- // Capped low: the focus indicator must never flash.
844+ case "focus.flash":
845+ b, err := onOff(v)
846+ if err != nil {
847+ return err
848+ }
849+ c.Focus.Flash = b
850+ case "focus.flash-width":
851+ n, err := strconv.Atoi(v)
852+ if err != nil || n < 1 || n > 8 {
853+ return fmt.Errorf("must be an integer between 1 and 8")
854+ }
855+ c.Focus.FlashWidth = n
856+ case "focus.flash-opacity":
855857 f, err := strconv.ParseFloat(v, 64)
856- if err != nil || !(f >= 0.01 && f <= 0.2) {
857- return fmt.Errorf("must be between 0.01 and 0.2")
858+ if err != nil || !(f >= 0.1 && f <= 1) {
859+ return fmt.Errorf("must be between 0.1 and 1")
860+ }
861+ c.Focus.FlashOpacity = f
862+ case "focus.flash-duration":
863+ d, err := time.ParseDuration(v)
864+ if err != nil || d < 50*time.Millisecond || d > time.Second {
865+ return fmt.Errorf("must be a duration between 50ms and 1s, e.g. 200ms")
866+ }
867+ c.Focus.FlashDuration = d
868+ case "focus.flash-color":
869+ if v != "" && !color.MatchString(v) {
870+ return fmt.Errorf("must be #rrggbb or empty (border.active)")
858871 }
859- c.Focus.Strength = f
872+ c.Focus.FlashColor = v
860873 case "render.direct-scanout":
861874 b, err := onOff(v)
862875 if err != nil {
@@ -184,9 +184,10 @@ func Defaults() ports.Config {
184 // Animations are experimental: off unless the config turns them on.184 // Animations are experimental: off unless the config turns them on.
185 c.Animations.On = false185 c.Animations.On = false
186 c.Animations.Slowdown = normalSlowdown186 c.Animations.Slowdown = normalSlowdown
187- c.Focus.Animation = ports.FocusAnimationPulse187+ c.Focus.Flash = true
188- c.Focus.Effect = ports.FocusEffectScreen188+ c.Focus.FlashWidth = 2
189- c.Focus.Strength = 0.04189+ c.Focus.FlashOpacity = 0.6
190+ c.Focus.FlashDuration = 200 * time.Millisecond
190 c.Binds = map[string]string{}191 c.Binds = map[string]string{}
191 for _, b := range defaultBinds {192 for _, b := range defaultBinds {
192 combos := []string{b.combo}193 combos := []string{b.combo}
@@ -840,23 +841,35 @@ func set(c *ports.Config, key, v string) error {
840 return err841 return err
841 }842 }
842 c.Focus.FollowMove = b843 c.Focus.FollowMove = b
843- case "focus.animation":844+ case "focus.flash":
844- if v != ports.FocusAnimationPulse && v != ports.FocusAnimationOff {845+ b, err := onOff(v)
845- return fmt.Errorf("must be pulse or off")846+ if err != nil {
846- }847+ return err
847- c.Focus.Animation = v848+ }
848- case "focus.effect":849+ c.Focus.Flash = b
849- if v != ports.FocusEffectScreen {850+ case "focus.flash-width":
850- return fmt.Errorf("must be screen")851+ n, err := strconv.Atoi(v)
851- }852+ if err != nil || n < 1 || n > 8 {
852- c.Focus.Effect = v853+ return fmt.Errorf("must be an integer between 1 and 8")
853- case "focus.strength":854+ }
854- // Capped low: the focus indicator must never flash.855+ c.Focus.FlashWidth = n
856+ case "focus.flash-opacity":
855 f, err := strconv.ParseFloat(v, 64)857 f, err := strconv.ParseFloat(v, 64)
856- if err != nil || !(f >= 0.01 && f <= 0.2) {858+ if err != nil || !(f >= 0.1 && f <= 1) {
857- return fmt.Errorf("must be between 0.01 and 0.2")859+ return fmt.Errorf("must be between 0.1 and 1")
860+ }
861+ c.Focus.FlashOpacity = f
862+ case "focus.flash-duration":
863+ d, err := time.ParseDuration(v)
864+ if err != nil || d < 50*time.Millisecond || d > time.Second {
865+ return fmt.Errorf("must be a duration between 50ms and 1s, e.g. 200ms")
866+ }
867+ c.Focus.FlashDuration = d
868+ case "focus.flash-color":
869+ if v != "" && !color.MatchString(v) {
870+ return fmt.Errorf("must be #rrggbb or empty (border.active)")
858 }871 }
859- c.Focus.Strength = f872+ c.Focus.FlashColor = v
860 case "render.direct-scanout":873 case "render.direct-scanout":
861 b, err := onOff(v)874 b, err := onOff(v)
862 if err != nil {875 if err != nil {
M internal/adapters/config/config_test.go +26 -7
@@ -110,21 +110,40 @@ func TestAnimations(t *testing.T) {
110110 }
111111 }
112112
113-func TestFocusIndicator(t *testing.T) {
113+func TestFocusFlash(t *testing.T) {
114114 d := Defaults().Focus
115- if d.Animation != ports.FocusAnimationPulse || d.Effect != ports.FocusEffectScreen || d.Strength != 0.04 {
116- t.Fatal("focus indicator defaults", d)
115+ if !d.Flash || d.FlashWidth != 2 || d.FlashOpacity != 0.6 || d.FlashDuration != 200*time.Millisecond || d.FlashColor != "" {
116+ t.Fatal("focus flash defaults", d)
117117 }
118- c, w := parseString(t, "focus.animation = off\nfocus.effect = screen\nfocus.strength = 0.08\n")
119- if c.Focus.Animation != ports.FocusAnimationOff || c.Focus.Effect != ports.FocusEffectScreen || c.Focus.Strength != 0.08 || len(w) != 0 {
118+ c, w := parseString(t, "focus.flash = off\nfocus.flash-width = 8\nfocus.flash-opacity = 1\nfocus.flash-duration = 1s\nfocus.flash-color = #112233\n")
119+ want := d
120+ want.Flash, want.FlashWidth, want.FlashOpacity, want.FlashDuration, want.FlashColor = false, 8, 1, time.Second, "#112233"
121+ if c.Focus != want || len(w) != 0 {
120122 t.Fatal(c.Focus, w)
121123 }
122- // Each rejected value warns and keeps its default.
123- for _, line := range []string{"focus.animation = blink", "focus.effect = glow", "focus.strength = 0", "focus.strength = 0.5", "focus.strength = x"} {
124+ c, w = parseString(t, "focus.flash = on\nfocus.flash-width = 1\nfocus.flash-opacity = 0.1\nfocus.flash-duration = 50ms\nfocus.flash-color =\n")
125+ want.Flash, want.FlashWidth, want.FlashOpacity, want.FlashDuration, want.FlashColor = true, 1, 0.1, 50*time.Millisecond, ""
126+ if c.Focus != want || len(w) != 0 {
127+ t.Fatal(c.Focus, w)
128+ }
129+ // Each rejected value warns and keeps its default; removed keys are unknown.
130+ for _, line := range []string{
131+ "focus.flash = maybe",
132+ "focus.flash-width = 0", "focus.flash-width = 9", "focus.flash-width = x", "focus.flash-width = 1.5",
133+ "focus.flash-opacity = 0.05", "focus.flash-opacity = 1.1", "focus.flash-opacity = x",
134+ "focus.flash-duration = 10ms", "focus.flash-duration = 2s", "focus.flash-duration = x", "focus.flash-duration = 200",
135+ "focus.flash-color = red", "focus.flash-color = #12345",
136+ "focus.animation = pulse", "focus.effect = screen", "focus.strength = 0.04",
137+ } {
124138 c, w = parseString(t, line+"\n")
125139 if c.Focus != d || len(w) != 1 {
126140 t.Fatal(line, c.Focus, w)
127141 }
142+ if strings.HasPrefix(line, "focus.animation") || strings.HasPrefix(line, "focus.effect") || strings.HasPrefix(line, "focus.strength") {
143+ if !strings.Contains(w[0].Msg, "unknown key") {
144+ t.Fatal(line, w)
145+ }
146+ }
128147 }
129148 }
130149
@@ -110,21 +110,40 @@ func TestAnimations(t *testing.T) {
110 }110 }
111 }111 }
112 112
113-func TestFocusIndicator(t *testing.T) {113+func TestFocusFlash(t *testing.T) {
114 d := Defaults().Focus114 d := Defaults().Focus
115- if d.Animation != ports.FocusAnimationPulse || d.Effect != ports.FocusEffectScreen || d.Strength != 0.04 {115+ if !d.Flash || d.FlashWidth != 2 || d.FlashOpacity != 0.6 || d.FlashDuration != 200*time.Millisecond || d.FlashColor != "" {
116- t.Fatal("focus indicator defaults", d)116+ t.Fatal("focus flash defaults", d)
117 }117 }
118- c, w := parseString(t, "focus.animation = off\nfocus.effect = screen\nfocus.strength = 0.08\n")118+ c, w := parseString(t, "focus.flash = off\nfocus.flash-width = 8\nfocus.flash-opacity = 1\nfocus.flash-duration = 1s\nfocus.flash-color = #112233\n")
119- if c.Focus.Animation != ports.FocusAnimationOff || c.Focus.Effect != ports.FocusEffectScreen || c.Focus.Strength != 0.08 || len(w) != 0 {119+ want := d
120+ want.Flash, want.FlashWidth, want.FlashOpacity, want.FlashDuration, want.FlashColor = false, 8, 1, time.Second, "#112233"
121+ if c.Focus != want || len(w) != 0 {
120 t.Fatal(c.Focus, w)122 t.Fatal(c.Focus, w)
121 }123 }
122- // Each rejected value warns and keeps its default.124+ c, w = parseString(t, "focus.flash = on\nfocus.flash-width = 1\nfocus.flash-opacity = 0.1\nfocus.flash-duration = 50ms\nfocus.flash-color =\n")
123- for _, line := range []string{"focus.animation = blink", "focus.effect = glow", "focus.strength = 0", "focus.strength = 0.5", "focus.strength = x"} {125+ want.Flash, want.FlashWidth, want.FlashOpacity, want.FlashDuration, want.FlashColor = true, 1, 0.1, 50*time.Millisecond, ""
126+ if c.Focus != want || len(w) != 0 {
127+ t.Fatal(c.Focus, w)
128+ }
129+ // Each rejected value warns and keeps its default; removed keys are unknown.
130+ for _, line := range []string{
131+ "focus.flash = maybe",
132+ "focus.flash-width = 0", "focus.flash-width = 9", "focus.flash-width = x", "focus.flash-width = 1.5",
133+ "focus.flash-opacity = 0.05", "focus.flash-opacity = 1.1", "focus.flash-opacity = x",
134+ "focus.flash-duration = 10ms", "focus.flash-duration = 2s", "focus.flash-duration = x", "focus.flash-duration = 200",
135+ "focus.flash-color = red", "focus.flash-color = #12345",
136+ "focus.animation = pulse", "focus.effect = screen", "focus.strength = 0.04",
137+ } {
124 c, w = parseString(t, line+"\n")138 c, w = parseString(t, line+"\n")
125 if c.Focus != d || len(w) != 1 {139 if c.Focus != d || len(w) != 1 {
126 t.Fatal(line, c.Focus, w)140 t.Fatal(line, c.Focus, w)
127 }141 }
142+ if strings.HasPrefix(line, "focus.animation") || strings.HasPrefix(line, "focus.effect") || strings.HasPrefix(line, "focus.strength") {
143+ if !strings.Contains(w[0].Msg, "unknown key") {
144+ t.Fatal(line, w)
145+ }
146+ }
128 }147 }
129 }148 }
130 149
A internal/adapters/drm/focus_flash_test.go +18 -0
@@ -0,0 +1,18 @@
1+package drm
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "github.com/stretchr/testify/require"
6+ "testing"
7+)
8+
9+func TestFocusFlashRequiresComposition(t *testing.T) {
10+ s, contents := fullscreenScene()
11+ s.Flash = &ports.FocusFlash{Alpha: 0.5}
12+ _, reason := scanoutCandidate(s, contents, 200, 100, nil)
13+ require.Equal(t, "focus_flash", reason)
14+ s, contents = overlayScene()
15+ s.Flash = &ports.FocusFlash{Alpha: 0.5}
16+ _, _, _, reason = overlayCandidate(s, contents, false, nil, nil)
17+ require.Equal(t, "focus_flash", reason)
18+}
@@ -0,0 +1,18 @@
1+package drm
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "github.com/stretchr/testify/require"
6+ "testing"
7+)
8+
9+func TestFocusFlashRequiresComposition(t *testing.T) {
10+ s, contents := fullscreenScene()
11+ s.Flash = &ports.FocusFlash{Alpha: 0.5}
12+ _, reason := scanoutCandidate(s, contents, 200, 100, nil)
13+ require.Equal(t, "focus_flash", reason)
14+ s, contents = overlayScene()
15+ s.Flash = &ports.FocusFlash{Alpha: 0.5}
16+ _, _, _, reason = overlayCandidate(s, contents, false, nil, nil)
17+ require.Equal(t, "focus_flash", reason)
18+}
M internal/adapters/drm/overlay.go +7 -3
@@ -66,6 +66,10 @@ type overlayWin struct {
6666 // kept is scanoutCandidate's: a window drawn from a withdrawn content is
6767 // composed, never put on the plane.
6868 func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline, kept func(ports.WindowID) bool) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) {
69+ // A hardware plane would bypass the compositor-owned flash border.
70+ if s.Flash != nil {
71+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash"
72+ }
6973 if s.Transform != 0 {
7074 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform"
7175 }
@@ -93,9 +97,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
9397 // buffer, and so is a colour the plane cannot show (raw PQ values
9498 // on an SDR output, SDR on HDR without a pipeline). A dimmed
9599 // window (a peeking stashed one) needs the veil drawn over it, a
96- // fading one its translucency, and one with the focus effect its
97- // lift; an overview preview is drawn smaller than its buffer.
98- if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) {
100+ // fading one its translucency; an overview preview is drawn smaller
101+ // than its buffer.
102+ if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) {
99103 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {
100104 pick, pickAt, mode = w, i, m
101105 continue
@@ -66,6 +66,10 @@ type overlayWin struct {
66 // kept is scanoutCandidate's: a window drawn from a withdrawn content is66 // kept is scanoutCandidate's: a window drawn from a withdrawn content is
67 // composed, never put on the plane.67 // composed, never put on the plane.
68 func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline, kept func(ports.WindowID) bool) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) {68 func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline, kept func(ports.WindowID) bool) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) {
69+ // A hardware plane would bypass the compositor-owned flash border.
70+ if s.Flash != nil {
71+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash"
72+ }
69 if s.Transform != 0 {73 if s.Transform != 0 {
70 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform"74 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform"
71 }75 }
@@ -93,9 +97,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
93 // buffer, and so is a colour the plane cannot show (raw PQ values97 // buffer, and so is a colour the plane cannot show (raw PQ values
94 // on an SDR output, SDR on HDR without a pipeline). A dimmed98 // on an SDR output, SDR on HDR without a pipeline). A dimmed
95 // window (a peeking stashed one) needs the veil drawn over it, a99 // window (a peeking stashed one) needs the veil drawn over it, a
96- // fading one its translucency, and one with the focus effect its100+ // fading one its translucency; an overview preview is drawn smaller
97- // lift; an overview preview is drawn smaller than its buffer.101+ // than its buffer.
98- if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) {102+ if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) {
99 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {103 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {
100 pick, pickAt, mode = w, i, m104 pick, pickAt, mode = w, i, m
101 continue105 continue
M internal/adapters/drm/overlay_test.go +0 -6
@@ -156,12 +156,6 @@ func TestOverlayCandidate(t *testing.T) {
156156 if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
157157 t.Fatalf("zoomed window reason %q", reason)
158158 }
159- // A window with the focus effect needs it composed.
160- s, _ = overlayScene()
161- s.Windows[len(s.Windows)-1].FocusEffect = 0.05
162- if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
163- t.Fatalf("pulsing window reason %q", reason)
164- }
165159 // An overview preview is drawn smaller than its buffer: composed.
166160 s, _ = overlayScene()
167161 s.Windows[len(s.Windows)-1].Preview = 0.5
@@ -156,12 +156,6 @@ func TestOverlayCandidate(t *testing.T) {
156 if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {156 if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
157 t.Fatalf("zoomed window reason %q", reason)157 t.Fatalf("zoomed window reason %q", reason)
158 }158 }
159- // A window with the focus effect needs it composed.
160- s, _ = overlayScene()
161- s.Windows[len(s.Windows)-1].FocusEffect = 0.05
162- if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" {
163- t.Fatalf("pulsing window reason %q", reason)
164- }
165 // An overview preview is drawn smaller than its buffer: composed.159 // An overview preview is drawn smaller than its buffer: composed.
166 s, _ = overlayScene()160 s, _ = overlayScene()
167 s.Windows[len(s.Windows)-1].Preview = 0.5161 s.Windows[len(s.Windows)-1].Preview = 0.5
M internal/adapters/drm/scanout.go +4 -0
@@ -90,6 +90,10 @@ func fullscreenShown(s *ports.Scene) bool {
9090 // closed and fades out): the client may already have its buffer back, so
9191 // it is never put on a plane (nil: none is).
9292 func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int, kept func(ports.WindowID) bool) (ports.SurfaceContent, string) {
93+ // A hardware plane would bypass the compositor-owned flash border.
94+ if s.Flash != nil {
95+ return ports.SurfaceContent{}, "focus_flash"
96+ }
9397 if s.Transform != 0 {
9498 // The scanout buffer would have to be pre-rotated: rotated outputs compose.
9599 return ports.SurfaceContent{}, "output_transform"
@@ -90,6 +90,10 @@ func fullscreenShown(s *ports.Scene) bool {
90 // closed and fades out): the client may already have its buffer back, so90 // closed and fades out): the client may already have its buffer back, so
91 // it is never put on a plane (nil: none is).91 // it is never put on a plane (nil: none is).
92 func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int, kept func(ports.WindowID) bool) (ports.SurfaceContent, string) {92 func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int, kept func(ports.WindowID) bool) (ports.SurfaceContent, string) {
93+ // A hardware plane would bypass the compositor-owned flash border.
94+ if s.Flash != nil {
95+ return ports.SurfaceContent{}, "focus_flash"
96+ }
93 if s.Transform != 0 {97 if s.Transform != 0 {
94 // The scanout buffer would have to be pre-rotated: rotated outputs compose.98 // The scanout buffer would have to be pre-rotated: rotated outputs compose.
95 return ports.SurfaceContent{}, "output_transform"99 return ports.SurfaceContent{}, "output_transform"
M internal/adapters/vulkan/damage.go +8 -0
@@ -286,6 +286,14 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim
286286 return image.Rectangle{}, false
287287 }
288288 var area image.Rectangle
289+ if (old.Flash == nil) != (cur.Flash == nil) || (old.Flash != nil && cur.Flash != nil && *old.Flash != *cur.Flash) {
290+ if old.Flash != nil {
291+ area = area.Union(phys(old.Flash.Rect))
292+ }
293+ if cur.Flash != nil {
294+ area = area.Union(phys(cur.Flash.Rect))
295+ }
296+ }
289297 for i := range old.Windows {
290298 a, b := old.Windows[i], cur.Windows[i]
291299 if a.ID != b.ID {
@@ -286,6 +286,14 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim
286 return image.Rectangle{}, false286 return image.Rectangle{}, false
287 }287 }
288 var area image.Rectangle288 var area image.Rectangle
289+ if (old.Flash == nil) != (cur.Flash == nil) || (old.Flash != nil && cur.Flash != nil && *old.Flash != *cur.Flash) {
290+ if old.Flash != nil {
291+ area = area.Union(phys(old.Flash.Rect))
292+ }
293+ if cur.Flash != nil {
294+ area = area.Union(phys(cur.Flash.Rect))
295+ }
296+ }
289 for i := range old.Windows {297 for i := range old.Windows {
290 a, b := old.Windows[i], cur.Windows[i]298 a, b := old.Windows[i], cur.Windows[i]
291 if a.ID != b.ID {299 if a.ID != b.ID {
M internal/adapters/vulkan/damage_delta_test.go +36 -4
@@ -17,9 +17,9 @@ import (
1717 // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta
1818 // lists the fields that force a full redraw.
1919 func TestSceneFieldCount(t *testing.T) {
20- // 22 with Animating, which sceneDelta intentionally ignores: it only
20+ // 23 with Animating, which sceneDelta intentionally ignores: it only
2121 // schedules the next frame, it draws nothing.
22- const fields = 22
22+ const fields = 23
2323 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),
2424 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")
2525 }
@@ -28,7 +28,7 @@ func TestSceneFieldCount(t *testing.T) {
2828 // sceneDelta compares windows by struct equality, so a new field is covered
2929 // as long as it only changes where or how that window draws (inside Rect).
3030 func TestSceneWindowFieldCount(t *testing.T) {
31- const fields = 15
31+ const fields = 14
3232 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))),
3333 "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count")
3434 }
@@ -75,7 +75,7 @@ func TestDamageDeltaAtScale(t *testing.T) {
7575 func TestDamageDeltaWindowFieldChange(t *testing.T) {
7676 old := deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
7777 cur := deltaScene(2, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
78- cur.Windows[0].FocusEffect = 0.3
78+ cur.Windows[0].Dim = 0.3
7979 d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
8080 require.False(t, d.all())
8181 assert.Equal(t, image.Rect(0, 0, 100, 100), d.area)
@@ -407,3 +407,35 @@ func TestDamageDeltaFadeAndLeavingEntry(t *testing.T) {
407407 float.Windows[1].Floating, float.Windows[1].Fade = true, 0.1
408408 assert.True(t, newDamage(held(gone), float, bounds).all())
409409 }
410+
411+func TestSceneDeltaFlashRectDamage(t *testing.T) {
412+ a := &ports.FocusFlash{Rect: ports.Rect{X: 10, Y: 20, W: 100, H: 80}, Width: 3, Color: "#ff0000", Alpha: 1}
413+ faded := *a
414+ faded.Alpha = 0.5
415+ moved := faded
416+ moved.Rect.X = 150
417+ phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X*2, r.Y*2, (r.X+r.W)*2, (r.Y+r.H)*2) }
418+ for _, tc := range []struct {
419+ name string
420+ old, cur *ports.FocusFlash
421+ want image.Rectangle
422+ }{
423+ {"appears", nil, a, phys(a.Rect)},
424+ {"alpha", a, &faded, phys(a.Rect)},
425+ {"moves", a, &moved, phys(a.Rect).Union(phys(moved.Rect))},
426+ {"removed", a, nil, phys(a.Rect)},
427+ {"idle identical", a, a, image.Rectangle{}},
428+ } {
429+ t.Run(tc.name, func(t *testing.T) {
430+ old, cur := deltaScene(1), deltaScene(2)
431+ old.Flash, cur.Flash = tc.old, tc.cur
432+ area, ok := sceneDelta(old, cur, phys, 1000000)
433+ require.True(t, ok)
434+ assert.Equal(t, tc.want, area)
435+ })
436+ }
437+ old, cur := deltaScene(1), deltaScene(2)
438+ cur.Flash = a
439+ _, ok := sceneDelta(old, cur, phys, 1)
440+ require.False(t, ok, "large damage must retain full-redraw fallback")
441+}
@@ -17,9 +17,9 @@ import (
17 // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta17 // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta
18 // lists the fields that force a full redraw.18 // lists the fields that force a full redraw.
19 func TestSceneFieldCount(t *testing.T) {19 func TestSceneFieldCount(t *testing.T) {
20- // 22 with Animating, which sceneDelta intentionally ignores: it only20+ // 23 with Animating, which sceneDelta intentionally ignores: it only
21 // schedules the next frame, it draws nothing.21 // schedules the next frame, it draws nothing.
22- const fields = 2222+ const fields = 23
23 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),23 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),
24 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")24 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")
25 }25 }
@@ -28,7 +28,7 @@ func TestSceneFieldCount(t *testing.T) {
28 // sceneDelta compares windows by struct equality, so a new field is covered28 // sceneDelta compares windows by struct equality, so a new field is covered
29 // as long as it only changes where or how that window draws (inside Rect).29 // as long as it only changes where or how that window draws (inside Rect).
30 func TestSceneWindowFieldCount(t *testing.T) {30 func TestSceneWindowFieldCount(t *testing.T) {
31- const fields = 1531+ const fields = 14
32 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))),32 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))),
33 "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count")33 "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count")
34 }34 }
@@ -75,7 +75,7 @@ func TestDamageDeltaAtScale(t *testing.T) {
75 func TestDamageDeltaWindowFieldChange(t *testing.T) {75 func TestDamageDeltaWindowFieldChange(t *testing.T) {
76 old := deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))76 old := deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
77 cur := deltaScene(2, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))77 cur := deltaScene(2, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
78- cur.Windows[0].FocusEffect = 0.378+ cur.Windows[0].Dim = 0.3
79 d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))79 d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
80 require.False(t, d.all())80 require.False(t, d.all())
81 assert.Equal(t, image.Rect(0, 0, 100, 100), d.area)81 assert.Equal(t, image.Rect(0, 0, 100, 100), d.area)
@@ -407,3 +407,35 @@ func TestDamageDeltaFadeAndLeavingEntry(t *testing.T) {
407 float.Windows[1].Floating, float.Windows[1].Fade = true, 0.1407 float.Windows[1].Floating, float.Windows[1].Fade = true, 0.1
408 assert.True(t, newDamage(held(gone), float, bounds).all())408 assert.True(t, newDamage(held(gone), float, bounds).all())
409 }409 }
410+
411+func TestSceneDeltaFlashRectDamage(t *testing.T) {
412+ a := &ports.FocusFlash{Rect: ports.Rect{X: 10, Y: 20, W: 100, H: 80}, Width: 3, Color: "#ff0000", Alpha: 1}
413+ faded := *a
414+ faded.Alpha = 0.5
415+ moved := faded
416+ moved.Rect.X = 150
417+ phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X*2, r.Y*2, (r.X+r.W)*2, (r.Y+r.H)*2) }
418+ for _, tc := range []struct {
419+ name string
420+ old, cur *ports.FocusFlash
421+ want image.Rectangle
422+ }{
423+ {"appears", nil, a, phys(a.Rect)},
424+ {"alpha", a, &faded, phys(a.Rect)},
425+ {"moves", a, &moved, phys(a.Rect).Union(phys(moved.Rect))},
426+ {"removed", a, nil, phys(a.Rect)},
427+ {"idle identical", a, a, image.Rectangle{}},
428+ } {
429+ t.Run(tc.name, func(t *testing.T) {
430+ old, cur := deltaScene(1), deltaScene(2)
431+ old.Flash, cur.Flash = tc.old, tc.cur
432+ area, ok := sceneDelta(old, cur, phys, 1000000)
433+ require.True(t, ok)
434+ assert.Equal(t, tc.want, area)
435+ })
436+ }
437+ old, cur := deltaScene(1), deltaScene(2)
438+ cur.Flash = a
439+ _, ok := sceneDelta(old, cur, phys, 1)
440+ require.False(t, ok, "large damage must retain full-redraw fallback")
441+}
A internal/adapters/vulkan/flash_perf_test.go +19 -0
@@ -0,0 +1,19 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func BenchmarkFlashSceneWalk(b *testing.B) {
11+ r := &Renderer{width: 1000, height: 600}
12+ w := sceneWalk{r: r, s: ports.Scene{Flash: &ports.FocusFlash{Rect: ports.Rect{X: 10, Y: 10, W: 300, H: 200}, Width: 4, Color: "#ff0000", Alpha: 0.5}}, scale: 1, zoom: 1, alpha: 1, bounds: image.Rect(0, 0, 1000, 600), draws: make([]draw, 0, 4)}
13+ b.ReportAllocs()
14+ b.ResetTimer()
15+ for range b.N {
16+ w.draws = w.draws[:0]
17+ w.focusFlash()
18+ }
19+}
@@ -0,0 +1,19 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func BenchmarkFlashSceneWalk(b *testing.B) {
11+ r := &Renderer{width: 1000, height: 600}
12+ w := sceneWalk{r: r, s: ports.Scene{Flash: &ports.FocusFlash{Rect: ports.Rect{X: 10, Y: 10, W: 300, H: 200}, Width: 4, Color: "#ff0000", Alpha: 0.5}}, scale: 1, zoom: 1, alpha: 1, bounds: image.Rect(0, 0, 1000, 600), draws: make([]draw, 0, 4)}
13+ b.ReportAllocs()
14+ b.ResetTimer()
15+ for range b.N {
16+ w.draws = w.draws[:0]
17+ w.focusFlash()
18+ }
19+}
A internal/adapters/vulkan/focus_flash_test.go +45 -0
@@ -0,0 +1,45 @@
1+package vulkan
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "github.com/stretchr/testify/require"
6+ "image"
7+ "testing"
8+)
9+
10+func TestFocusFlashGeometryAlphaAndOrder(t *testing.T) {
11+ for _, scale := range []int{1, 2} {
12+ r := &Renderer{width: 128, height: 128}
13+ s := ports.Scene{Scale: float64(scale), Border: ports.Border{Active: "#00ff00"},
14+ Separators: []ports.Separator{{Rect: ports.Rect{W: 2, H: 20}, Active: true}},
15+ Flash: &ports.FocusFlash{Rect: ports.Rect{X: 8, Y: 4, W: 16, H: 8}, Width: 2, Color: "#ff0000", Alpha: 0.5},
16+ CaptureIndicators: []ports.CaptureIndicator{{Rect: ports.Rect{W: 64, H: 64}}}}
17+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128)))
18+ require.Len(t, ds, 9)
19+ require.Equal(t, [4]float32{0, 1, 0, 1}, ds[0].pc.color)
20+ for i, rect := range [][4]int32{{8, 4, 24, 6}, {8, 10, 24, 12}, {8, 6, 10, 10}, {22, 6, 24, 10}} {
21+ for j := range rect {
22+ rect[j] *= int32(scale)
23+ }
24+ require.Equal(t, rect, ds[i+1].pc.rect)
25+ require.Equal(t, float32(0.5), ds[i+1].pc.color[3])
26+ require.Greater(t, ds[i+1].pc.color[0], float32(0))
27+ require.Zero(t, ds[i+1].pc.color[1])
28+ }
29+ require.Equal(t, float32(1), ds[5].pc.color[3], "capture indicator remains opaque and above flash")
30+ }
31+}
32+
33+func TestFocusFlashAbsentEmptyAndThin(t *testing.T) {
34+ r := &Renderer{width: 64, height: 64}
35+ for _, f := range []*ports.FocusFlash{nil, {Alpha: 0}, {Alpha: -1}, {Alpha: 1, Rect: ports.Rect{W: 0, H: 10}}} {
36+ s := ports.Scene{Scale: 1, Flash: f}
37+ require.Empty(t, r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64))))
38+ }
39+ for _, width := range []int{0, 8} {
40+ s := ports.Scene{Scale: 1, Flash: &ports.FocusFlash{Alpha: 1, Width: width, Rect: ports.Rect{X: 2, Y: 3, W: 1, H: 10}}}
41+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64)))
42+ require.Len(t, ds, 1)
43+ require.Equal(t, [4]int32{2, 3, 3, 13}, ds[0].pc.rect)
44+ }
45+}
@@ -0,0 +1,45 @@
1+package vulkan
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "github.com/stretchr/testify/require"
6+ "image"
7+ "testing"
8+)
9+
10+func TestFocusFlashGeometryAlphaAndOrder(t *testing.T) {
11+ for _, scale := range []int{1, 2} {
12+ r := &Renderer{width: 128, height: 128}
13+ s := ports.Scene{Scale: float64(scale), Border: ports.Border{Active: "#00ff00"},
14+ Separators: []ports.Separator{{Rect: ports.Rect{W: 2, H: 20}, Active: true}},
15+ Flash: &ports.FocusFlash{Rect: ports.Rect{X: 8, Y: 4, W: 16, H: 8}, Width: 2, Color: "#ff0000", Alpha: 0.5},
16+ CaptureIndicators: []ports.CaptureIndicator{{Rect: ports.Rect{W: 64, H: 64}}}}
17+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128)))
18+ require.Len(t, ds, 9)
19+ require.Equal(t, [4]float32{0, 1, 0, 1}, ds[0].pc.color)
20+ for i, rect := range [][4]int32{{8, 4, 24, 6}, {8, 10, 24, 12}, {8, 6, 10, 10}, {22, 6, 24, 10}} {
21+ for j := range rect {
22+ rect[j] *= int32(scale)
23+ }
24+ require.Equal(t, rect, ds[i+1].pc.rect)
25+ require.Equal(t, float32(0.5), ds[i+1].pc.color[3])
26+ require.Greater(t, ds[i+1].pc.color[0], float32(0))
27+ require.Zero(t, ds[i+1].pc.color[1])
28+ }
29+ require.Equal(t, float32(1), ds[5].pc.color[3], "capture indicator remains opaque and above flash")
30+ }
31+}
32+
33+func TestFocusFlashAbsentEmptyAndThin(t *testing.T) {
34+ r := &Renderer{width: 64, height: 64}
35+ for _, f := range []*ports.FocusFlash{nil, {Alpha: 0}, {Alpha: -1}, {Alpha: 1, Rect: ports.Rect{W: 0, H: 10}}} {
36+ s := ports.Scene{Scale: 1, Flash: f}
37+ require.Empty(t, r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64))))
38+ }
39+ for _, width := range []int{0, 8} {
40+ s := ports.Scene{Scale: 1, Flash: &ports.FocusFlash{Alpha: 1, Width: width, Rect: ports.Rect{X: 2, Y: 3, W: 1, H: 10}}}
41+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64)))
42+ require.Len(t, ds, 1)
43+ require.Equal(t, [4]int32{2, 3, 3, 13}, ds[0].pc.rect)
44+ }
45+}
M internal/adapters/vulkan/hdr_composition_test.go +0 -19
@@ -259,25 +259,6 @@ func TestHDRLinearBlend(t *testing.T) {
259259 }
260260 }
261261
262-// The focus pulse on an HDR output applies the SDR lift to sRGB content
263-// before decoding: sRGB 64 at 0.1 shows as sRGB 83.1 would. A dark input
264-// separates this from a lift in linear light or none at all.
265-func TestHDRPulse(t *testing.T) {
266- r := hdrTestRenderer(t)
267- c := shmContent(t, 64, 16, 64*4, fill(64, 16, [4]byte{64, 64, 64, 255}))
268- scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}, FocusEffect: 0.1}}}
269- if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {
270- t.Fatal(err)
271- }
272- got := hdrTargetAt(t, r, 5, 5)
273- want := pqEncode(srgbToLinear((64+0.1*(255-64))/255) * 203)
274- for i, v := range got {
275- if math.Abs(v-want) > .006 {
276- t.Errorf("channel %d: got %.4f want %.4f", i, v, want)
277- }
278- }
279-}
280-
281262 func TestHDRSolidColorsMatchPartialRedraw(t *testing.T) {
282263 r := hdrTestRenderer(t)
283264 const background = "#804020"
@@ -259,25 +259,6 @@ func TestHDRLinearBlend(t *testing.T) {
259 }259 }
260 }260 }
261 261
262-// The focus pulse on an HDR output applies the SDR lift to sRGB content
263-// before decoding: sRGB 64 at 0.1 shows as sRGB 83.1 would. A dark input
264-// separates this from a lift in linear light or none at all.
265-func TestHDRPulse(t *testing.T) {
266- r := hdrTestRenderer(t)
267- c := shmContent(t, 64, 16, 64*4, fill(64, 16, [4]byte{64, 64, 64, 255}))
268- scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}, FocusEffect: 0.1}}}
269- if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {
270- t.Fatal(err)
271- }
272- got := hdrTargetAt(t, r, 5, 5)
273- want := pqEncode(srgbToLinear((64+0.1*(255-64))/255) * 203)
274- for i, v := range got {
275- if math.Abs(v-want) > .006 {
276- t.Errorf("channel %d: got %.4f want %.4f", i, v, want)
277- }
278- }
279-}
280-
281 func TestHDRSolidColorsMatchPartialRedraw(t *testing.T) {262 func TestHDRSolidColorsMatchPartialRedraw(t *testing.T) {
282 r := hdrTestRenderer(t)263 r := hdrTestRenderer(t)
283 const background = "#804020"264 const background = "#804020"
M internal/adapters/vulkan/renderer_test.go +0 -24
@@ -111,30 +111,6 @@ func TestRendererFade(t *testing.T) {
111111 }
112112 }
113113
114-// A pulsing window lifts toward white: black the most, white not at all.
115-func TestRendererPulse(t *testing.T) {
116- r, err := New(64, 48)
117- if err != nil {
118- t.Skipf("Vulkan unavailable: %v", err)
119- }
120- defer r.Close()
121- for i, tc := range []struct {
122- in, want uint8
123- }{{0, 26}, {64, 83}, {200, 206}, {255, 255}} {
124- // One window per case: the shm copy is cached per window.
125- id := ports.WindowID(i + 1)
126- c := solidContent(t, 64, 48, color.RGBA{tc.in, tc.in, tc.in, 255})
127- s := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: id, Rect: ports.Rect{W: 64, H: 48}, FocusEffect: 0.1}}}
128- if err := render(r, s, map[ports.WindowID]ports.SurfaceContent{id: c}); err != nil {
129- t.Fatal(err)
130- }
131- got := readPixels(t, r).At(10, 10).(color.RGBA)
132- if d := int(got.R) - int(tc.want); d < -1 || d > 1 || got.R != got.G || got.G != got.B {
133- t.Errorf("pulse of %d = %v, want %d", tc.in, got, tc.want)
134- }
135- }
136-}
137-
138114 func TestRendererContents(t *testing.T) {
139115 r, err := New(64, 48)
140116 if err != nil {
@@ -111,30 +111,6 @@ func TestRendererFade(t *testing.T) {
111 }111 }
112 }112 }
113 113
114-// A pulsing window lifts toward white: black the most, white not at all.
115-func TestRendererPulse(t *testing.T) {
116- r, err := New(64, 48)
117- if err != nil {
118- t.Skipf("Vulkan unavailable: %v", err)
119- }
120- defer r.Close()
121- for i, tc := range []struct {
122- in, want uint8
123- }{{0, 26}, {64, 83}, {200, 206}, {255, 255}} {
124- // One window per case: the shm copy is cached per window.
125- id := ports.WindowID(i + 1)
126- c := solidContent(t, 64, 48, color.RGBA{tc.in, tc.in, tc.in, 255})
127- s := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: id, Rect: ports.Rect{W: 64, H: 48}, FocusEffect: 0.1}}}
128- if err := render(r, s, map[ports.WindowID]ports.SurfaceContent{id: c}); err != nil {
129- t.Fatal(err)
130- }
131- got := readPixels(t, r).At(10, 10).(color.RGBA)
132- if d := int(got.R) - int(tc.want); d < -1 || d > 1 || got.R != got.G || got.G != got.B {
133- t.Errorf("pulse of %d = %v, want %d", tc.in, got, tc.want)
134- }
135- }
136-}
137-
138 func TestRendererContents(t *testing.T) {114 func TestRendererContents(t *testing.T) {
139 r, err := New(64, 48)115 r, err := New(64, 48)
140 if err != nil {116 if err != nil {
M internal/adapters/vulkan/scene.go +28 -18
@@ -20,9 +20,6 @@ type sceneWalk struct {
2020 // zoom shrinks the surfaces of the window being placed: an overview
2121 // preview (ports.SceneWindow.Preview); 1 otherwise.
2222 zoom float64
23- // pulse is the focus effect of the window being placed
24- // (ports.SceneWindow.FocusEffect); 0 otherwise.
25- pulse float32
2623 // alpha is the opacity of the window being drawn, 1 - its Fade
2724 // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by
2825 // it (premultiplied); 1 otherwise.
@@ -61,11 +58,37 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
6158 w.bounds = outputBounds
6259 w.layers(true)
6360 w.popups(true)
61+ w.focusFlash()
6462 w.captureIndicators()
6563 r.scratchDraws = w.draws
6664 return w.draws
6765 }
6866
67+// focusFlash draws an inner border in logical coordinates, above the scene
68+// but below capture indicators. Restore alpha so later overlays stay opaque.
69+func (w *sceneWalk) focusFlash() {
70+ f := w.s.Flash
71+ if f == nil || f.Alpha <= 0 || f.Rect.W <= 0 || f.Rect.H <= 0 {
72+ return
73+ }
74+ alpha := w.alpha
75+ w.alpha = float32(f.Alpha)
76+ w.innerBorder(f.Rect, min(f.Width, f.Rect.W/2, f.Rect.H/2), parseColor(f.Color))
77+ w.alpha = alpha
78+}
79+
80+// innerBorder draws four non-overlapping strips, or fills a thin target.
81+func (w *sceneWalk) innerBorder(t ports.Rect, b int, col [3]uint8) {
82+ if b <= 0 {
83+ w.fill(w.physRect(t.X, t.Y, t.W, t.H), col)
84+ return
85+ }
86+ w.fill(w.physRect(t.X, t.Y, t.W, b), col)
87+ w.fill(w.physRect(t.X, t.Y+t.H-b, t.W, b), col)
88+ w.fill(w.physRect(t.X, t.Y+b, b, t.H-2*b), col)
89+ w.fill(w.physRect(t.X+t.W-b, t.Y+b, b, t.H-2*b), col)
90+}
91+
6992 // captureIndicators draws the compositor's capture indicator over everything
7093 // (ports.CaptureIndicator). The border is four fills on the inside edge of the
7194 // target, so it stays visible on a monitor edge; the pill is a small rounded
@@ -86,17 +109,7 @@ func (w *sceneWalk) captureIndicators() {
86109 w.pill(w.physRect(t.X, t.Y, t.W, t.H), col)
87110 continue
88111 }
89- b := ports.CaptureBorderOf(t.W, t.H)
90- if b <= 0 {
91- // Too thin for a border (core inflates such marks, so this is a
92- // 1-pixel output): a listed mark always draws something.
93- w.fill(w.physRect(t.X, t.Y, t.W, t.H), col)
94- continue
95- }
96- w.fill(w.physRect(t.X, t.Y, t.W, b), col)
97- w.fill(w.physRect(t.X, t.Y+t.H-b, t.W, b), col)
98- w.fill(w.physRect(t.X, t.Y+b, b, t.H-2*b), col)
99- w.fill(w.physRect(t.X+t.W-b, t.Y+b, b, t.H-2*b), col)
112+ w.innerBorder(t, ports.CaptureBorderOf(t.W, t.H), col)
100113 }
101114 }
102115
@@ -251,9 +264,8 @@ func (w *sceneWalk) windows() {
251264 } else if win.Preview > 0 {
252265 w.zoom = win.Preview
253266 }
254- w.pulse = float32(max(0, min(win.FocusEffect, 1)))
255267 w.place(win.ID, &content, c.X, c.Y, c.W, c.H)
256- w.zoom, w.pulse = 1, 0
268+ w.zoom = 1
257269 }
258270 if win.Floating {
259271 w.separators(win.ID)
@@ -543,7 +555,6 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
543555 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque)
544556 r.setContentColor(&dr, content)
545557 dr.pc.color[3] *= w.alpha
546- dr.pc.mapy[2] = w.pulse
547558 if im.yuv {
548559 dr.pc.misc[1] |= flagYUV
549560 if content.DMABuf.Format == fourcc('P', '0', '1', '0') {
@@ -590,7 +601,6 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
590601 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque)
591602 r.setContentColor(&dr, content)
592603 dr.pc.color[3] *= w.alpha
593- dr.pc.mapy[2] = w.pulse
594604 dr.shm = c
595605 dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0}
596606 w.draws = append(w.draws, dr)
@@ -20,9 +20,6 @@ type sceneWalk struct {
20 // zoom shrinks the surfaces of the window being placed: an overview20 // zoom shrinks the surfaces of the window being placed: an overview
21 // preview (ports.SceneWindow.Preview); 1 otherwise.21 // preview (ports.SceneWindow.Preview); 1 otherwise.
22 zoom float6422 zoom float64
23- // pulse is the focus effect of the window being placed
24- // (ports.SceneWindow.FocusEffect); 0 otherwise.
25- pulse float32
26 // alpha is the opacity of the window being drawn, 1 - its Fade23 // alpha is the opacity of the window being drawn, 1 - its Fade
27 // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by24 // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by
28 // it (premultiplied); 1 otherwise.25 // it (premultiplied); 1 otherwise.
@@ -61,11 +58,37 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
61 w.bounds = outputBounds58 w.bounds = outputBounds
62 w.layers(true)59 w.layers(true)
63 w.popups(true)60 w.popups(true)
61+ w.focusFlash()
64 w.captureIndicators()62 w.captureIndicators()
65 r.scratchDraws = w.draws63 r.scratchDraws = w.draws
66 return w.draws64 return w.draws
67 }65 }
68 66
67+// focusFlash draws an inner border in logical coordinates, above the scene
68+// but below capture indicators. Restore alpha so later overlays stay opaque.
69+func (w *sceneWalk) focusFlash() {
70+ f := w.s.Flash
71+ if f == nil || f.Alpha <= 0 || f.Rect.W <= 0 || f.Rect.H <= 0 {
72+ return
73+ }
74+ alpha := w.alpha
75+ w.alpha = float32(f.Alpha)
76+ w.innerBorder(f.Rect, min(f.Width, f.Rect.W/2, f.Rect.H/2), parseColor(f.Color))
77+ w.alpha = alpha
78+}
79+
80+// innerBorder draws four non-overlapping strips, or fills a thin target.
81+func (w *sceneWalk) innerBorder(t ports.Rect, b int, col [3]uint8) {
82+ if b <= 0 {
83+ w.fill(w.physRect(t.X, t.Y, t.W, t.H), col)
84+ return
85+ }
86+ w.fill(w.physRect(t.X, t.Y, t.W, b), col)
87+ w.fill(w.physRect(t.X, t.Y+t.H-b, t.W, b), col)
88+ w.fill(w.physRect(t.X, t.Y+b, b, t.H-2*b), col)
89+ w.fill(w.physRect(t.X+t.W-b, t.Y+b, b, t.H-2*b), col)
90+}
91+
69 // captureIndicators draws the compositor's capture indicator over everything92 // captureIndicators draws the compositor's capture indicator over everything
70 // (ports.CaptureIndicator). The border is four fills on the inside edge of the93 // (ports.CaptureIndicator). The border is four fills on the inside edge of the
71 // target, so it stays visible on a monitor edge; the pill is a small rounded94 // target, so it stays visible on a monitor edge; the pill is a small rounded
@@ -86,17 +109,7 @@ func (w *sceneWalk) captureIndicators() {
86 w.pill(w.physRect(t.X, t.Y, t.W, t.H), col)109 w.pill(w.physRect(t.X, t.Y, t.W, t.H), col)
87 continue110 continue
88 }111 }
89- b := ports.CaptureBorderOf(t.W, t.H)112+ w.innerBorder(t, ports.CaptureBorderOf(t.W, t.H), col)
90- if b <= 0 {
91- // Too thin for a border (core inflates such marks, so this is a
92- // 1-pixel output): a listed mark always draws something.
93- w.fill(w.physRect(t.X, t.Y, t.W, t.H), col)
94- continue
95- }
96- w.fill(w.physRect(t.X, t.Y, t.W, b), col)
97- w.fill(w.physRect(t.X, t.Y+t.H-b, t.W, b), col)
98- w.fill(w.physRect(t.X, t.Y+b, b, t.H-2*b), col)
99- w.fill(w.physRect(t.X+t.W-b, t.Y+b, b, t.H-2*b), col)
100 }113 }
101 }114 }
102 115
@@ -251,9 +264,8 @@ func (w *sceneWalk) windows() {
251 } else if win.Preview > 0 {264 } else if win.Preview > 0 {
252 w.zoom = win.Preview265 w.zoom = win.Preview
253 }266 }
254- w.pulse = float32(max(0, min(win.FocusEffect, 1)))
255 w.place(win.ID, &content, c.X, c.Y, c.W, c.H)267 w.place(win.ID, &content, c.X, c.Y, c.W, c.H)
256- w.zoom, w.pulse = 1, 0268+ w.zoom = 1
257 }269 }
258 if win.Floating {270 if win.Floating {
259 w.separators(win.ID)271 w.separators(win.ID)
@@ -543,7 +555,6 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
543 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque)555 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque)
544 r.setContentColor(&dr, content)556 r.setContentColor(&dr, content)
545 dr.pc.color[3] *= w.alpha557 dr.pc.color[3] *= w.alpha
546- dr.pc.mapy[2] = w.pulse
547 if im.yuv {558 if im.yuv {
548 dr.pc.misc[1] |= flagYUV559 dr.pc.misc[1] |= flagYUV
549 if content.DMABuf.Format == fourcc('P', '0', '1', '0') {560 if content.DMABuf.Format == fourcc('P', '0', '1', '0') {
@@ -590,7 +601,6 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
590 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque)601 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque)
591 r.setContentColor(&dr, content)602 r.setContentColor(&dr, content)
592 dr.pc.color[3] *= w.alpha603 dr.pc.color[3] *= w.alpha
593- dr.pc.mapy[2] = w.pulse
594 dr.shm = c604 dr.shm = c
595 dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0}605 dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0}
596 w.draws = append(w.draws, dr)606 w.draws = append(w.draws, dr)
M internal/adapters/vulkan/shaders/compose.frag +0 -6
@@ -146,12 +146,6 @@ void main() {
146146 c.rgb = mix(v * 12.92, 1.055 * pow(v, vec3(1.0 / 2.4)) - 0.055, greaterThan(v, vec3(0.0031308))) * c.a;
147147 }
148148 }
149- if (d.mapy.z > 0.0) {
150- // Focus pulse: a screen-like lift toward white, on premultiplied
151- // values (rgb/a + k * (1 - rgb/a)) * a. Black shows it most; white
152- // never saturates.
153- c.rgb += d.mapy.z * (c.a - c.rgb);
154- }
155149 if (d.misc.x != modeSolid) {
156150 // The surface's alpha modifier; premultiplied, it scales all four.
157151 c *= d.color.a;
@@ -146,12 +146,6 @@ void main() {
146 c.rgb = mix(v * 12.92, 1.055 * pow(v, vec3(1.0 / 2.4)) - 0.055, greaterThan(v, vec3(0.0031308))) * c.a;146 c.rgb = mix(v * 12.92, 1.055 * pow(v, vec3(1.0 / 2.4)) - 0.055, greaterThan(v, vec3(0.0031308))) * c.a;
147 }147 }
148 }148 }
149- if (d.mapy.z > 0.0) {
150- // Focus pulse: a screen-like lift toward white, on premultiplied
151- // values (rgb/a + k * (1 - rgb/a)) * a. Black shows it most; white
152- // never saturates.
153- c.rgb += d.mapy.z * (c.a - c.rgb);
154- }
155 if (d.misc.x != modeSolid) {149 if (d.misc.x != modeSolid) {
156 // The surface's alpha modifier; premultiplied, it scales all four.150 // The surface's alpha modifier; premultiplied, it scales all four.
157 c *= d.color.a;151 c *= d.color.a;
M internal/adapters/vulkan/shaders/compose.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/vulkan/shaders/compose.vert +1 -1
@@ -5,7 +5,7 @@
55 struct Draw {
66 ivec4 rect; // visible x0, y0, x1, y1 in target pixels
77 vec4 map; // source texel = map.xy + fragment centre.x * map.zw
8- vec4 mapy; // + fragment centre.y * mapy.xy; mapy.z is the focus pulse
8+ vec4 mapy; // + fragment centre.y * mapy.xy; mapy.z and w are unused
99 vec4 color; // solid premultiplied RGBA; content opacity in alpha
1010 vec4 crop; // source crop bounds in buffer pixels
1111 uvec4 buf; // pixel-buffer first word, width, height; YUV metadata in w
@@ -5,7 +5,7 @@
5 struct Draw {5 struct Draw {
6 ivec4 rect; // visible x0, y0, x1, y1 in target pixels6 ivec4 rect; // visible x0, y0, x1, y1 in target pixels
7 vec4 map; // source texel = map.xy + fragment centre.x * map.zw7 vec4 map; // source texel = map.xy + fragment centre.x * map.zw
8- vec4 mapy; // + fragment centre.y * mapy.xy; mapy.z is the focus pulse8+ vec4 mapy; // + fragment centre.y * mapy.xy; mapy.z and w are unused
9 vec4 color; // solid premultiplied RGBA; content opacity in alpha9 vec4 color; // solid premultiplied RGBA; content opacity in alpha
10 vec4 crop; // source crop bounds in buffer pixels10 vec4 crop; // source crop bounds in buffer pixels
11 uvec4 buf; // pixel-buffer first word, width, height; YUV metadata in w11 uvec4 buf; // pixel-buffer first word, width, height; YUV metadata in w
M internal/adapters/vulkan/shaders/compose_hdr.frag +0 -6
@@ -141,12 +141,6 @@ void main() {
141141 // ext_linear has the protocol's default 80 cd/m² reference white.
142142 // Keep negative and above-white values in fp16 until the HDR pass.
143143 vec3 v = c.rgb / c.a;
144- bool srgb = (d.misc.y & (flagPQ | flagExtendedLinear)) == 0u;
145- if (srgb && d.mapy.z > 0.0) {
146- // Focus pulse: the SDR lift, on sRGB-encoded values before
147- // decoding, so it looks the same as on an SDR output.
148- v += d.mapy.z * (1.0 - v);
149- }
150144 vec3 linearRGB = (d.misc.y & flagPQ) != 0u ? pqToLinear709(v, d.color.x) :
151145 (d.misc.y & flagExtendedLinear) != 0u ? v * (80.0 / d.color.x) : decodeSRGB(v);
152146 c.rgb = linearRGB * c.a;
@@ -141,12 +141,6 @@ void main() {
141 // ext_linear has the protocol's default 80 cd/m² reference white.141 // ext_linear has the protocol's default 80 cd/m² reference white.
142 // Keep negative and above-white values in fp16 until the HDR pass.142 // Keep negative and above-white values in fp16 until the HDR pass.
143 vec3 v = c.rgb / c.a;143 vec3 v = c.rgb / c.a;
144- bool srgb = (d.misc.y & (flagPQ | flagExtendedLinear)) == 0u;
145- if (srgb && d.mapy.z > 0.0) {
146- // Focus pulse: the SDR lift, on sRGB-encoded values before
147- // decoding, so it looks the same as on an SDR output.
148- v += d.mapy.z * (1.0 - v);
149- }
150 vec3 linearRGB = (d.misc.y & flagPQ) != 0u ? pqToLinear709(v, d.color.x) :144 vec3 linearRGB = (d.misc.y & flagPQ) != 0u ? pqToLinear709(v, d.color.x) :
151 (d.misc.y & flagExtendedLinear) != 0u ? v * (80.0 / d.color.x) : decodeSRGB(v);145 (d.misc.y & flagExtendedLinear) != 0u ? v * (80.0 / d.color.x) : decodeSRGB(v);
152 c.rgb = linearRGB * c.a;146 c.rgb = linearRGB * c.a;
M internal/adapters/vulkan/shaders/compose_hdr.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/vulkan/shaders/compose_hdr_single.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/vulkan/shaders/compose_single.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/app/app.go +0 -4
@@ -176,10 +176,6 @@ func logScenes(log zerowrap.Logger, set []ports.Scene) {
176176 rects := make([]string, 0, len(s.Windows))
177177 for _, w := range s.Windows {
178178 rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
179- if w.FocusEffect > 0 {
180- // The focus indicator frame by frame: window and effect.
181- ev = ev.Uint64("focus_effect_window", uint64(w.ID)).Float64("focus_effect", w.FocusEffect)
182- }
183179 }
184180 ev.Strs("windows", rects).Msg("scene")
185181 }
@@ -176,10 +176,6 @@ func logScenes(log zerowrap.Logger, set []ports.Scene) {
176 rects := make([]string, 0, len(s.Windows))176 rects := make([]string, 0, len(s.Windows))
177 for _, w := range s.Windows {177 for _, w := range s.Windows {
178 rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))178 rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
179- if w.FocusEffect > 0 {
180- // The focus indicator frame by frame: window and effect.
181- ev = ev.Uint64("focus_effect_window", uint64(w.ID)).Float64("focus_effect", w.FocusEffect)
182- }
183 }179 }
184 ev.Strs("windows", rects).Msg("scene")180 ev.Strs("windows", rects).Msg("scene")
185 }181 }
M internal/app/app_test.go +0 -2
@@ -243,8 +243,6 @@ func headlessPointerClickFocus(t *testing.T, animated bool) {
243243 t.Setenv("XDG_STATE_HOME", t.TempDir())
244244 cfg := config.Defaults()
245245 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
246- // Pulse frames would fill the scene tap before the click.
247- cfg.Focus.Animation = ports.FocusAnimationOff
248246 cfg.Animations.On = animated
249247 // The tap drops a scene it has no room for: a collector keeps up with
250248 // the animation frames.
@@ -243,8 +243,6 @@ func headlessPointerClickFocus(t *testing.T, animated bool) {
243 t.Setenv("XDG_STATE_HOME", t.TempDir())243 t.Setenv("XDG_STATE_HOME", t.TempDir())
244 cfg := config.Defaults()244 cfg := config.Defaults()
245 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}245 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
246- // Pulse frames would fill the scene tap before the click.
247- cfg.Focus.Animation = ports.FocusAnimationOff
248 cfg.Animations.On = animated246 cfg.Animations.On = animated
249 // The tap drops a scene it has no room for: a collector keeps up with247 // The tap drops a scene it has no room for: a collector keeps up with
250 // the animation frames.248 // the animation frames.
M internal/app/scenelog_test.go +2 -2
@@ -13,7 +13,7 @@ func logTestScenes() []ports.Scene {
1313 Output: "A", Seq: 7, OutputWidth: 1920, OutputHeight: 1080, Scale: 1,
1414 Windows: []ports.SceneWindow{
1515 {ID: 1, Rect: ports.Rect{X: 10, Y: 20, W: 300, H: 200}},
16- {ID: 2, Rect: ports.Rect{X: 5, Y: 6, W: 7, H: 8}, FocusEffect: 0.5},
16+ {ID: 2, Rect: ports.Rect{X: 5, Y: 6, W: 7, H: 8}},
1717 },
1818 }, {Output: "B"}}
1919 }
@@ -40,7 +40,7 @@ func TestLogScenesDebug(t *testing.T) {
4040 for _, want := range []string{
4141 `"output":"A"`, `"seq":7`, `"out_w":1920`, `"out_h":1080`, `"scale":1`,
4242 `"windows":["1:10,20 300x200","2:5,6 7x8"]`,
43- `"focus_effect_window":2`, `"focus_effect":0.5`, `"message":"scene"`,
43+ `"message":"scene"`,
4444 } {
4545 if !strings.Contains(got, want) {
4646 t.Fatalf("debug log lacks %s: %s", want, got)
@@ -13,7 +13,7 @@ func logTestScenes() []ports.Scene {
13 Output: "A", Seq: 7, OutputWidth: 1920, OutputHeight: 1080, Scale: 1,13 Output: "A", Seq: 7, OutputWidth: 1920, OutputHeight: 1080, Scale: 1,
14 Windows: []ports.SceneWindow{14 Windows: []ports.SceneWindow{
15 {ID: 1, Rect: ports.Rect{X: 10, Y: 20, W: 300, H: 200}},15 {ID: 1, Rect: ports.Rect{X: 10, Y: 20, W: 300, H: 200}},
16- {ID: 2, Rect: ports.Rect{X: 5, Y: 6, W: 7, H: 8}, FocusEffect: 0.5},16+ {ID: 2, Rect: ports.Rect{X: 5, Y: 6, W: 7, H: 8}},
17 },17 },
18 }, {Output: "B"}}18 }, {Output: "B"}}
19 }19 }
@@ -40,7 +40,7 @@ func TestLogScenesDebug(t *testing.T) {
40 for _, want := range []string{40 for _, want := range []string{
41 `"output":"A"`, `"seq":7`, `"out_w":1920`, `"out_h":1080`, `"scale":1`,41 `"output":"A"`, `"seq":7`, `"out_w":1920`, `"out_h":1080`, `"scale":1`,
42 `"windows":["1:10,20 300x200","2:5,6 7x8"]`,42 `"windows":["1:10,20 300x200","2:5,6 7x8"]`,
43- `"focus_effect_window":2`, `"focus_effect":0.5`, `"message":"scene"`,43+ `"message":"scene"`,
44 } {44 } {
45 if !strings.Contains(got, want) {45 if !strings.Contains(got, want) {
46 t.Fatalf("debug log lacks %s: %s", want, got)46 t.Fatalf("debug log lacks %s: %s", want, got)
M internal/core/action.go +74 -0
@@ -1,6 +1,7 @@
11 package core
22
33 import (
4+ "slices"
45 "strconv"
56 "strings"
67
@@ -213,6 +214,79 @@ 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+ if sc == beforeScreen && sc.mon.Current() != beforeWS {
276+ return
277+ }
278+ after := sc.mon.Layout()
279+ changed := sc != beforeScreen || sc.mon.Current() != beforeWS || id != beforeID || !slices.EqualFunc(before, after, func(a, b Placement) bool {
280+ return a.ID == b.ID && a.Rect == b.Rect && a.Focused == b.Focused && a.Hidden == b.Hidden && a.Floating == b.Floating
281+ })
282+ if changed && id != 0 {
283+ c.requestFlash(level, id)
284+ }
285+ }()
286+ return c.dispatchAction(a)
287+}
288+
289+func (c *Core) dispatchAction(a Action) Effect {
216290 if e, ok := c.cur().mon.overviewAction(a); ok {
217291 return e
218292 }
@@ -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,79 @@ 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+ if sc == beforeScreen && sc.mon.Current() != beforeWS {
276+ return
277+ }
278+ after := sc.mon.Layout()
279+ changed := sc != beforeScreen || sc.mon.Current() != beforeWS || id != beforeID || !slices.EqualFunc(before, after, func(a, b Placement) bool {
280+ return a.ID == b.ID && a.Rect == b.Rect && a.Focused == b.Focused && a.Hidden == b.Hidden && a.Floating == b.Floating
281+ })
282+ if changed && id != 0 {
283+ c.requestFlash(level, id)
284+ }
285+ }()
286+ return c.dispatchAction(a)
287+}
288+
289+func (c *Core) dispatchAction(a Action) Effect {
216 if e, ok := c.cur().mon.overviewAction(a); ok {290 if e, ok := c.cur().mon.overviewAction(a); ok {
217 return e291 return e
218 }292 }
M internal/core/core.go +11 -47
@@ -155,9 +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
159- // pulse marks a window that just got the focus (pulse.go).
160- pulse focusPulse
158+ capt captureState
159+ flash focusFlash
161160 // switcher is the column switcher in progress: the screen it is on and
162161 // the timer that shows its cards (switcher.go).
163162 switcher struct {
@@ -532,14 +531,6 @@ func (v viewport) area(p Placement) Rect {
532531 return v.usable
533532 }
534533
535-// hasRoom reports whether p's area can show anything: panels can leave
536-// tiles no room at all. Unlike shows, it ignores where p is, so a window
537-// still sliding in counts.
538-func hasRoom(v viewport, p Placement) bool {
539- a := v.area(p)
540- return a.W > 0 && a.H > 0
541-}
542-
543534 // shows reports whether the user sees p: not hidden, and overlapping its
544535 // area.
545536 func (v viewport) shows(p Placement) bool {
@@ -636,9 +627,8 @@ func (c *Core) setLayers(all []ports.LayerSurface) {
636627 func (c *Core) publish(ctx context.Context) error { return c.publishFrame(ctx, nil) }
637628
638629 // publishFrame publishes the scenes. only is the screen whose page flip
639-// moved the springs (nil: any, or none moved): the pulse advances with the
640-// focused screen's flip, and only a flip's own screen gets a fresh Seq for
641-// its animation, so another output keeps its Seq while it draws the same.
630+// moved the springs (nil: any, or none moved): only a flip's own screen gets
631+// a fresh Seq for its animation, so another output keeps its Seq while it draws the same.
642632 func (c *Core) publishFrame(ctx context.Context, only *screen) error {
643633 if c.inputEpochChanged() {
644634 return errSecurityChanged
@@ -685,44 +675,18 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
685675 return err
686676 }
687677 focus := c.keyboardFocus()
688- window, _ := c.cur().mon.Focused()
689- c.pulseFocus(window, focus == window)
690- // The pulse runs on the focused output: a flip of another output reuses
691- // its value so the focused scene keeps its Seq.
692- if c.pulse.id == 0 {
693- c.pulse.value = 0
694- } else if only == nil || only == c.cur() {
695- c.pulse.value = c.advancePulse(c.now())
696- }
697- pulse := c.pulse.value
698- // drawable: the scene can show a pulse on the focused window.
699- drawable := false
678+ c.advanceFlash(only)
700679 scenes := make([]ports.Scene, 0, len(c.screens))
701680 // Before the first output (and after the last is unplugged) the
702681 // placeholder's scene has no output name: no renderer draws it.
703682 for i, sc := range c.screens {
704- scene, shown, err := c.sceneFor(ctx, i, sc, only, capture, pulse)
683+ scene, err := c.sceneFor(ctx, i, sc, only, capture)
705684 if err != nil {
706685 return err
707686 }
708- drawable = drawable || shown
709687 scenes = append(scenes, scene)
710688 }
711689 c.configures.prune()
712- // A pulse nothing shows (fullscreen, overview) asks for no frames.
713- if c.pulse.drawable = drawable; !drawable && c.pulse.id != 0 {
714- c.pulse.id = 0
715- // The focused scene was built as animating: publish it settled,
716- // or its flips would keep waking core. A scene that kept its Seq
717- // needs a fresh one, or outputs keep the animating one they show.
718- if i := slices.Index(c.screens, c.cur()); i >= 0 && scenes[i].Animating && !c.cur().springing() {
719- scenes[i].Animating = false
720- if scenes[i].Seq == c.cur().last.Seq {
721- c.seq++
722- scenes[i].Seq = c.seq
723- }
724- }
725- }
726690 for i, sc := range c.screens {
727691 sc.last = scenes[i]
728692 }
@@ -776,11 +740,11 @@ func (c *Core) step(ctx context.Context, only *screen) error {
776740 return c.slidFrame(ctx, false, only)
777741 }
778742
779-// animatingOther reports whether a spring or the pulse runs on a screen
743+// animatingOther reports whether a spring runs on a screen
780744 // other than only.
781745 func (c *Core) animatingOther(only *screen) bool {
782746 for _, sc := range c.screens {
783- if sc != only && (sc.springing() || c.pulsing(sc)) {
747+ if sc != only && (sc.springing() || c.flashing(sc)) {
784748 return true
785749 }
786750 }
@@ -1128,7 +1092,7 @@ func (c *Core) Run(ctx context.Context) error {
11281092 c.syncSecurity()
11291093 defer c.stopFrame()
11301094 defer c.stopCaptureTimer()
1131- defer c.stopPulseTimer()
1095+ defer c.stopFlashTimer()
11321096 defer c.cancelSwitcher()
11331097 defer c.stopRepeat()
11341098 // Startup commands run once per session. Full launcher queues retain a
@@ -1198,11 +1162,11 @@ func (c *Core) Run(ctx context.Context) error {
11981162 return nil
11991163 }
12001164 continue
1201- case <-c.pulse.timerC:
1165+ case <-c.flash.timerC:
12021166 if c.securityCheckpoint(ctx) != nil {
12031167 return nil
12041168 }
1205- if c.pulseTick() && c.publish(ctx) != nil {
1169+ if c.flashTick() && c.publish(ctx) != nil {
12061170 return nil
12071171 }
12081172 continue
@@ -155,9 +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- // pulse marks a window that just got the focus (pulse.go).159+ flash focusFlash
160- pulse focusPulse
161 // 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
162 // the timer that shows its cards (switcher.go).161 // the timer that shows its cards (switcher.go).
163 switcher struct {162 switcher struct {
@@ -532,14 +531,6 @@ func (v viewport) area(p Placement) Rect {
532 return v.usable531 return v.usable
533 }532 }
534 533
535-// hasRoom reports whether p's area can show anything: panels can leave
536-// tiles no room at all. Unlike shows, it ignores where p is, so a window
537-// still sliding in counts.
538-func hasRoom(v viewport, p Placement) bool {
539- a := v.area(p)
540- return a.W > 0 && a.H > 0
541-}
542-
543 // shows reports whether the user sees p: not hidden, and overlapping its534 // shows reports whether the user sees p: not hidden, and overlapping its
544 // area.535 // area.
545 func (v viewport) shows(p Placement) bool {536 func (v viewport) shows(p Placement) bool {
@@ -636,9 +627,8 @@ func (c *Core) setLayers(all []ports.LayerSurface) {
636 func (c *Core) publish(ctx context.Context) error { return c.publishFrame(ctx, nil) }627 func (c *Core) publish(ctx context.Context) error { return c.publishFrame(ctx, nil) }
637 628
638 // publishFrame publishes the scenes. only is the screen whose page flip629 // publishFrame publishes the scenes. only is the screen whose page flip
639-// moved the springs (nil: any, or none moved): the pulse advances with the630+// moved the springs (nil: any, or none moved): only a flip's own screen gets
640-// focused screen's flip, and only a flip's own screen gets a fresh Seq for631+// a fresh Seq for its animation, so another output keeps its Seq while it draws the same.
641-// its animation, so another output keeps its Seq while it draws the same.
642 func (c *Core) publishFrame(ctx context.Context, only *screen) error {632 func (c *Core) publishFrame(ctx context.Context, only *screen) error {
643 if c.inputEpochChanged() {633 if c.inputEpochChanged() {
644 return errSecurityChanged634 return errSecurityChanged
@@ -685,44 +675,18 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
685 return err675 return err
686 }676 }
687 focus := c.keyboardFocus()677 focus := c.keyboardFocus()
688- window, _ := c.cur().mon.Focused()678+ c.advanceFlash(only)
689- c.pulseFocus(window, focus == window)
690- // The pulse runs on the focused output: a flip of another output reuses
691- // its value so the focused scene keeps its Seq.
692- if c.pulse.id == 0 {
693- c.pulse.value = 0
694- } else if only == nil || only == c.cur() {
695- c.pulse.value = c.advancePulse(c.now())
696- }
697- pulse := c.pulse.value
698- // drawable: the scene can show a pulse on the focused window.
699- drawable := false
700 scenes := make([]ports.Scene, 0, len(c.screens))679 scenes := make([]ports.Scene, 0, len(c.screens))
701 // Before the first output (and after the last is unplugged) the680 // Before the first output (and after the last is unplugged) the
702 // placeholder's scene has no output name: no renderer draws it.681 // placeholder's scene has no output name: no renderer draws it.
703 for i, sc := range c.screens {682 for i, sc := range c.screens {
704- scene, shown, err := c.sceneFor(ctx, i, sc, only, capture, pulse)683+ scene, err := c.sceneFor(ctx, i, sc, only, capture)
705 if err != nil {684 if err != nil {
706 return err685 return err
707 }686 }
708- drawable = drawable || shown
709 scenes = append(scenes, scene)687 scenes = append(scenes, scene)
710 }688 }
711 c.configures.prune()689 c.configures.prune()
712- // A pulse nothing shows (fullscreen, overview) asks for no frames.
713- if c.pulse.drawable = drawable; !drawable && c.pulse.id != 0 {
714- c.pulse.id = 0
715- // The focused scene was built as animating: publish it settled,
716- // or its flips would keep waking core. A scene that kept its Seq
717- // needs a fresh one, or outputs keep the animating one they show.
718- if i := slices.Index(c.screens, c.cur()); i >= 0 && scenes[i].Animating && !c.cur().springing() {
719- scenes[i].Animating = false
720- if scenes[i].Seq == c.cur().last.Seq {
721- c.seq++
722- scenes[i].Seq = c.seq
723- }
724- }
725- }
726 for i, sc := range c.screens {690 for i, sc := range c.screens {
727 sc.last = scenes[i]691 sc.last = scenes[i]
728 }692 }
@@ -776,11 +740,11 @@ func (c *Core) step(ctx context.Context, only *screen) error {
776 return c.slidFrame(ctx, false, only)740 return c.slidFrame(ctx, false, only)
777 }741 }
778 742
779-// animatingOther reports whether a spring or the pulse runs on a screen743+// animatingOther reports whether a spring runs on a screen
780 // other than only.744 // other than only.
781 func (c *Core) animatingOther(only *screen) bool {745 func (c *Core) animatingOther(only *screen) bool {
782 for _, sc := range c.screens {746 for _, sc := range c.screens {
783- if sc != only && (sc.springing() || c.pulsing(sc)) {747+ if sc != only && (sc.springing() || c.flashing(sc)) {
784 return true748 return true
785 }749 }
786 }750 }
@@ -1128,7 +1092,7 @@ func (c *Core) Run(ctx context.Context) error {
1128 c.syncSecurity()1092 c.syncSecurity()
1129 defer c.stopFrame()1093 defer c.stopFrame()
1130 defer c.stopCaptureTimer()1094 defer c.stopCaptureTimer()
1131- defer c.stopPulseTimer()1095+ defer c.stopFlashTimer()
1132 defer c.cancelSwitcher()1096 defer c.cancelSwitcher()
1133 defer c.stopRepeat()1097 defer c.stopRepeat()
1134 // Startup commands run once per session. Full launcher queues retain a1098 // Startup commands run once per session. Full launcher queues retain a
@@ -1198,11 +1162,11 @@ func (c *Core) Run(ctx context.Context) error {
1198 return nil1162 return nil
1199 }1163 }
1200 continue1164 continue
1201- case <-c.pulse.timerC:1165+ case <-c.flash.timerC:
1202 if c.securityCheckpoint(ctx) != nil {1166 if c.securityCheckpoint(ctx) != nil {
1203 return nil1167 return nil
1204 }1168 }
1205- if c.pulseTick() && c.publish(ctx) != nil {1169+ if c.flashTick() && c.publish(ctx) != nil {
1206 return nil1170 return nil
1207 }1171 }
1208 continue1172 continue
M internal/core/core_test.go +0 -2
@@ -973,7 +973,6 @@ func TestFullscreenAtMapIgnored(t *testing.T) { both(t, fullscreenAtMapIgnored)
973973 func fullscreenAtMapIgnored(t *testing.T, animated bool) {
974974 cfg := scrollDefaults()
975975 cfg.Layout.Overflow = "fixed"
976- cfg.Focus.Animation = ports.FocusAnimationOff
977976 client := make(chan ports.ClientEvent, 8)
978977 output := make(chan ports.OutputEvent, 8)
979978 commands := make(chan ports.ClientCommand, 64)
@@ -1063,7 +1062,6 @@ func TestExternalFullscreenAtMapApplies(t *testing.T) { both(t, externalFullscre
10631062
10641063 func externalFullscreenAtMapApplies(t *testing.T, animated bool) {
10651064 cfg := scrollDefaults()
1066- cfg.Focus.Animation = ports.FocusAnimationOff
10671065 client := make(chan ports.ClientEvent, 8)
10681066 output := make(chan ports.OutputEvent, 8)
10691067 commands := make(chan ports.ClientCommand, 64)
@@ -973,7 +973,6 @@ func TestFullscreenAtMapIgnored(t *testing.T) { both(t, fullscreenAtMapIgnored)
973 func fullscreenAtMapIgnored(t *testing.T, animated bool) {973 func fullscreenAtMapIgnored(t *testing.T, animated bool) {
974 cfg := scrollDefaults()974 cfg := scrollDefaults()
975 cfg.Layout.Overflow = "fixed"975 cfg.Layout.Overflow = "fixed"
976- cfg.Focus.Animation = ports.FocusAnimationOff
977 client := make(chan ports.ClientEvent, 8)976 client := make(chan ports.ClientEvent, 8)
978 output := make(chan ports.OutputEvent, 8)977 output := make(chan ports.OutputEvent, 8)
979 commands := make(chan ports.ClientCommand, 64)978 commands := make(chan ports.ClientCommand, 64)
@@ -1063,7 +1062,6 @@ func TestExternalFullscreenAtMapApplies(t *testing.T) { both(t, externalFullscre
1063 1062
1064 func externalFullscreenAtMapApplies(t *testing.T, animated bool) {1063 func externalFullscreenAtMapApplies(t *testing.T, animated bool) {
1065 cfg := scrollDefaults()1064 cfg := scrollDefaults()
1066- cfg.Focus.Animation = ports.FocusAnimationOff
1067 client := make(chan ports.ClientEvent, 8)1065 client := make(chan ports.ClientEvent, 8)
1068 output := make(chan ports.OutputEvent, 8)1066 output := make(chan ports.OutputEvent, 8)
1069 commands := make(chan ports.ClientCommand, 64)1067 commands := make(chan ports.ClientCommand, 64)
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 +226 -0
@@ -0,0 +1,226 @@
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+ lastStarts map[flashTarget]time.Time
37+ layout []Placement // Owner scratch, never shared with a published scene.
38+ published *ports.FocusFlash // Immutable last sample, reused when unchanged.
39+}
40+
41+func (c *Core) flashOn() bool { return c.animOn() && c.cfg.Focus.Flash && !c.protectionRequested() }
42+
43+func (c *Core) requestFlash(level flashLevel, window WindowID) {
44+ if !c.flashOn() {
45+ return
46+ }
47+ c.stopFlash()
48+ f := &c.flash
49+ f.pending = &flashTarget{level: level, window: window, output: c.cur().name()}
50+ f.since = c.now()
51+ f.timerC, f.timerStop = newTimer(c.opts.Clock, flashSettle)
52+}
53+
54+func (c *Core) flashTick() bool {
55+ f := &c.flash
56+ f.timerC, f.timerStop = nil, nil
57+ if f.pending == nil {
58+ return false
59+ }
60+ target := *f.pending
61+ f.pending = nil
62+ if !c.flashOn() {
63+ return false
64+ }
65+ i := c.screenIndex(target.output)
66+ if i < 0 {
67+ return false
68+ }
69+ sc := c.screens[i]
70+ if sc.mon.ov.open {
71+ return false
72+ }
73+ now := c.now()
74+ if target.level != flashMonitor {
75+ owner, _ := c.screenOf(target.window)
76+ record := c.windows.lookup(target.window)
77+ if owner != sc || record.client.ID == 0 {
78+ return false
79+ }
80+ }
81+ wait := flashSettle - now.Sub(f.since)
82+ if wait > 0 || !sc.settled() {
83+ if wait <= 0 {
84+ wait = flashRecheck
85+ }
86+ f.pending = &target
87+ f.timerC, f.timerStop = newTimer(c.opts.Clock, wait)
88+ return false
89+ }
90+ layout := sc.mon.Layout()
91+ var rect Rect
92+ if target.level == flashMonitor {
93+ o := sc.mon.Output()
94+ rect = Rect{W: o.W, H: o.H}
95+ } else {
96+ at := slices.IndexFunc(layout, func(p Placement) bool { return p.ID == target.window && !p.Hidden && p.Preview == 0 })
97+ if at < 0 || layout[at].Fullscreen {
98+ return false
99+ }
100+ rect = layout[at].Rect
101+ if target.level == flashColumn {
102+ w := sc.mon.Current()
103+ col := w.columnOf(target.window)
104+ if col < 0 {
105+ return false
106+ }
107+ for _, p := range layout {
108+ if p.Hidden || !slices.Contains(w.Columns[col].Windows, p.ID) {
109+ continue
110+ }
111+ rect = union(rect, p.Rect)
112+ }
113+ }
114+ }
115+ if at, ok := f.lastStarts[target]; ok && now.Sub(at) < flashCooldown {
116+ return false
117+ }
118+ if f.lastStarts == nil {
119+ f.lastStarts = make(map[flashTarget]time.Time)
120+ }
121+ for key, at := range f.lastStarts {
122+ if now.Sub(at) >= flashCooldown {
123+ delete(f.lastStarts, key)
124+ }
125+ }
126+ f.lastStarts[target] = now
127+ f.running, f.rect, f.start = &target, rect, now
128+ f.value = c.cfg.Focus.FlashOpacity
129+ return true
130+}
131+
132+func (c *Core) advanceFlash(only *screen) {
133+ f := &c.flash
134+ if f.running == nil {
135+ return
136+ }
137+ if !c.flashOn() || c.screenIndex(f.running.output) < 0 {
138+ c.stopFlash()
139+ return
140+ }
141+ sc := c.screens[c.screenIndex(f.running.output)]
142+ if !c.flashVisible(*f.running, sc) {
143+ c.stopFlash()
144+ return
145+ }
146+ if only != nil && only.name() != f.running.output {
147+ return
148+ }
149+ d := c.cfg.Focus.FlashDuration
150+ elapsed := c.now().Sub(f.start)
151+ if d <= 0 || elapsed >= d {
152+ f.running = nil
153+ f.value = 0
154+ return
155+ }
156+ f.value = c.cfg.Focus.FlashOpacity * (1 - float64(max(elapsed, 0))/float64(d))
157+}
158+
159+// flashVisible checks the current layout, not leaving placements retained for
160+// transitions: an old border must not outlive its target's visibility.
161+func (c *Core) flashVisible(target flashTarget, sc *screen) bool {
162+ if sc.mon.ov.open {
163+ return false
164+ }
165+ if target.level == flashMonitor {
166+ return true
167+ }
168+ owner, _ := c.screenOf(target.window)
169+ if owner != sc || c.windows.lookup(target.window).client.ID == 0 {
170+ return false
171+ }
172+ view := sc.mon.viewport()
173+ c.flash.layout = sc.mon.layoutInto(c.flash.layout)
174+ for _, p := range c.flash.layout {
175+ if p.ID == target.window {
176+ return !p.Fullscreen && p.Preview == 0 && view.shows(p)
177+ }
178+ }
179+ return false
180+}
181+
182+func (c *Core) flashFor(sc *screen) *ports.FocusFlash {
183+ f := &c.flash
184+ if !c.flashing(sc) {
185+ return nil
186+ }
187+ color := c.cfg.Focus.FlashColor
188+ if color == "" {
189+ color = c.cfg.Border.Active
190+ }
191+ sample := ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Color: color, Alpha: f.value}
192+ if f.published == nil || *f.published != sample {
193+ f.published = &ports.FocusFlash{Rect: sample.Rect, Width: sample.Width, Color: sample.Color, Alpha: sample.Alpha}
194+ }
195+ return f.published
196+}
197+
198+func (c *Core) flashing(sc *screen) bool {
199+ return c.flash.running != nil && c.flash.running.output == sc.name()
200+}
201+
202+func (c *Core) stopFlashTimer() {
203+ if c.flash.timerStop != nil {
204+ c.flash.timerStop()
205+ }
206+ c.flash.timerC, c.flash.timerStop = nil, nil
207+}
208+
209+func (c *Core) stopFlash() {
210+ c.stopFlashTimer()
211+ c.flash.pending, c.flash.running, c.flash.value = nil, nil, 0
212+}
213+
214+func (m *Monitor) settled() bool {
215+ if m.switchView.busy() {
216+ return false
217+ }
218+ for w := range m.all() {
219+ if w.view.busy() || w.stashView.busy() {
220+ return false
221+ }
222+ }
223+ return true
224+}
225+
226+func (s *screen) settled() bool { return s.mon.settled() && len(s.rects) == 0 }
@@ -0,0 +1,226 @@
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+ lastStarts map[flashTarget]time.Time
37+ layout []Placement // Owner scratch, never shared with a published scene.
38+ published *ports.FocusFlash // Immutable last sample, reused when unchanged.
39+}
40+
41+func (c *Core) flashOn() bool { return c.animOn() && c.cfg.Focus.Flash && !c.protectionRequested() }
42+
43+func (c *Core) requestFlash(level flashLevel, window WindowID) {
44+ if !c.flashOn() {
45+ return
46+ }
47+ c.stopFlash()
48+ f := &c.flash
49+ f.pending = &flashTarget{level: level, window: window, output: c.cur().name()}
50+ f.since = c.now()
51+ f.timerC, f.timerStop = newTimer(c.opts.Clock, flashSettle)
52+}
53+
54+func (c *Core) flashTick() bool {
55+ f := &c.flash
56+ f.timerC, f.timerStop = nil, nil
57+ if f.pending == nil {
58+ return false
59+ }
60+ target := *f.pending
61+ f.pending = nil
62+ if !c.flashOn() {
63+ return false
64+ }
65+ i := c.screenIndex(target.output)
66+ if i < 0 {
67+ return false
68+ }
69+ sc := c.screens[i]
70+ if sc.mon.ov.open {
71+ return false
72+ }
73+ now := c.now()
74+ if target.level != flashMonitor {
75+ owner, _ := c.screenOf(target.window)
76+ record := c.windows.lookup(target.window)
77+ if owner != sc || record.client.ID == 0 {
78+ return false
79+ }
80+ }
81+ wait := flashSettle - now.Sub(f.since)
82+ if wait > 0 || !sc.settled() {
83+ if wait <= 0 {
84+ wait = flashRecheck
85+ }
86+ f.pending = &target
87+ f.timerC, f.timerStop = newTimer(c.opts.Clock, wait)
88+ return false
89+ }
90+ layout := sc.mon.Layout()
91+ var rect Rect
92+ if target.level == flashMonitor {
93+ o := sc.mon.Output()
94+ rect = Rect{W: o.W, H: o.H}
95+ } else {
96+ at := slices.IndexFunc(layout, func(p Placement) bool { return p.ID == target.window && !p.Hidden && p.Preview == 0 })
97+ if at < 0 || layout[at].Fullscreen {
98+ return false
99+ }
100+ rect = layout[at].Rect
101+ if target.level == flashColumn {
102+ w := sc.mon.Current()
103+ col := w.columnOf(target.window)
104+ if col < 0 {
105+ return false
106+ }
107+ for _, p := range layout {
108+ if p.Hidden || !slices.Contains(w.Columns[col].Windows, p.ID) {
109+ continue
110+ }
111+ rect = union(rect, p.Rect)
112+ }
113+ }
114+ }
115+ if at, ok := f.lastStarts[target]; ok && now.Sub(at) < flashCooldown {
116+ return false
117+ }
118+ if f.lastStarts == nil {
119+ f.lastStarts = make(map[flashTarget]time.Time)
120+ }
121+ for key, at := range f.lastStarts {
122+ if now.Sub(at) >= flashCooldown {
123+ delete(f.lastStarts, key)
124+ }
125+ }
126+ f.lastStarts[target] = now
127+ f.running, f.rect, f.start = &target, rect, now
128+ f.value = c.cfg.Focus.FlashOpacity
129+ return true
130+}
131+
132+func (c *Core) advanceFlash(only *screen) {
133+ f := &c.flash
134+ if f.running == nil {
135+ return
136+ }
137+ if !c.flashOn() || c.screenIndex(f.running.output) < 0 {
138+ c.stopFlash()
139+ return
140+ }
141+ sc := c.screens[c.screenIndex(f.running.output)]
142+ if !c.flashVisible(*f.running, sc) {
143+ c.stopFlash()
144+ return
145+ }
146+ if only != nil && only.name() != f.running.output {
147+ return
148+ }
149+ d := c.cfg.Focus.FlashDuration
150+ elapsed := c.now().Sub(f.start)
151+ if d <= 0 || elapsed >= d {
152+ f.running = nil
153+ f.value = 0
154+ return
155+ }
156+ f.value = c.cfg.Focus.FlashOpacity * (1 - float64(max(elapsed, 0))/float64(d))
157+}
158+
159+// flashVisible checks the current layout, not leaving placements retained for
160+// transitions: an old border must not outlive its target's visibility.
161+func (c *Core) flashVisible(target flashTarget, sc *screen) bool {
162+ if sc.mon.ov.open {
163+ return false
164+ }
165+ if target.level == flashMonitor {
166+ return true
167+ }
168+ owner, _ := c.screenOf(target.window)
169+ if owner != sc || c.windows.lookup(target.window).client.ID == 0 {
170+ return false
171+ }
172+ view := sc.mon.viewport()
173+ c.flash.layout = sc.mon.layoutInto(c.flash.layout)
174+ for _, p := range c.flash.layout {
175+ if p.ID == target.window {
176+ return !p.Fullscreen && p.Preview == 0 && view.shows(p)
177+ }
178+ }
179+ return false
180+}
181+
182+func (c *Core) flashFor(sc *screen) *ports.FocusFlash {
183+ f := &c.flash
184+ if !c.flashing(sc) {
185+ return nil
186+ }
187+ color := c.cfg.Focus.FlashColor
188+ if color == "" {
189+ color = c.cfg.Border.Active
190+ }
191+ sample := ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Color: color, Alpha: f.value}
192+ if f.published == nil || *f.published != sample {
193+ f.published = &ports.FocusFlash{Rect: sample.Rect, Width: sample.Width, Color: sample.Color, Alpha: sample.Alpha}
194+ }
195+ return f.published
196+}
197+
198+func (c *Core) flashing(sc *screen) bool {
199+ return c.flash.running != nil && c.flash.running.output == sc.name()
200+}
201+
202+func (c *Core) stopFlashTimer() {
203+ if c.flash.timerStop != nil {
204+ c.flash.timerStop()
205+ }
206+ c.flash.timerC, c.flash.timerStop = nil, nil
207+}
208+
209+func (c *Core) stopFlash() {
210+ c.stopFlashTimer()
211+ c.flash.pending, c.flash.running, c.flash.value = nil, nil, 0
212+}
213+
214+func (m *Monitor) settled() bool {
215+ if m.switchView.busy() {
216+ return false
217+ }
218+ for w := range m.all() {
219+ if w.view.busy() || w.stashView.busy() {
220+ return false
221+ }
222+ }
223+ return true
224+}
225+
226+func (s *screen) settled() bool { return s.mon.settled() && len(s.rects) == 0 }
A internal/core/flash_test.go +600 -0
@@ -0,0 +1,600 @@
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+ } {
144+ t.Run(test.name, func(t *testing.T) {
145+ c, clock := flashCore(t)
146+ test.change(c, clock)
147+ c.requestFlash(flashWindow, 3)
148+ clock.now = clock.now.Add(flashSettle)
149+ if c.flashTick() || c.flash.running != nil {
150+ t.Fatal("ineligible flash")
151+ }
152+ })
153+ }
154+}
155+
156+func TestFlashStopAndColor(t *testing.T) {
157+ c, clock := flashCore(t)
158+ c.cfg.Focus.FlashColor = "#123456"
159+ c.cfg.Animations.Slowdown = 10
160+ startFlash(t, c, clock, flashWindow, 3)
161+ if s := indicatorScene(t, c); s.Flash.Color != "#123456" {
162+ t.Fatal("configured color ignored")
163+ }
164+ clock.now = clock.now.Add(c.cfg.Focus.FlashDuration)
165+ if indicatorScene(t, c).Flash != nil {
166+ t.Fatal("duration scaled by slowdown")
167+ }
168+ c.requestFlash(flashWindow, 2)
169+ c.stopFlash()
170+ if c.flash.timerC != nil || c.flash.pending != nil || c.flash.running != nil {
171+ t.Fatal("stop retained state")
172+ }
173+}
174+
175+func TestFlashActionTriggers(t *testing.T) {
176+ for _, test := range []struct {
177+ action Action
178+ level flashLevel
179+ setup func(*Core)
180+ }{
181+ {ActionFocusColumnLeft, flashColumn, nil},
182+ {ActionFocusColumnRight, flashColumn, func(c *Core) { c.cur().mon.Current().FocusID(1) }},
183+ {ActionMoveColumnLeft, flashColumn, nil},
184+ {ActionMoveColumnRight, flashColumn, func(c *Core) { c.cur().mon.Current().FocusID(1) }},
185+ {ActionConsumeOrExpelLeft, flashWindow, nil},
186+ {ActionConsumeOrExpelRight, flashWindow, func(c *Core) { c.cur().mon.Current().FocusID(1) }},
187+ {ActionToggleFloating, flashWindow, nil},
188+ {ActionFocusWindowUp, flashWindow, func(c *Core) { c.cur().mon.Current().ConsumeOrExpel(-1) }},
189+ {ActionFocusWindowDown, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ConsumeOrExpel(-1); w.FocusWindow(-1) }},
190+ {ActionMoveWindowUp, flashWindow, func(c *Core) { c.cur().mon.Current().ConsumeOrExpel(-1) }},
191+ {ActionMoveWindowDown, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ConsumeOrExpel(-1); w.FocusWindow(-1) }},
192+ {ActionToggleWindowStash, flashWindow, func(c *Core) { c.cur().mon.Current().ToggleWindowStash() }},
193+ {ActionToggleStashVisible, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ToggleWindowStash(); w.ToggleStashVisible() }},
194+ {ActionToggleOverview, flashWindow, func(c *Core) { c.cur().mon.ToggleOverview() }},
195+ } {
196+ t.Run(string(test.action), func(t *testing.T) {
197+ c, clock := flashCore(t)
198+ if test.setup != nil {
199+ test.setup(c)
200+ }
201+ c.applyAction(test.action)
202+ if c.flash.pending == nil || c.flash.pending.level != test.level {
203+ t.Fatalf("pending %+v want level %v", c.flash.pending, test.level)
204+ }
205+ target := *c.flash.pending
206+ clock.now = clock.now.Add(flashSettle)
207+ if !c.flashTick() {
208+ t.Fatal("action flash did not start")
209+ }
210+ var want Rect
211+ for _, p := range c.cur().mon.Layout() {
212+ if p.ID == target.window {
213+ want = p.Rect
214+ }
215+ }
216+ if c.flash.rect != want {
217+ t.Fatalf("action rect %+v want %+v", c.flash.rect, want)
218+ }
219+ })
220+ }
221+}
222+
223+func TestFlashExcludedActions(t *testing.T) {
224+ for _, action := range []Action{ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveWindowToWorkspaceNext, ActionMoveColumnToWorkspaceNext, ActionToggleFullscreen, ActionMaximizeColumn, ActionCycleColumnWidth, ActionFocusColumnRight, ActionMoveColumnRight, ActionMoveWindowUp} {
225+ t.Run(string(action), func(t *testing.T) {
226+ c, _ := flashCore(t)
227+ c.applyAction(action)
228+ if c.flash.pending != nil {
229+ t.Fatalf("unexpected flash %+v", c.flash.pending)
230+ }
231+ })
232+ }
233+}
234+
235+func TestFlashMonitorTriggers(t *testing.T) {
236+ for _, action := range []Action{ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown} {
237+ t.Run(string(action), func(t *testing.T) {
238+ c, clock := flashCore(t)
239+ c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
240+ a, b := c.screens[0], c.screens[1]
241+ a.x, a.y = 0, 0
242+ b.x, b.y = 300, 0
243+ switch monitorDirection(action) {
244+ case dirLeft:
245+ b.x = -400
246+ case dirUp:
247+ b.x, b.y = 0, -300
248+ case dirDown:
249+ b.x, b.y = 0, 200
250+ }
251+ c.applyAction(action)
252+ if c.flash.pending == nil || c.flash.pending.level != flashMonitor || c.flash.pending.output != "B" {
253+ t.Fatalf("destination request %+v", c.flash.pending)
254+ }
255+ clock.now = clock.now.Add(flashSettle)
256+ if !c.flashTick() || c.flash.rect != (Rect{W: 400, H: 300}) {
257+ t.Fatalf("monitor rect %+v", c.flash.rect)
258+ }
259+ })
260+ }
261+}
262+
263+func TestFlashColumnUnion(t *testing.T) {
264+ c, clock := flashCore(t)
265+ w := c.cur().mon.Current()
266+ w.ConsumeOrExpel(-1)
267+ startFlash(t, c, clock, flashColumn, 3)
268+ col := w.Columns[w.columnOf(3)]
269+ var area int
270+ for _, p := range c.cur().mon.Layout() {
271+ for _, id := range col.Windows {
272+ if id == p.ID {
273+ area += p.Rect.W * p.Rect.H
274+ }
275+ }
276+ }
277+ if c.flash.rect.W*c.flash.rect.H != area {
278+ t.Fatalf("union %+v does not cover column area %d", c.flash.rect, area)
279+ }
280+}
281+
282+func TestFlashGestureLanding(t *testing.T) {
283+ c, _ := flashCore(t)
284+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
285+ c.swipeUpdate(ports.SwipeUpdate{DX: -800, Time: 50 * time.Millisecond})
286+ c.swipeEnd(ports.SwipeEnd{Time: 60 * time.Millisecond})
287+ if c.flash.pending == nil || c.flash.pending.level != flashColumn {
288+ t.Fatalf("landing request %+v", c.flash.pending)
289+ }
290+ c.stopFlash()
291+ c.swipeBegin(ports.SwipeBegin{Fingers: 3, Time: 100 * time.Millisecond})
292+ c.swipeUpdate(ports.SwipeUpdate{DX: 800, Time: 150 * time.Millisecond})
293+ c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: 160 * time.Millisecond})
294+ if c.flash.pending != nil {
295+ t.Fatal("cancelled gesture flashed")
296+ }
297+}
298+
299+func TestFlashLockClearsPendingAndRunning(t *testing.T) {
300+ for _, running := range []bool{false, true} {
301+ c, clock := flashCore(t)
302+ state := ports.SecurityState{}
303+ gate := portsmocks.NewMockSessionSecurity(t)
304+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
305+ c.opts.Security = gate
306+ if running {
307+ startFlash(t, c, clock, flashWindow, 3)
308+ } else {
309+ c.requestFlash(flashWindow, 3)
310+ }
311+ state = ports.SecurityState{Generation: 1, Protected: true}
312+ if !c.syncSecurity() {
313+ t.Fatal("security did not change")
314+ }
315+ if c.flash.pending != nil || c.flash.running != nil || c.flash.timerC != nil {
316+ t.Fatal("lock retained flash")
317+ }
318+ state = ports.SecurityState{Generation: 2}
319+ c.syncSecurity()
320+ if indicatorScene(t, c).Flash != nil || c.flash.pending != nil {
321+ t.Fatal("unlock requested flash")
322+ }
323+ }
324+}
325+
326+func TestFlashOverviewClickAndKeyboard(t *testing.T) {
327+ for _, click := range []bool{true, false} {
328+ c, _ := flashCore(t)
329+ c.cur().mon.ToggleOverview()
330+ scene := indicatorScene(t, c)
331+ if click {
332+ var rect Rect
333+ for _, p := range scene.Windows {
334+ if p.ID == 2 {
335+ rect = p.Rect
336+ }
337+ }
338+ c.cursorX, c.cursorY = float64(rect.X+rect.W/2), float64(rect.Y+rect.H/2)
339+ picked, err := c.overviewClick(context.Background())
340+ if err != nil || !picked {
341+ t.Fatalf("click %v %v", picked, err)
342+ }
343+ } else {
344+ if err := c.keyEvent(context.Background(), ports.KeyEvent{Keysym: "Return", Pressed: true}); err != nil {
345+ t.Fatal(err)
346+ }
347+ }
348+ if c.flash.pending == nil || c.flash.pending.level != flashWindow {
349+ t.Fatalf("overview request %+v", c.flash.pending)
350+ }
351+ }
352+}
353+
354+func TestFlashTileDrop(t *testing.T) {
355+ c, _ := flashCore(t)
356+ scene := indicatorScene(t, c)
357+ var rect Rect
358+ for _, p := range scene.Windows {
359+ if p.ID == 2 {
360+ rect = p.Rect
361+ }
362+ }
363+ c.cursorX, c.cursorY = float64(rect.X+rect.W/2), float64(rect.Y+rect.H/2)
364+ c.drag = &dragState{id: 3}
365+ if err := c.endDrag(context.Background()); err != nil {
366+ t.Fatal(err)
367+ }
368+ if c.flash.pending == nil || c.flash.pending.level != flashWindow || c.flash.pending.window != 3 {
369+ t.Fatalf("drop request %+v", c.flash.pending)
370+ }
371+}
372+
373+func TestFlashNoImplicitFocusTriggers(t *testing.T) {
374+ c, _ := flashCore(t)
375+ ctx := context.Background()
376+ indicatorScene(t, c)
377+ if c.flash.pending != nil {
378+ t.Fatal("startup publication flashed")
379+ }
380+ for _, ev := range []ports.ClientEvent{ports.WindowMapped{ID: 4}, ports.WindowActivate{ID: 1}} {
381+ if _, stop := c.clientEvent(ctx, ev); stop {
382+ t.Fatalf("event %T stopped", ev)
383+ }
384+ if c.flash.pending != nil {
385+ t.Fatalf("event %T flashed", ev)
386+ }
387+ }
388+ indicatorScene(t, c)
389+ if err := c.pointerMotion(ctx, ports.PointerMotion{X: 50, Y: 50}); err != nil {
390+ t.Fatal(err)
391+ }
392+ if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil {
393+ t.Fatal(err)
394+ }
395+ if c.flash.pending != nil {
396+ t.Fatal("pointer focus flashed")
397+ }
398+}
399+
400+func TestFlashCancelsStaleRunningTargets(t *testing.T) {
401+ for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {
402+ for _, change := range []string{"overview", "output gone", "fullscreen", "unmapped", "hidden", "other output", "workspace changed"} {
403+ t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) {
404+ c, clock := flashCore(t)
405+ a := c.cur()
406+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
407+ b := c.screens[1]
408+ startFlash(t, c, clock, level, 3)
409+ if indicatorScene(t, c).Flash == nil {
410+ t.Fatal("setup: no running flash")
411+ }
412+ switch change {
413+ case "overview":
414+ a.mon.ToggleOverview()
415+ case "output gone":
416+ c.screens = []*screen{b}
417+ c.focusScreen = 0
418+ case "fullscreen":
419+ a.mon.ToggleFullscreen()
420+ case "unmapped":
421+ c.windows.drop(3)
422+ case "hidden":
423+ w := a.mon.Current()
424+ w.ToggleWindowStash()
425+ w.ToggleStashVisible()
426+ case "other output":
427+ a.mon.RemoveWindow(3)
428+ b.mon.AddWindow(3)
429+ case "workspace changed":
430+ a.mon.Focus(a.mon.Active + 1)
431+ }
432+ // Cancellation must not wait for the target output's next flip.
433+ scenes := stepScenes(t, c, b)
434+ shouldCancel := level != flashMonitor || change == "overview" || change == "output gone"
435+ if shouldCancel {
436+ if scenes["A"].Flash != nil || c.flash.running != nil || c.animating() {
437+ t.Fatalf("stale flash retained: %+v, animating=%v", scenes["A"].Flash, c.animating())
438+ }
439+ } else if scenes["A"].Flash == nil || !c.animating() {
440+ t.Fatal("monitor flash cancelled for a window change")
441+ }
442+ })
443+ }
444+ }
445+}
446+
447+func TestFlashCooldownRemembersEveryTarget(t *testing.T) {
448+ c, clock := flashCore(t)
449+ startFlash(t, c, clock, flashWindow, 3)
450+ first := c.flash.start
451+ startFlash(t, c, clock, flashWindow, 2)
452+ c.requestFlash(flashWindow, 3)
453+ clock.now = clock.now.Add(flashSettle)
454+ if c.flashTick() || c.flash.running != nil {
455+ t.Fatal("A flashed again after B within cooldown")
456+ }
457+ clock.now = first.Add(flashCooldown)
458+ startFlash(t, c, clock, flashWindow, 3)
459+ if c.flash.running.window != 3 {
460+ t.Fatal("A did not flash after cooldown")
461+ }
462+ // Inserting after all previous starts expire prunes old target keys.
463+ clock.now = clock.now.Add(flashCooldown)
464+ startFlash(t, c, clock, flashMonitor, 0)
465+ if len(c.flash.lastStarts) != 1 {
466+ t.Fatalf("expired cooldown entries retained: %v", c.flash.lastStarts)
467+ }
468+}
469+
470+// BenchmarkFlashSceneBuild includes revalidation and immutable scene creation.
471+func BenchmarkFlashSceneBuild(b *testing.B) {
472+ var cfg ports.Config
473+ cfg.Layout.MaxColumns = 2
474+ cfg.Keyboard.CmdKey = "super"
475+ cfg.Animations.On = true
476+ cfg.Focus.Flash = true
477+ cfg.Focus.FlashOpacity = 0.8
478+ cfg.Focus.FlashDuration = time.Hour
479+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
480+ if err != nil {
481+ b.Fatal(err)
482+ }
483+ c.addScreen(ports.OutputInfo{Name: "A", Width: 1000, Height: 600})
484+ c.windows.mapped(ports.WindowMapped{ID: 1}, time.Now())
485+ c.cur().mon.AddWindow(1)
486+ c.flash.running = &flashTarget{level: flashWindow, window: 1, output: "A"}
487+ c.flash.start = time.Now()
488+ c.flash.rect = Rect{W: 1000, H: 600}
489+ c.refreshShown()
490+ ctx := context.Background()
491+ b.ReportAllocs()
492+ b.ResetTimer()
493+ for range b.N {
494+ c.refreshShown()
495+ c.advanceFlash(nil)
496+ scene, err := c.sceneFor(ctx, 0, c.cur(), nil, nil)
497+ if err != nil {
498+ b.Fatal(err)
499+ }
500+ c.cur().last = scene
501+ }
502+}
503+
504+func TestFlashEdgeWorkspaceSwitchDoesNotFlash(t *testing.T) {
505+ for _, tc := range []struct {
506+ name string
507+ overflow Overflow
508+ action Action
509+ }{
510+ {"vertical", OverflowScroll, ActionFocusWindowDown},
511+ {"horizontal", OverflowCascade, ActionFocusColumnRight},
512+ } {
513+ t.Run(tc.name, func(t *testing.T) {
514+ c, _ := flashCore(t)
515+ m := c.cur().mon
516+ m.SetOverflow(tc.overflow)
517+ before := m.Current()
518+ m.Focus(1)
519+ m.AddWindow(4)
520+ m.Focus(0)
521+ c.applyAction(tc.action)
522+ if m.Current() == before {
523+ t.Fatal("setup: edge did not switch workspace")
524+ }
525+ if id, _ := m.Focused(); id != 4 {
526+ t.Fatalf("did not land on occupied workspace: %d", id)
527+ }
528+ if c.flash.pending != nil || c.flash.running != nil {
529+ t.Fatal("workspace switch flashed")
530+ }
531+ })
532+ }
533+}
534+
535+func TestFlashExplicitActionOnFreshlyMappedWindow(t *testing.T) {
536+ c, clock := flashCore(t)
537+ c.clientEvent(context.Background(), ports.WindowMapped{ID: 4})
538+ if c.flash.pending != nil {
539+ t.Fatal("mapping requested flash")
540+ }
541+ c.applyAction(ActionToggleFloating)
542+ if c.flash.pending == nil || c.flash.pending.window != 4 {
543+ t.Fatal("explicit action did not request flash")
544+ }
545+ c.stopAnimations()
546+ clock.now = clock.now.Add(flashSettle)
547+ if !c.flashTick() {
548+ t.Fatal("freshly mapped target rejected")
549+ }
550+}
551+
552+func TestFlashPendingTimerStopped(t *testing.T) {
553+ for _, reason := range []string{"replacement", "security", "run exit"} {
554+ t.Run(reason, func(t *testing.T) {
555+ c, clock := flashCore(t)
556+ timer := portsmocks.NewMockTimer(t)
557+ timer.EXPECT().C().Return(make(chan time.Time)).Once()
558+ timer.EXPECT().Stop().Return(true).Times(1)
559+ strictClock := portsmocks.NewMockClock(t)
560+ strictClock.EXPECT().Now().Return(clock.now).Maybe()
561+ strictClock.EXPECT().NewTimer(flashSettle).Return(timer).Once()
562+ c.opts.Clock = strictClock
563+ c.requestFlash(flashWindow, 3)
564+ // Only the pending flash timer belongs to the strict clock.
565+ c.opts.Clock = nil
566+ switch reason {
567+ case "replacement":
568+ c.requestFlash(flashWindow, 2)
569+ c.stopFlash()
570+ case "security":
571+ gate := portsmocks.NewMockSessionSecurity(t)
572+ gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
573+ c.opts.Security = gate
574+ c.syncSecurity()
575+ case "run exit":
576+ ctx, cancel := context.WithCancel(context.Background())
577+ cancel()
578+ if err := c.Run(ctx); err != nil {
579+ t.Fatal(err)
580+ }
581+ }
582+ })
583+ }
584+}
585+
586+func TestFlashUnchangedSampleIsReused(t *testing.T) {
587+ c, clock := flashCore(t)
588+ startFlash(t, c, clock, flashWindow, 3)
589+ first := indicatorScene(t, c).Flash
590+ if first == nil {
591+ t.Fatal("missing flash")
592+ }
593+ if next := indicatorScene(t, c).Flash; next != first {
594+ t.Fatal("unchanged immutable sample not reused")
595+ }
596+ clock.now = clock.now.Add(time.Millisecond)
597+ if next := indicatorScene(t, c).Flash; next == first || first.Alpha != c.cfg.Focus.FlashOpacity {
598+ t.Fatal("changed sample mutated prior scene")
599+ }
600+}
@@ -0,0 +1,600 @@
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+ } {
144+ t.Run(test.name, func(t *testing.T) {
145+ c, clock := flashCore(t)
146+ test.change(c, clock)
147+ c.requestFlash(flashWindow, 3)
148+ clock.now = clock.now.Add(flashSettle)
149+ if c.flashTick() || c.flash.running != nil {
150+ t.Fatal("ineligible flash")
151+ }
152+ })
153+ }
154+}
155+
156+func TestFlashStopAndColor(t *testing.T) {
157+ c, clock := flashCore(t)
158+ c.cfg.Focus.FlashColor = "#123456"
159+ c.cfg.Animations.Slowdown = 10
160+ startFlash(t, c, clock, flashWindow, 3)
161+ if s := indicatorScene(t, c); s.Flash.Color != "#123456" {
162+ t.Fatal("configured color ignored")
163+ }
164+ clock.now = clock.now.Add(c.cfg.Focus.FlashDuration)
165+ if indicatorScene(t, c).Flash != nil {
166+ t.Fatal("duration scaled by slowdown")
167+ }
168+ c.requestFlash(flashWindow, 2)
169+ c.stopFlash()
170+ if c.flash.timerC != nil || c.flash.pending != nil || c.flash.running != nil {
171+ t.Fatal("stop retained state")
172+ }
173+}
174+
175+func TestFlashActionTriggers(t *testing.T) {
176+ for _, test := range []struct {
177+ action Action
178+ level flashLevel
179+ setup func(*Core)
180+ }{
181+ {ActionFocusColumnLeft, flashColumn, nil},
182+ {ActionFocusColumnRight, flashColumn, func(c *Core) { c.cur().mon.Current().FocusID(1) }},
183+ {ActionMoveColumnLeft, flashColumn, nil},
184+ {ActionMoveColumnRight, flashColumn, func(c *Core) { c.cur().mon.Current().FocusID(1) }},
185+ {ActionConsumeOrExpelLeft, flashWindow, nil},
186+ {ActionConsumeOrExpelRight, flashWindow, func(c *Core) { c.cur().mon.Current().FocusID(1) }},
187+ {ActionToggleFloating, flashWindow, nil},
188+ {ActionFocusWindowUp, flashWindow, func(c *Core) { c.cur().mon.Current().ConsumeOrExpel(-1) }},
189+ {ActionFocusWindowDown, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ConsumeOrExpel(-1); w.FocusWindow(-1) }},
190+ {ActionMoveWindowUp, flashWindow, func(c *Core) { c.cur().mon.Current().ConsumeOrExpel(-1) }},
191+ {ActionMoveWindowDown, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ConsumeOrExpel(-1); w.FocusWindow(-1) }},
192+ {ActionToggleWindowStash, flashWindow, func(c *Core) { c.cur().mon.Current().ToggleWindowStash() }},
193+ {ActionToggleStashVisible, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ToggleWindowStash(); w.ToggleStashVisible() }},
194+ {ActionToggleOverview, flashWindow, func(c *Core) { c.cur().mon.ToggleOverview() }},
195+ } {
196+ t.Run(string(test.action), func(t *testing.T) {
197+ c, clock := flashCore(t)
198+ if test.setup != nil {
199+ test.setup(c)
200+ }
201+ c.applyAction(test.action)
202+ if c.flash.pending == nil || c.flash.pending.level != test.level {
203+ t.Fatalf("pending %+v want level %v", c.flash.pending, test.level)
204+ }
205+ target := *c.flash.pending
206+ clock.now = clock.now.Add(flashSettle)
207+ if !c.flashTick() {
208+ t.Fatal("action flash did not start")
209+ }
210+ var want Rect
211+ for _, p := range c.cur().mon.Layout() {
212+ if p.ID == target.window {
213+ want = p.Rect
214+ }
215+ }
216+ if c.flash.rect != want {
217+ t.Fatalf("action rect %+v want %+v", c.flash.rect, want)
218+ }
219+ })
220+ }
221+}
222+
223+func TestFlashExcludedActions(t *testing.T) {
224+ for _, action := range []Action{ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveWindowToWorkspaceNext, ActionMoveColumnToWorkspaceNext, ActionToggleFullscreen, ActionMaximizeColumn, ActionCycleColumnWidth, ActionFocusColumnRight, ActionMoveColumnRight, ActionMoveWindowUp} {
225+ t.Run(string(action), func(t *testing.T) {
226+ c, _ := flashCore(t)
227+ c.applyAction(action)
228+ if c.flash.pending != nil {
229+ t.Fatalf("unexpected flash %+v", c.flash.pending)
230+ }
231+ })
232+ }
233+}
234+
235+func TestFlashMonitorTriggers(t *testing.T) {
236+ for _, action := range []Action{ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown} {
237+ t.Run(string(action), func(t *testing.T) {
238+ c, clock := flashCore(t)
239+ c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
240+ a, b := c.screens[0], c.screens[1]
241+ a.x, a.y = 0, 0
242+ b.x, b.y = 300, 0
243+ switch monitorDirection(action) {
244+ case dirLeft:
245+ b.x = -400
246+ case dirUp:
247+ b.x, b.y = 0, -300
248+ case dirDown:
249+ b.x, b.y = 0, 200
250+ }
251+ c.applyAction(action)
252+ if c.flash.pending == nil || c.flash.pending.level != flashMonitor || c.flash.pending.output != "B" {
253+ t.Fatalf("destination request %+v", c.flash.pending)
254+ }
255+ clock.now = clock.now.Add(flashSettle)
256+ if !c.flashTick() || c.flash.rect != (Rect{W: 400, H: 300}) {
257+ t.Fatalf("monitor rect %+v", c.flash.rect)
258+ }
259+ })
260+ }
261+}
262+
263+func TestFlashColumnUnion(t *testing.T) {
264+ c, clock := flashCore(t)
265+ w := c.cur().mon.Current()
266+ w.ConsumeOrExpel(-1)
267+ startFlash(t, c, clock, flashColumn, 3)
268+ col := w.Columns[w.columnOf(3)]
269+ var area int
270+ for _, p := range c.cur().mon.Layout() {
271+ for _, id := range col.Windows {
272+ if id == p.ID {
273+ area += p.Rect.W * p.Rect.H
274+ }
275+ }
276+ }
277+ if c.flash.rect.W*c.flash.rect.H != area {
278+ t.Fatalf("union %+v does not cover column area %d", c.flash.rect, area)
279+ }
280+}
281+
282+func TestFlashGestureLanding(t *testing.T) {
283+ c, _ := flashCore(t)
284+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
285+ c.swipeUpdate(ports.SwipeUpdate{DX: -800, Time: 50 * time.Millisecond})
286+ c.swipeEnd(ports.SwipeEnd{Time: 60 * time.Millisecond})
287+ if c.flash.pending == nil || c.flash.pending.level != flashColumn {
288+ t.Fatalf("landing request %+v", c.flash.pending)
289+ }
290+ c.stopFlash()
291+ c.swipeBegin(ports.SwipeBegin{Fingers: 3, Time: 100 * time.Millisecond})
292+ c.swipeUpdate(ports.SwipeUpdate{DX: 800, Time: 150 * time.Millisecond})
293+ c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: 160 * time.Millisecond})
294+ if c.flash.pending != nil {
295+ t.Fatal("cancelled gesture flashed")
296+ }
297+}
298+
299+func TestFlashLockClearsPendingAndRunning(t *testing.T) {
300+ for _, running := range []bool{false, true} {
301+ c, clock := flashCore(t)
302+ state := ports.SecurityState{}
303+ gate := portsmocks.NewMockSessionSecurity(t)
304+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
305+ c.opts.Security = gate
306+ if running {
307+ startFlash(t, c, clock, flashWindow, 3)
308+ } else {
309+ c.requestFlash(flashWindow, 3)
310+ }
311+ state = ports.SecurityState{Generation: 1, Protected: true}
312+ if !c.syncSecurity() {
313+ t.Fatal("security did not change")
314+ }
315+ if c.flash.pending != nil || c.flash.running != nil || c.flash.timerC != nil {
316+ t.Fatal("lock retained flash")
317+ }
318+ state = ports.SecurityState{Generation: 2}
319+ c.syncSecurity()
320+ if indicatorScene(t, c).Flash != nil || c.flash.pending != nil {
321+ t.Fatal("unlock requested flash")
322+ }
323+ }
324+}
325+
326+func TestFlashOverviewClickAndKeyboard(t *testing.T) {
327+ for _, click := range []bool{true, false} {
328+ c, _ := flashCore(t)
329+ c.cur().mon.ToggleOverview()
330+ scene := indicatorScene(t, c)
331+ if click {
332+ var rect Rect
333+ for _, p := range scene.Windows {
334+ if p.ID == 2 {
335+ rect = p.Rect
336+ }
337+ }
338+ c.cursorX, c.cursorY = float64(rect.X+rect.W/2), float64(rect.Y+rect.H/2)
339+ picked, err := c.overviewClick(context.Background())
340+ if err != nil || !picked {
341+ t.Fatalf("click %v %v", picked, err)
342+ }
343+ } else {
344+ if err := c.keyEvent(context.Background(), ports.KeyEvent{Keysym: "Return", Pressed: true}); err != nil {
345+ t.Fatal(err)
346+ }
347+ }
348+ if c.flash.pending == nil || c.flash.pending.level != flashWindow {
349+ t.Fatalf("overview request %+v", c.flash.pending)
350+ }
351+ }
352+}
353+
354+func TestFlashTileDrop(t *testing.T) {
355+ c, _ := flashCore(t)
356+ scene := indicatorScene(t, c)
357+ var rect Rect
358+ for _, p := range scene.Windows {
359+ if p.ID == 2 {
360+ rect = p.Rect
361+ }
362+ }
363+ c.cursorX, c.cursorY = float64(rect.X+rect.W/2), float64(rect.Y+rect.H/2)
364+ c.drag = &dragState{id: 3}
365+ if err := c.endDrag(context.Background()); err != nil {
366+ t.Fatal(err)
367+ }
368+ if c.flash.pending == nil || c.flash.pending.level != flashWindow || c.flash.pending.window != 3 {
369+ t.Fatalf("drop request %+v", c.flash.pending)
370+ }
371+}
372+
373+func TestFlashNoImplicitFocusTriggers(t *testing.T) {
374+ c, _ := flashCore(t)
375+ ctx := context.Background()
376+ indicatorScene(t, c)
377+ if c.flash.pending != nil {
378+ t.Fatal("startup publication flashed")
379+ }
380+ for _, ev := range []ports.ClientEvent{ports.WindowMapped{ID: 4}, ports.WindowActivate{ID: 1}} {
381+ if _, stop := c.clientEvent(ctx, ev); stop {
382+ t.Fatalf("event %T stopped", ev)
383+ }
384+ if c.flash.pending != nil {
385+ t.Fatalf("event %T flashed", ev)
386+ }
387+ }
388+ indicatorScene(t, c)
389+ if err := c.pointerMotion(ctx, ports.PointerMotion{X: 50, Y: 50}); err != nil {
390+ t.Fatal(err)
391+ }
392+ if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil {
393+ t.Fatal(err)
394+ }
395+ if c.flash.pending != nil {
396+ t.Fatal("pointer focus flashed")
397+ }
398+}
399+
400+func TestFlashCancelsStaleRunningTargets(t *testing.T) {
401+ for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {
402+ for _, change := range []string{"overview", "output gone", "fullscreen", "unmapped", "hidden", "other output", "workspace changed"} {
403+ t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) {
404+ c, clock := flashCore(t)
405+ a := c.cur()
406+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
407+ b := c.screens[1]
408+ startFlash(t, c, clock, level, 3)
409+ if indicatorScene(t, c).Flash == nil {
410+ t.Fatal("setup: no running flash")
411+ }
412+ switch change {
413+ case "overview":
414+ a.mon.ToggleOverview()
415+ case "output gone":
416+ c.screens = []*screen{b}
417+ c.focusScreen = 0
418+ case "fullscreen":
419+ a.mon.ToggleFullscreen()
420+ case "unmapped":
421+ c.windows.drop(3)
422+ case "hidden":
423+ w := a.mon.Current()
424+ w.ToggleWindowStash()
425+ w.ToggleStashVisible()
426+ case "other output":
427+ a.mon.RemoveWindow(3)
428+ b.mon.AddWindow(3)
429+ case "workspace changed":
430+ a.mon.Focus(a.mon.Active + 1)
431+ }
432+ // Cancellation must not wait for the target output's next flip.
433+ scenes := stepScenes(t, c, b)
434+ shouldCancel := level != flashMonitor || change == "overview" || change == "output gone"
435+ if shouldCancel {
436+ if scenes["A"].Flash != nil || c.flash.running != nil || c.animating() {
437+ t.Fatalf("stale flash retained: %+v, animating=%v", scenes["A"].Flash, c.animating())
438+ }
439+ } else if scenes["A"].Flash == nil || !c.animating() {
440+ t.Fatal("monitor flash cancelled for a window change")
441+ }
442+ })
443+ }
444+ }
445+}
446+
447+func TestFlashCooldownRemembersEveryTarget(t *testing.T) {
448+ c, clock := flashCore(t)
449+ startFlash(t, c, clock, flashWindow, 3)
450+ first := c.flash.start
451+ startFlash(t, c, clock, flashWindow, 2)
452+ c.requestFlash(flashWindow, 3)
453+ clock.now = clock.now.Add(flashSettle)
454+ if c.flashTick() || c.flash.running != nil {
455+ t.Fatal("A flashed again after B within cooldown")
456+ }
457+ clock.now = first.Add(flashCooldown)
458+ startFlash(t, c, clock, flashWindow, 3)
459+ if c.flash.running.window != 3 {
460+ t.Fatal("A did not flash after cooldown")
461+ }
462+ // Inserting after all previous starts expire prunes old target keys.
463+ clock.now = clock.now.Add(flashCooldown)
464+ startFlash(t, c, clock, flashMonitor, 0)
465+ if len(c.flash.lastStarts) != 1 {
466+ t.Fatalf("expired cooldown entries retained: %v", c.flash.lastStarts)
467+ }
468+}
469+
470+// BenchmarkFlashSceneBuild includes revalidation and immutable scene creation.
471+func BenchmarkFlashSceneBuild(b *testing.B) {
472+ var cfg ports.Config
473+ cfg.Layout.MaxColumns = 2
474+ cfg.Keyboard.CmdKey = "super"
475+ cfg.Animations.On = true
476+ cfg.Focus.Flash = true
477+ cfg.Focus.FlashOpacity = 0.8
478+ cfg.Focus.FlashDuration = time.Hour
479+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
480+ if err != nil {
481+ b.Fatal(err)
482+ }
483+ c.addScreen(ports.OutputInfo{Name: "A", Width: 1000, Height: 600})
484+ c.windows.mapped(ports.WindowMapped{ID: 1}, time.Now())
485+ c.cur().mon.AddWindow(1)
486+ c.flash.running = &flashTarget{level: flashWindow, window: 1, output: "A"}
487+ c.flash.start = time.Now()
488+ c.flash.rect = Rect{W: 1000, H: 600}
489+ c.refreshShown()
490+ ctx := context.Background()
491+ b.ReportAllocs()
492+ b.ResetTimer()
493+ for range b.N {
494+ c.refreshShown()
495+ c.advanceFlash(nil)
496+ scene, err := c.sceneFor(ctx, 0, c.cur(), nil, nil)
497+ if err != nil {
498+ b.Fatal(err)
499+ }
500+ c.cur().last = scene
501+ }
502+}
503+
504+func TestFlashEdgeWorkspaceSwitchDoesNotFlash(t *testing.T) {
505+ for _, tc := range []struct {
506+ name string
507+ overflow Overflow
508+ action Action
509+ }{
510+ {"vertical", OverflowScroll, ActionFocusWindowDown},
511+ {"horizontal", OverflowCascade, ActionFocusColumnRight},
512+ } {
513+ t.Run(tc.name, func(t *testing.T) {
514+ c, _ := flashCore(t)
515+ m := c.cur().mon
516+ m.SetOverflow(tc.overflow)
517+ before := m.Current()
518+ m.Focus(1)
519+ m.AddWindow(4)
520+ m.Focus(0)
521+ c.applyAction(tc.action)
522+ if m.Current() == before {
523+ t.Fatal("setup: edge did not switch workspace")
524+ }
525+ if id, _ := m.Focused(); id != 4 {
526+ t.Fatalf("did not land on occupied workspace: %d", id)
527+ }
528+ if c.flash.pending != nil || c.flash.running != nil {
529+ t.Fatal("workspace switch flashed")
530+ }
531+ })
532+ }
533+}
534+
535+func TestFlashExplicitActionOnFreshlyMappedWindow(t *testing.T) {
536+ c, clock := flashCore(t)
537+ c.clientEvent(context.Background(), ports.WindowMapped{ID: 4})
538+ if c.flash.pending != nil {
539+ t.Fatal("mapping requested flash")
540+ }
541+ c.applyAction(ActionToggleFloating)
542+ if c.flash.pending == nil || c.flash.pending.window != 4 {
543+ t.Fatal("explicit action did not request flash")
544+ }
545+ c.stopAnimations()
546+ clock.now = clock.now.Add(flashSettle)
547+ if !c.flashTick() {
548+ t.Fatal("freshly mapped target rejected")
549+ }
550+}
551+
552+func TestFlashPendingTimerStopped(t *testing.T) {
553+ for _, reason := range []string{"replacement", "security", "run exit"} {
554+ t.Run(reason, func(t *testing.T) {
555+ c, clock := flashCore(t)
556+ timer := portsmocks.NewMockTimer(t)
557+ timer.EXPECT().C().Return(make(chan time.Time)).Once()
558+ timer.EXPECT().Stop().Return(true).Times(1)
559+ strictClock := portsmocks.NewMockClock(t)
560+ strictClock.EXPECT().Now().Return(clock.now).Maybe()
561+ strictClock.EXPECT().NewTimer(flashSettle).Return(timer).Once()
562+ c.opts.Clock = strictClock
563+ c.requestFlash(flashWindow, 3)
564+ // Only the pending flash timer belongs to the strict clock.
565+ c.opts.Clock = nil
566+ switch reason {
567+ case "replacement":
568+ c.requestFlash(flashWindow, 2)
569+ c.stopFlash()
570+ case "security":
571+ gate := portsmocks.NewMockSessionSecurity(t)
572+ gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
573+ c.opts.Security = gate
574+ c.syncSecurity()
575+ case "run exit":
576+ ctx, cancel := context.WithCancel(context.Background())
577+ cancel()
578+ if err := c.Run(ctx); err != nil {
579+ t.Fatal(err)
580+ }
581+ }
582+ })
583+ }
584+}
585+
586+func TestFlashUnchangedSampleIsReused(t *testing.T) {
587+ c, clock := flashCore(t)
588+ startFlash(t, c, clock, flashWindow, 3)
589+ first := indicatorScene(t, c).Flash
590+ if first == nil {
591+ t.Fatal("missing flash")
592+ }
593+ if next := indicatorScene(t, c).Flash; next != first {
594+ t.Fatal("unchanged immutable sample not reused")
595+ }
596+ clock.now = clock.now.Add(time.Millisecond)
597+ if next := indicatorScene(t, c).Flash; next == first || first.Alpha != c.cfg.Focus.FlashOpacity {
598+ t.Fatal("changed sample mutated prior scene")
599+ }
600+}
D internal/core/frame_pulse_test.go +0 -69
@@ -1,69 +0,0 @@
1-package core
2-
3-import (
4- "context"
5- "testing"
6- "time"
7-
8- "github.com/bnema/neferwl/internal/ports"
9-)
10-
11-// stepScenes steps only the screen and returns the scene of each output.
12-func stepScenes(t *testing.T, c *Core, only *screen) map[string]ports.Scene {
13- t.Helper()
14- if err := c.step(context.Background(), only); err != nil {
15- t.Fatal(err)
16- }
17- out := map[string]ports.Scene{}
18- for _, s := range <-c.ch.Scenes {
19- out[s.Output] = s
20- }
21- return out
22-}
23-
24-// While the focus pulse runs on the focused output, a flip of another output
25-// keeps the focused output's Seq; its own flip samples the pulse.
26-func TestFlipOfOtherOutputKeepsPulseSeq(t *testing.T) {
27- c, ic := pulseCore(t)
28- a := c.cur()
29- c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
30- b := c.screens[1]
31- for id := WindowID(3); id <= 5; id++ {
32- b.mon.AddWindow(id)
33- }
34- indicatorScene(t, c)
35-
36- // A pulse on window 1 of A.
37- a.mon.Current().FocusID(1)
38- indicatorScene(t, c)
39- ic.now = ic.now.Add(pulseSettle)
40- if !c.pulseTick() {
41- t.Fatal("no pulse after settle")
42- }
43- // A camera transition runs on B.
44- w := b.mon.Current()
45- w.view.off = 100
46- w.view.motion = newMotion(viewSpring(w.view.off, 0), ic.now, 1)
47-
48- ic.now = ic.now.Add(pulseRise / 2)
49- first := stepScenes(t, c, a)
50- if pulseOf(first["A"], 1) <= 0 {
51- t.Fatalf("no pulse on A: %v", pulseOf(first["A"], 1))
52- }
53- ic.now = ic.now.Add(10 * time.Millisecond)
54- second := stepScenes(t, c, b)
55- if second["A"].Seq != first["A"].Seq || !second["A"].SameAs(first["A"]) {
56- t.Fatalf("A changed on a flip of B: Seq %d, was %d", second["A"].Seq, first["A"].Seq)
57- }
58- if second["B"].SameAs(first["B"]) {
59- t.Fatal("B did not move on its own flip")
60- }
61- ic.now = ic.now.Add(10 * time.Millisecond)
62- third := stepScenes(t, c, a)
63- if third["A"].Seq <= second["A"].Seq || pulseOf(third["A"], 1) == pulseOf(second["A"], 1) {
64- t.Fatalf("A did not sample the pulse on its own flip: Seq %d, was %d", third["A"].Seq, second["A"].Seq)
65- }
66- if third["B"].Seq != second["B"].Seq {
67- t.Fatalf("B changed on a flip of A: Seq %d, was %d", third["B"].Seq, second["B"].Seq)
68- }
69-}
@@ -1,69 +0,0 @@
1-package core
2-
3-import (
4- "context"
5- "testing"
6- "time"
7-
8- "github.com/bnema/neferwl/internal/ports"
9-)
10-
11-// stepScenes steps only the screen and returns the scene of each output.
12-func stepScenes(t *testing.T, c *Core, only *screen) map[string]ports.Scene {
13- t.Helper()
14- if err := c.step(context.Background(), only); err != nil {
15- t.Fatal(err)
16- }
17- out := map[string]ports.Scene{}
18- for _, s := range <-c.ch.Scenes {
19- out[s.Output] = s
20- }
21- return out
22-}
23-
24-// While the focus pulse runs on the focused output, a flip of another output
25-// keeps the focused output's Seq; its own flip samples the pulse.
26-func TestFlipOfOtherOutputKeepsPulseSeq(t *testing.T) {
27- c, ic := pulseCore(t)
28- a := c.cur()
29- c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
30- b := c.screens[1]
31- for id := WindowID(3); id <= 5; id++ {
32- b.mon.AddWindow(id)
33- }
34- indicatorScene(t, c)
35-
36- // A pulse on window 1 of A.
37- a.mon.Current().FocusID(1)
38- indicatorScene(t, c)
39- ic.now = ic.now.Add(pulseSettle)
40- if !c.pulseTick() {
41- t.Fatal("no pulse after settle")
42- }
43- // A camera transition runs on B.
44- w := b.mon.Current()
45- w.view.off = 100
46- w.view.motion = newMotion(viewSpring(w.view.off, 0), ic.now, 1)
47-
48- ic.now = ic.now.Add(pulseRise / 2)
49- first := stepScenes(t, c, a)
50- if pulseOf(first["A"], 1) <= 0 {
51- t.Fatalf("no pulse on A: %v", pulseOf(first["A"], 1))
52- }
53- ic.now = ic.now.Add(10 * time.Millisecond)
54- second := stepScenes(t, c, b)
55- if second["A"].Seq != first["A"].Seq || !second["A"].SameAs(first["A"]) {
56- t.Fatalf("A changed on a flip of B: Seq %d, was %d", second["A"].Seq, first["A"].Seq)
57- }
58- if second["B"].SameAs(first["B"]) {
59- t.Fatal("B did not move on its own flip")
60- }
61- ic.now = ic.now.Add(10 * time.Millisecond)
62- third := stepScenes(t, c, a)
63- if third["A"].Seq <= second["A"].Seq || pulseOf(third["A"], 1) == pulseOf(second["A"], 1) {
64- t.Fatalf("A did not sample the pulse on its own flip: Seq %d, was %d", third["A"].Seq, second["A"].Seq)
65- }
66- if third["B"].Seq != second["B"].Seq {
67- t.Fatalf("B changed on a flip of A: Seq %d, was %d", third["B"].Seq, second["B"].Seq)
68- }
69-}
M internal/core/gesture.go +17 -5
@@ -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,15 +586,15 @@ 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() || c.pulsing(c.screens[i]))
592+ return i >= 0 && (c.screens[i].springing() || c.flashing(c.screens[i]))
581593 }
582594
583-// animating reports whether a spring or the focus pulse runs on any output.
595+// animating reports whether a spring or focus flash runs on any output.
584596 func (c *Core) animating() bool {
585- if c.pulse.id != 0 {
597+ if c.flash.running != nil {
586598 return true
587599 }
588600 for _, sc := range c.screens {
@@ -610,7 +622,7 @@ func (m *Monitor) springing() bool {
610622 func (c *Core) frameFallback() time.Duration {
611623 refresh := 60000
612624 for _, sc := range c.screens {
613- if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.pulsing(sc)) {
625+ if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.flashing(sc)) {
614626 refresh = min(refresh, r)
615627 }
616628 }
@@ -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,15 +586,15 @@ 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() || c.pulsing(c.screens[i]))592+ return i >= 0 && (c.screens[i].springing() || c.flashing(c.screens[i]))
581 }593 }
582 594
583-// animating reports whether a spring or the focus pulse 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 {
585- if c.pulse.id != 0 {597+ if c.flash.running != nil {
586 return true598 return true
587 }599 }
588 for _, sc := range c.screens {600 for _, sc := range c.screens {
@@ -610,7 +622,7 @@ func (m *Monitor) springing() bool {
610 func (c *Core) frameFallback() time.Duration {622 func (c *Core) frameFallback() time.Duration {
611 refresh := 60000623 refresh := 60000
612 for _, sc := range c.screens {624 for _, sc := range c.screens {
613- if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.pulsing(sc)) {625+ if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.flashing(sc)) {
614 refresh = min(refresh, r)626 refresh = min(refresh, r)
615 }627 }
616 }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/persist_test.go +1 -1
@@ -22,7 +22,7 @@ func steppingClock(t *testing.T) *portsmocks.MockClock {
2222 now = now.Add(time.Second)
2323 return now
2424 }).Maybe()
25- // Timers (the focus pulse) never fire: tests drive events only.
25+ // Timers never fire: tests drive events only.
2626 clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
2727 timer := portsmocks.NewMockTimer(t)
2828 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
@@ -22,7 +22,7 @@ func steppingClock(t *testing.T) *portsmocks.MockClock {
22 now = now.Add(time.Second)22 now = now.Add(time.Second)
23 return now23 return now
24 }).Maybe()24 }).Maybe()
25- // Timers (the focus pulse) never fire: tests drive events only.25+ // Timers never fire: tests drive events only.
26 clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {26 clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
27 timer := portsmocks.NewMockTimer(t)27 timer := portsmocks.NewMockTimer(t)
28 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()28 timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
D internal/core/pulse.go +0 -183
@@ -1,183 +0,0 @@
1-package core
2-
3-import (
4- "math"
5- "time"
6-
7- "github.com/bnema/neferwl/internal/ports"
8-)
9-
10-// The focus pulse is the pulse animation of the focus indicator
11-// (focus.animation): it briefly shows the indicator's effect (focus.effect, a
12-// light screen blend visible on black backgrounds too) on a window that just
13-// got the keyboard focus. Only the last focus counts: it must hold for
14-// pulseSettle before the pulse starts, a focus change stops a running one, and
15-// the window that pulsed last does not pulse again within pulseCooldown
16-// (switching back after another window held the focus is a new change).
17-// Fast switching therefore shows nothing. Fullscreen windows, a window alone
18-// in the usable area (e.g. a maximized column), overview previews, a window focused as
19-// it maps and a protected session never pulse.
20-
21-const (
22- pulseSettle = 150 * time.Millisecond
23- pulseRise = 90 * time.Millisecond
24- pulseFall = 230 * time.Millisecond
25- pulseCooldown = time.Second
26- // pulseRecheck is how often a pulse waiting for a slide to land checks again.
27- pulseRecheck = pulseSettle / 3
28-)
29-
30-// focusPulse is the pulse state. target is the window focus last seen and
31-// since when; id is the window pulsing from start (0: none). last and lastAt
32-// are the previous pulse, for the cooldown. drawable is whether the last
33-// scene could show a pulse on target.
34-type focusPulse struct {
35- target WindowID
36- since time.Time
37- id WindowID
38- start time.Time
39- last WindowID
40- lastAt time.Time
41- drawable bool
42- // value is the effect of the last sample, kept for scenes published
43- // between the focused output's own flips.
44- value float64
45- timerC <-chan time.Time
46- timerStop func() bool
47-}
48-
49-func (c *Core) pulseOn() bool {
50- return c.animOn() && c.cfg.Focus.Animation == ports.FocusAnimationPulse
51-}
52-
53-// pulseFocus follows the window focus. held is false while something else
54-// (a popup grab, a layer, a lock surface) has the keyboard: the running pulse
55-// stops, and the window getting it back is not a new focus. A new window
56-// stops the running pulse and waits pulseSettle before one starts. With the
57-// pulse off the focus is still followed, so turning it on pulses nothing.
58-func (c *Core) pulseFocus(window WindowID, held bool) {
59- p := &c.pulse
60- if !held || !c.pulseOn() {
61- p.id = 0
62- c.stopPulseTimer()
63- if held {
64- p.target = window
65- }
66- return
67- }
68- if window == p.target {
69- return
70- }
71- p.target, p.id = window, 0
72- c.stopPulseTimer()
73- if window == 0 {
74- return
75- }
76- p.since = c.now()
77- if c.justMapped(window) {
78- return
79- }
80- p.timerC, p.timerStop = newTimer(c.opts.Clock, pulseSettle)
81-}
82-
83-// justMapped reports whether the window got the focus as it mapped: opening
84-// an app is not a focus change, and its first frames may still be on their
85-// way. Its focus counts from the next change.
86-func (c *Core) justMapped(id WindowID) bool {
87- p := &c.pulse
88- return p.target == id && p.since.Sub(c.windows.lookup(id).mappedAt) < pulseSettle
89-}
90-
91-// pulseTick runs when the settle timer fired. It reports whether a pulse
92-// started.
93-func (c *Core) pulseTick() bool {
94- p := &c.pulse
95- p.timerC, p.timerStop = nil, nil
96- if p.target == 0 || !c.pulseOn() || !p.drawable || c.justMapped(p.target) {
97- return false
98- }
99- now := c.now()
100- if wait := pulseSettle - now.Sub(p.since); wait > 0 {
101- p.timerC, p.timerStop = newTimer(c.opts.Clock, wait)
102- return false
103- }
104- // A swipe or its landing slide still shows the neighbours: decide on
105- // the settled layout, or a window left alone would start a pulse cut
106- // short.
107- if !c.cur().settled() {
108- p.timerC, p.timerStop = newTimer(c.opts.Clock, pulseRecheck)
109- return false
110- }
111- if p.target == p.last && now.Sub(p.lastAt) < pulseCooldown {
112- return false
113- }
114- p.id, p.start, p.last, p.lastAt = p.target, now, p.target, now
115- return true
116-}
117-
118-// advancePulse is the effect of the running pulse at now: a quick sine rise
119-// to focus.strength, then a smooth fall. It ends the pulse once over.
120-func (c *Core) advancePulse(now time.Time) float64 {
121- p := &c.pulse
122- if p.id == 0 {
123- return 0
124- }
125- peak := c.cfg.Focus.Strength
126- t := now.Sub(p.start)
127- switch {
128- case t < 0:
129- return 0
130- case t < pulseRise:
131- return peak * math.Sin(float64(t)/float64(pulseRise)*math.Pi/2)
132- case t < pulseRise+pulseFall:
133- x := 1 - float64(t-pulseRise)/float64(pulseFall)
134- return peak * x * x * (3 - 2*x)
135- }
136- p.id = 0
137- return 0
138-}
139-
140-// settled reports whether no swipe or slide moves the monitor's layout
141-// (screen.settled adds the rect motions).
142-func (m *Monitor) settled() bool {
143- if m.switchView.busy() {
144- return false
145- }
146- for w := range m.all() {
147- if w.view.busy() || w.stashView.busy() {
148- return false
149- }
150- }
151- return true
152-}
153-
154-// visibleCount is the number of windows of layout seen in v.
155-func visibleCount(layout []Placement, v viewport) int {
156- n := 0
157- for _, p := range layout {
158- if p.Preview == 0 && v.shows(p) {
159- n++
160- }
161- }
162- return n
163-}
164-
165-// pulsing reports whether a pulse runs on the screen.
166-func (c *Core) pulsing(sc *screen) bool {
167- return c.pulse.id != 0 && sc == c.cur()
168-}
169-
170-func (c *Core) stopPulseTimer() {
171- p := &c.pulse
172- if p.timerStop != nil {
173- p.timerStop()
174- }
175- p.timerC, p.timerStop = nil, nil
176-}
177-
178-// stopPulse ends the running pulse and its timer (security change). The
179-// focus is kept: the window focused again after an unlock does not pulse.
180-func (c *Core) stopPulse() {
181- c.stopPulseTimer()
182- c.pulse.id, c.pulse.drawable = 0, false
183-}
@@ -1,183 +0,0 @@
1-package core
2-
3-import (
4- "math"
5- "time"
6-
7- "github.com/bnema/neferwl/internal/ports"
8-)
9-
10-// The focus pulse is the pulse animation of the focus indicator
11-// (focus.animation): it briefly shows the indicator's effect (focus.effect, a
12-// light screen blend visible on black backgrounds too) on a window that just
13-// got the keyboard focus. Only the last focus counts: it must hold for
14-// pulseSettle before the pulse starts, a focus change stops a running one, and
15-// the window that pulsed last does not pulse again within pulseCooldown
16-// (switching back after another window held the focus is a new change).
17-// Fast switching therefore shows nothing. Fullscreen windows, a window alone
18-// in the usable area (e.g. a maximized column), overview previews, a window focused as
19-// it maps and a protected session never pulse.
20-
21-const (
22- pulseSettle = 150 * time.Millisecond
23- pulseRise = 90 * time.Millisecond
24- pulseFall = 230 * time.Millisecond
25- pulseCooldown = time.Second
26- // pulseRecheck is how often a pulse waiting for a slide to land checks again.
27- pulseRecheck = pulseSettle / 3
28-)
29-
30-// focusPulse is the pulse state. target is the window focus last seen and
31-// since when; id is the window pulsing from start (0: none). last and lastAt
32-// are the previous pulse, for the cooldown. drawable is whether the last
33-// scene could show a pulse on target.
34-type focusPulse struct {
35- target WindowID
36- since time.Time
37- id WindowID
38- start time.Time
39- last WindowID
40- lastAt time.Time
41- drawable bool
42- // value is the effect of the last sample, kept for scenes published
43- // between the focused output's own flips.
44- value float64
45- timerC <-chan time.Time
46- timerStop func() bool
47-}
48-
49-func (c *Core) pulseOn() bool {
50- return c.animOn() && c.cfg.Focus.Animation == ports.FocusAnimationPulse
51-}
52-
53-// pulseFocus follows the window focus. held is false while something else
54-// (a popup grab, a layer, a lock surface) has the keyboard: the running pulse
55-// stops, and the window getting it back is not a new focus. A new window
56-// stops the running pulse and waits pulseSettle before one starts. With the
57-// pulse off the focus is still followed, so turning it on pulses nothing.
58-func (c *Core) pulseFocus(window WindowID, held bool) {
59- p := &c.pulse
60- if !held || !c.pulseOn() {
61- p.id = 0
62- c.stopPulseTimer()
63- if held {
64- p.target = window
65- }
66- return
67- }
68- if window == p.target {
69- return
70- }
71- p.target, p.id = window, 0
72- c.stopPulseTimer()
73- if window == 0 {
74- return
75- }
76- p.since = c.now()
77- if c.justMapped(window) {
78- return
79- }
80- p.timerC, p.timerStop = newTimer(c.opts.Clock, pulseSettle)
81-}
82-
83-// justMapped reports whether the window got the focus as it mapped: opening
84-// an app is not a focus change, and its first frames may still be on their
85-// way. Its focus counts from the next change.
86-func (c *Core) justMapped(id WindowID) bool {
87- p := &c.pulse
88- return p.target == id && p.since.Sub(c.windows.lookup(id).mappedAt) < pulseSettle
89-}
90-
91-// pulseTick runs when the settle timer fired. It reports whether a pulse
92-// started.
93-func (c *Core) pulseTick() bool {
94- p := &c.pulse
95- p.timerC, p.timerStop = nil, nil
96- if p.target == 0 || !c.pulseOn() || !p.drawable || c.justMapped(p.target) {
97- return false
98- }
99- now := c.now()
100- if wait := pulseSettle - now.Sub(p.since); wait > 0 {
101- p.timerC, p.timerStop = newTimer(c.opts.Clock, wait)
102- return false
103- }
104- // A swipe or its landing slide still shows the neighbours: decide on
105- // the settled layout, or a window left alone would start a pulse cut
106- // short.
107- if !c.cur().settled() {
108- p.timerC, p.timerStop = newTimer(c.opts.Clock, pulseRecheck)
109- return false
110- }
111- if p.target == p.last && now.Sub(p.lastAt) < pulseCooldown {
112- return false
113- }
114- p.id, p.start, p.last, p.lastAt = p.target, now, p.target, now
115- return true
116-}
117-
118-// advancePulse is the effect of the running pulse at now: a quick sine rise
119-// to focus.strength, then a smooth fall. It ends the pulse once over.
120-func (c *Core) advancePulse(now time.Time) float64 {
121- p := &c.pulse
122- if p.id == 0 {
123- return 0
124- }
125- peak := c.cfg.Focus.Strength
126- t := now.Sub(p.start)
127- switch {
128- case t < 0:
129- return 0
130- case t < pulseRise:
131- return peak * math.Sin(float64(t)/float64(pulseRise)*math.Pi/2)
132- case t < pulseRise+pulseFall:
133- x := 1 - float64(t-pulseRise)/float64(pulseFall)
134- return peak * x * x * (3 - 2*x)
135- }
136- p.id = 0
137- return 0
138-}
139-
140-// settled reports whether no swipe or slide moves the monitor's layout
141-// (screen.settled adds the rect motions).
142-func (m *Monitor) settled() bool {
143- if m.switchView.busy() {
144- return false
145- }
146- for w := range m.all() {
147- if w.view.busy() || w.stashView.busy() {
148- return false
149- }
150- }
151- return true
152-}
153-
154-// visibleCount is the number of windows of layout seen in v.
155-func visibleCount(layout []Placement, v viewport) int {
156- n := 0
157- for _, p := range layout {
158- if p.Preview == 0 && v.shows(p) {
159- n++
160- }
161- }
162- return n
163-}
164-
165-// pulsing reports whether a pulse runs on the screen.
166-func (c *Core) pulsing(sc *screen) bool {
167- return c.pulse.id != 0 && sc == c.cur()
168-}
169-
170-func (c *Core) stopPulseTimer() {
171- p := &c.pulse
172- if p.timerStop != nil {
173- p.timerStop()
174- }
175- p.timerC, p.timerStop = nil, nil
176-}
177-
178-// stopPulse ends the running pulse and its timer (security change). The
179-// focus is kept: the window focused again after an unlock does not pulse.
180-func (c *Core) stopPulse() {
181- c.stopPulseTimer()
182- c.pulse.id, c.pulse.drawable = 0, false
183-}
D internal/core/pulse_test.go +0 -319
@@ -1,319 +0,0 @@
1-package core
2-
3-import (
4- "testing"
5- "time"
6-
7- "github.com/bnema/neferwl/internal/ports"
8-)
9-
10-// pulseCore is indicatorCore with the focus pulse on and two tiled windows,
11-// 2 focused, its pulse settled and over.
12-func pulseCore(t *testing.T) (*Core, *indicatorClock) {
13- t.Helper()
14- c, ic := indicatorCore(t)
15- c.cfg.Animations.On = true
16- c.cfg.Focus.Animation = ports.FocusAnimationPulse
17- c.cfg.Focus.Strength = 0.05
18- c.cur().mon.AddWindow(1)
19- c.cur().mon.AddWindow(2)
20- indicatorScene(t, c)
21- ic.now = ic.now.Add(pulseSettle)
22- c.pulseTick()
23- ic.now = ic.now.Add(pulseCooldown)
24- indicatorScene(t, c)
25- return c, ic
26-}
27-
28-func pulseOf(s ports.Scene, id WindowID) float64 {
29- for _, w := range s.Windows {
30- if w.ID == id {
31- return w.FocusEffect
32- }
33- }
34- return -1
35-}
36-
37-func TestFocusPulseAfterSettle(t *testing.T) {
38- c, ic := pulseCore(t)
39- c.cur().mon.Current().FocusID(1)
40- if s := indicatorScene(t, c); pulseOf(s, 1) != 0 {
41- t.Fatalf("pulse before settle: %v", pulseOf(s, 1))
42- }
43- ic.now = ic.now.Add(pulseSettle)
44- if !c.pulseTick() {
45- t.Fatal("no pulse after settle")
46- }
47- if !c.animating() {
48- t.Fatal("a running pulse asks for no frames")
49- }
50- ic.now = ic.now.Add(pulseRise)
51- s := indicatorScene(t, c)
52- if p := pulseOf(s, 1); p < 0.05*0.99 || p > 0.05 {
53- t.Fatalf("peak %v, want the configured 0.05", p)
54- }
55- if pulseOf(s, 2) != 0 {
56- t.Fatal("unfocused window pulses")
57- }
58- ic.now = ic.now.Add(pulseFall)
59- if s := indicatorScene(t, c); pulseOf(s, 1) != 0 || c.animating() {
60- t.Fatalf("pulse left after its end: %v", pulseOf(s, 1))
61- }
62-}
63-
64-// Switching faster than pulseSettle never pulses: only the last focus counts.
65-func TestFocusPulseDebounced(t *testing.T) {
66- c, ic := pulseCore(t)
67- for range 4 {
68- c.cur().mon.Current().FocusID(1)
69- indicatorScene(t, c)
70- ic.now = ic.now.Add(pulseSettle / 3)
71- c.cur().mon.Current().FocusID(2)
72- indicatorScene(t, c)
73- ic.now = ic.now.Add(pulseSettle / 3)
74- }
75- // A stale timer of an earlier focus fires: the focus is too recent.
76- if c.pulseTick() {
77- t.Fatal("pulse while switching")
78- }
79- if c.pulse.timerC == nil {
80- t.Fatal("no timer for the last focus")
81- }
82- ic.now = ic.now.Add(pulseSettle)
83- // Window 2 pulsed in pulseCore over a second ago: it may pulse again.
84- if !c.pulseTick() || c.pulse.id != 2 {
85- t.Fatalf("last focus did not pulse: %d", c.pulse.id)
86- }
87-}
88-
89-// A focus change stops the running pulse; coming back within the cooldown
90-// does not pulse the same window again.
91-func TestFocusPulseStopsAndCoolsDown(t *testing.T) {
92- c, ic := pulseCore(t)
93- c.cur().mon.Current().FocusID(1)
94- indicatorScene(t, c)
95- ic.now = ic.now.Add(pulseSettle)
96- c.pulseTick()
97- ic.now = ic.now.Add(pulseRise / 2)
98- c.cur().mon.Current().FocusID(2)
99- if s := indicatorScene(t, c); pulseOf(s, 1) != 0 || pulseOf(s, 2) != 0 {
100- t.Fatal("pulse survived a focus change")
101- }
102- c.cur().mon.Current().FocusID(1)
103- indicatorScene(t, c)
104- ic.now = ic.now.Add(pulseSettle)
105- if c.pulseTick() {
106- t.Fatal("same window pulsed within the cooldown")
107- }
108-}
109-
110-// A window focused as it maps does not pulse; focusing it again later does.
111-func TestFocusPulseSkipsNewWindow(t *testing.T) {
112- c, ic := pulseCore(t)
113- c.windows.mapped(ports.WindowMapped{ID: 3}, ic.now)
114- c.cur().mon.AddWindow(3)
115- indicatorScene(t, c)
116- if c.pulse.timerC != nil {
117- t.Fatal("opening a window arms the pulse")
118- }
119- ic.now = ic.now.Add(pulseSettle)
120- if c.pulseTick() || c.animating() {
121- t.Fatal("a new window pulses")
122- }
123- c.cur().mon.Current().FocusID(1)
124- indicatorScene(t, c)
125- c.cur().mon.Current().FocusID(3)
126- indicatorScene(t, c)
127- ic.now = ic.now.Add(pulseSettle)
128- if !c.pulseTick() || c.pulse.id != 3 {
129- t.Fatal("an existing window did not pulse")
130- }
131-}
132-
133-// A window that mapped earlier without the focus pulses when focused.
134-func TestFocusPulseWindowMappedEarlier(t *testing.T) {
135- c, ic := pulseCore(t)
136- c.windows.mapped(ports.WindowMapped{ID: 1}, ic.now.Add(-time.Minute))
137- c.cur().mon.Current().FocusID(1)
138- indicatorScene(t, c)
139- ic.now = ic.now.Add(pulseSettle)
140- if !c.pulseTick() || c.pulse.id != 1 {
141- t.Fatal("a window mapped earlier did not pulse")
142- }
143-}
144-
145-func TestFocusPulseSkipsFullscreen(t *testing.T) {
146- c, ic := pulseCore(t)
147- c.cur().mon.Current().FocusID(1)
148- c.cur().mon.ToggleFullscreen()
149- indicatorScene(t, c)
150- ic.now = ic.now.Add(pulseSettle)
151- if c.pulseTick() || c.animating() {
152- t.Fatal("fullscreen window asks for pulse frames")
153- }
154- // Fullscreen entered mid-pulse ends it.
155- c.cur().mon.ToggleFullscreen()
156- c.cur().mon.Current().FocusID(2)
157- indicatorScene(t, c)
158- ic.now = ic.now.Add(pulseSettle)
159- if !c.pulseTick() {
160- t.Fatal("no pulse on the tiled window")
161- }
162- c.cur().mon.ToggleFullscreen()
163- if s := indicatorScene(t, c); pulseOf(s, 2) != 0 || c.animating() {
164- t.Fatal("pulse survived fullscreen")
165- }
166-}
167-
168-// A window alone on screen (a maximized column here) needs no pulse; once
169-// another window shows beside it, focusing it pulses again.
170-func TestFocusPulseSkipsAloneOnScreen(t *testing.T) {
171- c, ic := pulseCore(t)
172- c.cur().mon.Current().FocusID(1)
173- c.cur().mon.Current().ToggleFullWidth()
174- indicatorScene(t, c)
175- ic.now = ic.now.Add(pulseSettle)
176- if c.pulseTick() || c.animating() {
177- t.Fatal("a window alone on screen pulses")
178- }
179- c.cur().mon.Current().ToggleFullWidth()
180- c.cur().mon.Current().FocusID(2)
181- indicatorScene(t, c)
182- ic.now = ic.now.Add(pulseSettle)
183- if !c.pulseTick() || c.pulse.id != 2 {
184- t.Fatal("a window beside another did not pulse")
185- }
186-}
187-
188-// A cascade window alone in its band above a bottom panel is alone on
189-// screen: the next band, under the panel, does not count.
190-func TestFocusPulseSkipsAloneAbovePanel(t *testing.T) {
191- c, ic := pulseCore(t)
192- m := c.cur().mon
193- m.SetOverflow(OverflowCascade)
194- m.SetMaxColumns(1)
195- m.SetUsable(Rect{W: 300, H: 180})
196- m.Current().FocusID(1)
197- indicatorScene(t, c)
198- ic.now = ic.now.Add(pulseSettle)
199- if c.pulseTick() || c.animating() {
200- t.Fatal("a window alone in its band above a panel pulses")
201- }
202-}
203-
204-// A swipe, its landing slide or a transition still shows the neighbours: the
205-// pulse waits for the layout to settle, then decides on it.
206-func TestFocusPulseWaitsForSlide(t *testing.T) {
207- for _, tc := range []struct {
208- name string
209- slide func(*testing.T, *Core, time.Time)
210- alone bool
211- }{
212- {"workspace landing, alone", func(_ *testing.T, c *Core, now time.Time) {
213- c.cur().mon.switchView.motion = newMotion(workspaceSpring(0.5, 0), now, 1)
214- }, true},
215- {"workspace drag, alone", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.switchView.off = 0.3 }, true},
216- {"column drag, beside another", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.Current().view.off = 40 }, false},
217- {"rect motion, beside another", func(_ *testing.T, c *Core, now time.Time) {
218- sc := c.cur()
219- sc.rects = map[WindowID]rectMotion{1: {x: c.spring(viewSpring(40, 0), now), dx: 40}}
220- sc.rectsWS = sc.mon.Current()
221- }, false},
222- {"camera transition of a key, beside another", func(t *testing.T, c *Core, now time.Time) {
223- m := c.cur().mon
224- for id := WindowID(3); id <= 4; id++ {
225- m.AddWindow(id)
226- }
227- indicatorScene(t, c)
228- before := c.snapshot(now)
229- for range 3 {
230- c.applyAction(ActionFocusColumnLeft)
231- }
232- c.transition(before, now)
233- if !m.Current().view.motion.on || m.settled() {
234- t.Fatal("no camera transition after the focus moved the view")
235- }
236- }, false},
237- } {
238- t.Run(tc.name, func(t *testing.T) {
239- c, ic := pulseCore(t)
240- m := c.cur().mon
241- m.Current().FocusID(1)
242- indicatorScene(t, c)
243- tc.slide(t, c, ic.now)
244- indicatorScene(t, c)
245- ic.now = ic.now.Add(pulseSettle)
246- if c.pulseTick() || c.pulse.timerC == nil {
247- t.Fatal("pulse decided during a slide")
248- }
249- m.stopSwitch()
250- m.Current().view.stop()
251- c.cur().stopRects()
252- if tc.alone {
253- m.Current().ToggleFullWidth()
254- }
255- indicatorScene(t, c)
256- ic.now = ic.now.Add(pulseRecheck)
257- if got := c.pulseTick(); got == tc.alone {
258- t.Fatalf("pulse started %v after landing, window alone %v", got, tc.alone)
259- }
260- })
261- }
262-}
263-
264-// A popup grab or layer taking the keyboard stops the pulse; the window
265-// getting it back is not a new focus and does not pulse again.
266-func TestFocusPulseIgnoresKeyboardDetours(t *testing.T) {
267- c, ic := pulseCore(t)
268- c.cur().mon.Current().FocusID(1)
269- indicatorScene(t, c)
270- ic.now = ic.now.Add(pulseSettle)
271- c.pulseTick()
272- // The keyboard goes elsewhere (held false), then comes back.
273- c.pulseFocus(1, false)
274- if c.animating() {
275- t.Fatal("pulse kept while the keyboard is elsewhere")
276- }
277- ic.now = ic.now.Add(2 * pulseCooldown)
278- c.pulseFocus(1, true)
279- if c.pulse.timerC != nil {
280- t.Fatal("returning keyboard counts as a new focus")
281- }
282-}
283-
284-func TestFocusPulseOff(t *testing.T) {
285- c, ic := pulseCore(t)
286- c.cfg.Focus.Animation = ports.FocusAnimationOff
287- c.cur().mon.Current().FocusID(1)
288- indicatorScene(t, c)
289- if c.pulse.timerC != nil {
290- t.Fatal("timer armed with the pulse off")
291- }
292- ic.now = ic.now.Add(pulseSettle)
293- if c.pulseTick() || c.animating() {
294- t.Fatal("pulse with focus.animation off")
295- }
296- // Turning it back on does not pulse the window already focused.
297- c.cfg.Focus.Animation = ports.FocusAnimationPulse
298- indicatorScene(t, c)
299- if c.pulse.timerC != nil {
300- t.Fatal("enabling the pulse pulses the focused window")
301- }
302-}
303-
304-func TestFocusPulseOffWithAnimationsOff(t *testing.T) {
305- c, ic := pulseCore(t)
306- c.cfg.Animations.On = false
307- if c.pulseOn() {
308- t.Fatal("pulse on with animations off")
309- }
310- c.cur().mon.Current().FocusID(1)
311- indicatorScene(t, c)
312- if c.pulse.timerC != nil || c.pulse.id != 0 {
313- t.Fatal("pulse armed with animations off")
314- }
315- ic.now = ic.now.Add(pulseSettle)
316- if c.pulseTick() || c.animating() {
317- t.Fatal("pulse with animations off")
318- }
319-}
@@ -1,319 +0,0 @@
1-package core
2-
3-import (
4- "testing"
5- "time"
6-
7- "github.com/bnema/neferwl/internal/ports"
8-)
9-
10-// pulseCore is indicatorCore with the focus pulse on and two tiled windows,
11-// 2 focused, its pulse settled and over.
12-func pulseCore(t *testing.T) (*Core, *indicatorClock) {
13- t.Helper()
14- c, ic := indicatorCore(t)
15- c.cfg.Animations.On = true
16- c.cfg.Focus.Animation = ports.FocusAnimationPulse
17- c.cfg.Focus.Strength = 0.05
18- c.cur().mon.AddWindow(1)
19- c.cur().mon.AddWindow(2)
20- indicatorScene(t, c)
21- ic.now = ic.now.Add(pulseSettle)
22- c.pulseTick()
23- ic.now = ic.now.Add(pulseCooldown)
24- indicatorScene(t, c)
25- return c, ic
26-}
27-
28-func pulseOf(s ports.Scene, id WindowID) float64 {
29- for _, w := range s.Windows {
30- if w.ID == id {
31- return w.FocusEffect
32- }
33- }
34- return -1
35-}
36-
37-func TestFocusPulseAfterSettle(t *testing.T) {
38- c, ic := pulseCore(t)
39- c.cur().mon.Current().FocusID(1)
40- if s := indicatorScene(t, c); pulseOf(s, 1) != 0 {
41- t.Fatalf("pulse before settle: %v", pulseOf(s, 1))
42- }
43- ic.now = ic.now.Add(pulseSettle)
44- if !c.pulseTick() {
45- t.Fatal("no pulse after settle")
46- }
47- if !c.animating() {
48- t.Fatal("a running pulse asks for no frames")
49- }
50- ic.now = ic.now.Add(pulseRise)
51- s := indicatorScene(t, c)
52- if p := pulseOf(s, 1); p < 0.05*0.99 || p > 0.05 {
53- t.Fatalf("peak %v, want the configured 0.05", p)
54- }
55- if pulseOf(s, 2) != 0 {
56- t.Fatal("unfocused window pulses")
57- }
58- ic.now = ic.now.Add(pulseFall)
59- if s := indicatorScene(t, c); pulseOf(s, 1) != 0 || c.animating() {
60- t.Fatalf("pulse left after its end: %v", pulseOf(s, 1))
61- }
62-}
63-
64-// Switching faster than pulseSettle never pulses: only the last focus counts.
65-func TestFocusPulseDebounced(t *testing.T) {
66- c, ic := pulseCore(t)
67- for range 4 {
68- c.cur().mon.Current().FocusID(1)
69- indicatorScene(t, c)
70- ic.now = ic.now.Add(pulseSettle / 3)
71- c.cur().mon.Current().FocusID(2)
72- indicatorScene(t, c)
73- ic.now = ic.now.Add(pulseSettle / 3)
74- }
75- // A stale timer of an earlier focus fires: the focus is too recent.
76- if c.pulseTick() {
77- t.Fatal("pulse while switching")
78- }
79- if c.pulse.timerC == nil {
80- t.Fatal("no timer for the last focus")
81- }
82- ic.now = ic.now.Add(pulseSettle)
83- // Window 2 pulsed in pulseCore over a second ago: it may pulse again.
84- if !c.pulseTick() || c.pulse.id != 2 {
85- t.Fatalf("last focus did not pulse: %d", c.pulse.id)
86- }
87-}
88-
89-// A focus change stops the running pulse; coming back within the cooldown
90-// does not pulse the same window again.
91-func TestFocusPulseStopsAndCoolsDown(t *testing.T) {
92- c, ic := pulseCore(t)
93- c.cur().mon.Current().FocusID(1)
94- indicatorScene(t, c)
95- ic.now = ic.now.Add(pulseSettle)
96- c.pulseTick()
97- ic.now = ic.now.Add(pulseRise / 2)
98- c.cur().mon.Current().FocusID(2)
99- if s := indicatorScene(t, c); pulseOf(s, 1) != 0 || pulseOf(s, 2) != 0 {
100- t.Fatal("pulse survived a focus change")
101- }
102- c.cur().mon.Current().FocusID(1)
103- indicatorScene(t, c)
104- ic.now = ic.now.Add(pulseSettle)
105- if c.pulseTick() {
106- t.Fatal("same window pulsed within the cooldown")
107- }
108-}
109-
110-// A window focused as it maps does not pulse; focusing it again later does.
111-func TestFocusPulseSkipsNewWindow(t *testing.T) {
112- c, ic := pulseCore(t)
113- c.windows.mapped(ports.WindowMapped{ID: 3}, ic.now)
114- c.cur().mon.AddWindow(3)
115- indicatorScene(t, c)
116- if c.pulse.timerC != nil {
117- t.Fatal("opening a window arms the pulse")
118- }
119- ic.now = ic.now.Add(pulseSettle)
120- if c.pulseTick() || c.animating() {
121- t.Fatal("a new window pulses")
122- }
123- c.cur().mon.Current().FocusID(1)
124- indicatorScene(t, c)
125- c.cur().mon.Current().FocusID(3)
126- indicatorScene(t, c)
127- ic.now = ic.now.Add(pulseSettle)
128- if !c.pulseTick() || c.pulse.id != 3 {
129- t.Fatal("an existing window did not pulse")
130- }
131-}
132-
133-// A window that mapped earlier without the focus pulses when focused.
134-func TestFocusPulseWindowMappedEarlier(t *testing.T) {
135- c, ic := pulseCore(t)
136- c.windows.mapped(ports.WindowMapped{ID: 1}, ic.now.Add(-time.Minute))
137- c.cur().mon.Current().FocusID(1)
138- indicatorScene(t, c)
139- ic.now = ic.now.Add(pulseSettle)
140- if !c.pulseTick() || c.pulse.id != 1 {
141- t.Fatal("a window mapped earlier did not pulse")
142- }
143-}
144-
145-func TestFocusPulseSkipsFullscreen(t *testing.T) {
146- c, ic := pulseCore(t)
147- c.cur().mon.Current().FocusID(1)
148- c.cur().mon.ToggleFullscreen()
149- indicatorScene(t, c)
150- ic.now = ic.now.Add(pulseSettle)
151- if c.pulseTick() || c.animating() {
152- t.Fatal("fullscreen window asks for pulse frames")
153- }
154- // Fullscreen entered mid-pulse ends it.
155- c.cur().mon.ToggleFullscreen()
156- c.cur().mon.Current().FocusID(2)
157- indicatorScene(t, c)
158- ic.now = ic.now.Add(pulseSettle)
159- if !c.pulseTick() {
160- t.Fatal("no pulse on the tiled window")
161- }
162- c.cur().mon.ToggleFullscreen()
163- if s := indicatorScene(t, c); pulseOf(s, 2) != 0 || c.animating() {
164- t.Fatal("pulse survived fullscreen")
165- }
166-}
167-
168-// A window alone on screen (a maximized column here) needs no pulse; once
169-// another window shows beside it, focusing it pulses again.
170-func TestFocusPulseSkipsAloneOnScreen(t *testing.T) {
171- c, ic := pulseCore(t)
172- c.cur().mon.Current().FocusID(1)
173- c.cur().mon.Current().ToggleFullWidth()
174- indicatorScene(t, c)
175- ic.now = ic.now.Add(pulseSettle)
176- if c.pulseTick() || c.animating() {
177- t.Fatal("a window alone on screen pulses")
178- }
179- c.cur().mon.Current().ToggleFullWidth()
180- c.cur().mon.Current().FocusID(2)
181- indicatorScene(t, c)
182- ic.now = ic.now.Add(pulseSettle)
183- if !c.pulseTick() || c.pulse.id != 2 {
184- t.Fatal("a window beside another did not pulse")
185- }
186-}
187-
188-// A cascade window alone in its band above a bottom panel is alone on
189-// screen: the next band, under the panel, does not count.
190-func TestFocusPulseSkipsAloneAbovePanel(t *testing.T) {
191- c, ic := pulseCore(t)
192- m := c.cur().mon
193- m.SetOverflow(OverflowCascade)
194- m.SetMaxColumns(1)
195- m.SetUsable(Rect{W: 300, H: 180})
196- m.Current().FocusID(1)
197- indicatorScene(t, c)
198- ic.now = ic.now.Add(pulseSettle)
199- if c.pulseTick() || c.animating() {
200- t.Fatal("a window alone in its band above a panel pulses")
201- }
202-}
203-
204-// A swipe, its landing slide or a transition still shows the neighbours: the
205-// pulse waits for the layout to settle, then decides on it.
206-func TestFocusPulseWaitsForSlide(t *testing.T) {
207- for _, tc := range []struct {
208- name string
209- slide func(*testing.T, *Core, time.Time)
210- alone bool
211- }{
212- {"workspace landing, alone", func(_ *testing.T, c *Core, now time.Time) {
213- c.cur().mon.switchView.motion = newMotion(workspaceSpring(0.5, 0), now, 1)
214- }, true},
215- {"workspace drag, alone", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.switchView.off = 0.3 }, true},
216- {"column drag, beside another", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.Current().view.off = 40 }, false},
217- {"rect motion, beside another", func(_ *testing.T, c *Core, now time.Time) {
218- sc := c.cur()
219- sc.rects = map[WindowID]rectMotion{1: {x: c.spring(viewSpring(40, 0), now), dx: 40}}
220- sc.rectsWS = sc.mon.Current()
221- }, false},
222- {"camera transition of a key, beside another", func(t *testing.T, c *Core, now time.Time) {
223- m := c.cur().mon
224- for id := WindowID(3); id <= 4; id++ {
225- m.AddWindow(id)
226- }
227- indicatorScene(t, c)
228- before := c.snapshot(now)
229- for range 3 {
230- c.applyAction(ActionFocusColumnLeft)
231- }
232- c.transition(before, now)
233- if !m.Current().view.motion.on || m.settled() {
234- t.Fatal("no camera transition after the focus moved the view")
235- }
236- }, false},
237- } {
238- t.Run(tc.name, func(t *testing.T) {
239- c, ic := pulseCore(t)
240- m := c.cur().mon
241- m.Current().FocusID(1)
242- indicatorScene(t, c)
243- tc.slide(t, c, ic.now)
244- indicatorScene(t, c)
245- ic.now = ic.now.Add(pulseSettle)
246- if c.pulseTick() || c.pulse.timerC == nil {
247- t.Fatal("pulse decided during a slide")
248- }
249- m.stopSwitch()
250- m.Current().view.stop()
251- c.cur().stopRects()
252- if tc.alone {
253- m.Current().ToggleFullWidth()
254- }
255- indicatorScene(t, c)
256- ic.now = ic.now.Add(pulseRecheck)
257- if got := c.pulseTick(); got == tc.alone {
258- t.Fatalf("pulse started %v after landing, window alone %v", got, tc.alone)
259- }
260- })
261- }
262-}
263-
264-// A popup grab or layer taking the keyboard stops the pulse; the window
265-// getting it back is not a new focus and does not pulse again.
266-func TestFocusPulseIgnoresKeyboardDetours(t *testing.T) {
267- c, ic := pulseCore(t)
268- c.cur().mon.Current().FocusID(1)
269- indicatorScene(t, c)
270- ic.now = ic.now.Add(pulseSettle)
271- c.pulseTick()
272- // The keyboard goes elsewhere (held false), then comes back.
273- c.pulseFocus(1, false)
274- if c.animating() {
275- t.Fatal("pulse kept while the keyboard is elsewhere")
276- }
277- ic.now = ic.now.Add(2 * pulseCooldown)
278- c.pulseFocus(1, true)
279- if c.pulse.timerC != nil {
280- t.Fatal("returning keyboard counts as a new focus")
281- }
282-}
283-
284-func TestFocusPulseOff(t *testing.T) {
285- c, ic := pulseCore(t)
286- c.cfg.Focus.Animation = ports.FocusAnimationOff
287- c.cur().mon.Current().FocusID(1)
288- indicatorScene(t, c)
289- if c.pulse.timerC != nil {
290- t.Fatal("timer armed with the pulse off")
291- }
292- ic.now = ic.now.Add(pulseSettle)
293- if c.pulseTick() || c.animating() {
294- t.Fatal("pulse with focus.animation off")
295- }
296- // Turning it back on does not pulse the window already focused.
297- c.cfg.Focus.Animation = ports.FocusAnimationPulse
298- indicatorScene(t, c)
299- if c.pulse.timerC != nil {
300- t.Fatal("enabling the pulse pulses the focused window")
301- }
302-}
303-
304-func TestFocusPulseOffWithAnimationsOff(t *testing.T) {
305- c, ic := pulseCore(t)
306- c.cfg.Animations.On = false
307- if c.pulseOn() {
308- t.Fatal("pulse on with animations off")
309- }
310- c.cur().mon.Current().FocusID(1)
311- indicatorScene(t, c)
312- if c.pulse.timerC != nil || c.pulse.id != 0 {
313- t.Fatal("pulse armed with animations off")
314- }
315- ic.now = ic.now.Add(pulseSettle)
316- if c.pulseTick() || c.animating() {
317- t.Fatal("pulse with animations off")
318- }
319-}
M internal/core/scene.go +4 -12
@@ -8,8 +8,7 @@ import (
88 )
99
1010 // sceneFor builds the scene of screen i and sends the configures it sizes.
11-// drawable reports that it can show the focus pulse on the focused window.
12-func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, capture *capView, pulse float64) (scene ports.Scene, drawable bool, err error) {
11+func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, capture *capView) (scene ports.Scene, err error) {
1312 o := sc.mon.Output()
1413 // view.frame is the viewport of the workspace on screen: the whole
1514 // output unless it has a size override (never in the overview).
@@ -68,8 +67,6 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
6867 // Only the focused output lights the focused window's lines.
6968 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), view, i == c.focusScreen)
7069 }
71- // A window alone on screen needs no pulse to show it has the focus.
72- alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, view) == 1
7370 for k, p := range layout {
7471 ps := settled[k]
7572 // Only the focused output has an activated window.
@@ -97,12 +94,6 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
9794 }
9895 continue
9996 }
100- if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.previewing() && hasRoom(view, p) {
101- drawable = true
102- if p.ID == c.pulse.id {
103- sw.FocusEffect = pulse
104- }
105- }
10697 scene.Windows = append(scene.Windows, sw)
10798 t := configureTarget{output: sc.name(), view: view, focused: focused, captured: capture != nil && capture.window == p.ID}
10899 if !p.Hidden && p.Preview == 0 {
@@ -120,7 +111,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
120111 cp, t := c.captureConfigure(sc, ps, t)
121112 if v, send := c.configures.nextWithCapture(ps, t, cp); send {
122113 if err := c.command(ctx, v); err != nil {
123- return ports.Scene{}, false, err
114+ return ports.Scene{}, err
124115 }
125116 c.configures.mark(v)
126117 }
@@ -130,6 +121,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
130121 // The drag icon is over everything, popups and layers included.
131122 scene.Windows = append(scene.Windows, icon)
132123 }
124+ scene.Flash = c.flashFor(sc)
133125 scene.CaptureIndicators = c.captureIndicators(sc)
134126 scene.Capture = c.captureSceneFor(sc, capture)
135127 // The springs of this frame are advanced: a scene shown while one still
@@ -160,5 +152,5 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
160152 scene.CaptureScene = c.captureWindowScene(capture, scene.Seq)
161153 }
162154 }
163- return scene, drawable, nil
155+ return scene, nil
164156 }
@@ -8,8 +8,7 @@ import (
8 )8 )
9 9
10 // sceneFor builds the scene of screen i and sends the configures it sizes.10 // sceneFor builds the scene of screen i and sends the configures it sizes.
11-// drawable reports that it can show the focus pulse on the focused window.11+func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, capture *capView) (scene ports.Scene, err error) {
12-func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, capture *capView, pulse float64) (scene ports.Scene, drawable bool, err error) {
13 o := sc.mon.Output()12 o := sc.mon.Output()
14 // view.frame is the viewport of the workspace on screen: the whole13 // view.frame is the viewport of the workspace on screen: the whole
15 // output unless it has a size override (never in the overview).14 // output unless it has a size override (never in the overview).
@@ -68,8 +67,6 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
68 // Only the focused output lights the focused window's lines.67 // Only the focused output lights the focused window's lines.
69 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), view, i == c.focusScreen)68 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), view, i == c.focusScreen)
70 }69 }
71- // A window alone on screen needs no pulse to show it has the focus.
72- alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, view) == 1
73 for k, p := range layout {70 for k, p := range layout {
74 ps := settled[k]71 ps := settled[k]
75 // Only the focused output has an activated window.72 // Only the focused output has an activated window.
@@ -97,12 +94,6 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
97 }94 }
98 continue95 continue
99 }96 }
100- if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.previewing() && hasRoom(view, p) {
101- drawable = true
102- if p.ID == c.pulse.id {
103- sw.FocusEffect = pulse
104- }
105- }
106 scene.Windows = append(scene.Windows, sw)97 scene.Windows = append(scene.Windows, sw)
107 t := configureTarget{output: sc.name(), view: view, focused: focused, captured: capture != nil && capture.window == p.ID}98 t := configureTarget{output: sc.name(), view: view, focused: focused, captured: capture != nil && capture.window == p.ID}
108 if !p.Hidden && p.Preview == 0 {99 if !p.Hidden && p.Preview == 0 {
@@ -120,7 +111,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
120 cp, t := c.captureConfigure(sc, ps, t)111 cp, t := c.captureConfigure(sc, ps, t)
121 if v, send := c.configures.nextWithCapture(ps, t, cp); send {112 if v, send := c.configures.nextWithCapture(ps, t, cp); send {
122 if err := c.command(ctx, v); err != nil {113 if err := c.command(ctx, v); err != nil {
123- return ports.Scene{}, false, err114+ return ports.Scene{}, err
124 }115 }
125 c.configures.mark(v)116 c.configures.mark(v)
126 }117 }
@@ -130,6 +121,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
130 // The drag icon is over everything, popups and layers included.121 // The drag icon is over everything, popups and layers included.
131 scene.Windows = append(scene.Windows, icon)122 scene.Windows = append(scene.Windows, icon)
132 }123 }
124+ scene.Flash = c.flashFor(sc)
133 scene.CaptureIndicators = c.captureIndicators(sc)125 scene.CaptureIndicators = c.captureIndicators(sc)
134 scene.Capture = c.captureSceneFor(sc, capture)126 scene.Capture = c.captureSceneFor(sc, capture)
135 // 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
@@ -160,5 +152,5 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
160 scene.CaptureScene = c.captureWindowScene(capture, scene.Seq)152 scene.CaptureScene = c.captureWindowScene(capture, scene.Seq)
161 }153 }
162 }154 }
163- return scene, drawable, nil155+ return scene, nil
164 }156 }
M internal/core/scene_animating_test.go +9 -25
@@ -55,33 +55,17 @@ func TestSceneAnimatingFollowsSprings(t *testing.T) {
5555 }
5656 }
5757
58-// A pulse cancelled because nothing can show it (fullscreen) publishes the
59-// focused scene settled, with a fresh Seq: otherwise outputs keep the
60-// animating scene and wake core on every flip.
61-func TestCancelledPulseClearsAnimating(t *testing.T) {
62- c, ic := pulseCore(t)
63- c.cur().mon.Current().FocusID(1)
64- indicatorScene(t, c)
65- ic.now = ic.now.Add(pulseSettle)
66- if !c.pulseTick() {
67- t.Fatal("bad setup: no pulse")
68- }
69- running := indicatorScene(t, c)
70- if !running.Animating {
71- t.Fatal("bad setup: the pulsing scene is not animating")
72- }
73- c.cur().mon.ToggleFullscreen()
74- cancelled := indicatorScene(t, c)
75- if cancelled.Animating || c.animating() {
76- t.Fatalf("cancelled pulse: scene animating=%v core animating=%v", cancelled.Animating, c.animating())
77- }
78- if cancelled.Seq == running.Seq {
79- t.Fatalf("cancelled pulse kept the animating scene's Seq %d", running.Seq)
58+// stepScenes steps only the screen and returns the scene of each output.
59+func stepScenes(t *testing.T, c *Core, only *screen) map[string]ports.Scene {
60+ t.Helper()
61+ if err := c.step(context.Background(), only); err != nil {
62+ t.Fatal(err)
8063 }
81- idle := indicatorScene(t, c)
82- if idle.Animating || idle.Seq != cancelled.Seq {
83- t.Fatalf("idle publish after cancel: animating=%v Seq %d (was %d)", idle.Animating, idle.Seq, cancelled.Seq)
64+ out := map[string]ports.Scene{}
65+ for _, s := range <-c.ch.Scenes {
66+ out[s.Output] = s
8467 }
68+ return out
8569 }
8670
8771 // Animating is per output: a spring on one does not mark the other.
@@ -55,33 +55,17 @@ func TestSceneAnimatingFollowsSprings(t *testing.T) {
55 }55 }
56 }56 }
57 57
58-// A pulse cancelled because nothing can show it (fullscreen) publishes the58+// stepScenes steps only the screen and returns the scene of each output.
59-// focused scene settled, with a fresh Seq: otherwise outputs keep the59+func stepScenes(t *testing.T, c *Core, only *screen) map[string]ports.Scene {
60-// animating scene and wake core on every flip.60+ t.Helper()
61-func TestCancelledPulseClearsAnimating(t *testing.T) {61+ if err := c.step(context.Background(), only); err != nil {
62- c, ic := pulseCore(t)62+ t.Fatal(err)
63- c.cur().mon.Current().FocusID(1)
64- indicatorScene(t, c)
65- ic.now = ic.now.Add(pulseSettle)
66- if !c.pulseTick() {
67- t.Fatal("bad setup: no pulse")
68- }
69- running := indicatorScene(t, c)
70- if !running.Animating {
71- t.Fatal("bad setup: the pulsing scene is not animating")
72- }
73- c.cur().mon.ToggleFullscreen()
74- cancelled := indicatorScene(t, c)
75- if cancelled.Animating || c.animating() {
76- t.Fatalf("cancelled pulse: scene animating=%v core animating=%v", cancelled.Animating, c.animating())
77- }
78- if cancelled.Seq == running.Seq {
79- t.Fatalf("cancelled pulse kept the animating scene's Seq %d", running.Seq)
80 }63 }
81- idle := indicatorScene(t, c)64+ out := map[string]ports.Scene{}
82- if idle.Animating || idle.Seq != cancelled.Seq {65+ for _, s := range <-c.ch.Scenes {
83- t.Fatalf("idle publish after cancel: animating=%v Seq %d (was %d)", idle.Animating, idle.Seq, cancelled.Seq)66+ out[s.Output] = s
84 }67 }
68+ return out
85 }69 }
86 70
87 // Animating is per output: a spring on one does not mark the other.71 // Animating is per output: a spring on one does not mark the other.
M internal/core/screens.go +0 -4
@@ -56,10 +56,6 @@ type screen struct {
5656 // springing reports whether a camera or rect spring runs on the screen.
5757 func (s *screen) springing() bool { return s.mon.springing() || len(s.rects) > 0 }
5858
59-// settled reports whether nothing moves the screen's layout: no swipe,
60-// no slide, no rect motion.
61-func (s *screen) settled() bool { return s.mon.settled() && len(s.rects) == 0 }
62-
6359 // stopRects drops the rect motions: the windows are drawn where they are.
6460 func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil }
6561
@@ -56,10 +56,6 @@ type screen struct {
56 // springing reports whether a camera or rect spring runs on the screen.56 // springing reports whether a camera or rect spring runs on the screen.
57 func (s *screen) springing() bool { return s.mon.springing() || len(s.rects) > 0 }57 func (s *screen) springing() bool { return s.mon.springing() || len(s.rects) > 0 }
58 58
59-// settled reports whether nothing moves the screen's layout: no swipe,
60-// no slide, no rect motion.
61-func (s *screen) settled() bool { return s.mon.settled() && len(s.rects) == 0 }
62-
63 // stopRects drops the rect motions: the windows are drawn where they are.59 // stopRects drops the rect motions: the windows are drawn where they are.
64 func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil }60 func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil }
65 61
M internal/core/security.go +1 -1
@@ -47,7 +47,7 @@ func (c *Core) syncSecurity() bool {
4747 // protected epoch clears that focus.
4848 c.gesture = clientGesture{}
4949 c.stopAnimations()
50- c.stopPulse()
50+ c.stopFlash()
5151 c.cancelSwitcher()
5252 c.stopRepeat()
5353 if state.Protected {
@@ -47,7 +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.stopPulse()50+ c.stopFlash()
51 c.cancelSwitcher()51 c.cancelSwitcher()
52 c.stopRepeat()52 c.stopRepeat()
53 if state.Protected {53 if state.Protected {
M internal/core/transition.go +1 -2
@@ -701,8 +701,7 @@ func (c *Core) snapshot(now time.Time) []viewShot {
701701 // while it shows on another) is animated on both screens: the destination's
702702 // windows slide in from where the source drew them (no workspace slide
703703 // there), the source slides to its next workspace. The focus-monitor-*
704-// actions move nothing: no window changes place, so they are not animated
705-// (the focus pulse marks the new focus).
704+// actions move nothing: no window changes place, so they are not animated.
706705 func (c *Core) transition(before []viewShot, now time.Time) {
707706 if !c.animOn() {
708707 return
@@ -701,8 +701,7 @@ func (c *Core) snapshot(now time.Time) []viewShot {
701 // while it shows on another) is animated on both screens: the destination's701 // while it shows on another) is animated on both screens: the destination's
702 // windows slide in from where the source drew them (no workspace slide702 // windows slide in from where the source drew them (no workspace slide
703 // there), the source slides to its next workspace. The focus-monitor-*703 // there), the source slides to its next workspace. The focus-monitor-*
704-// actions move nothing: no window changes place, so they are not animated704+// actions move nothing: no window changes place, so they are not animated.
705-// (the focus pulse marks the new focus).
706 func (c *Core) transition(before []viewShot, now time.Time) {705 func (c *Core) transition(before []viewShot, now time.Time) {
707 if !c.animOn() {706 if !c.animOn() {
708 return707 return
M internal/core/viewport_test.go +4 -6
@@ -184,20 +184,18 @@ func TestTileStopsAtPanel(t *testing.T) {
184184 }
185185 }
186186
187-// Panels that leave no room draw no tile, a leaving one included, and the
188-// focus pulse has nothing to show.
187+// Panels that leave no room draw no tile, a leaving one included.
189188 func TestNoUsableAreaDrawsNoTile(t *testing.T) {
190189 c, check := viewportCore(t, func(cfg *ports.Config) { cfg.Layout.MaxColumns = 1 }, Rect{})
191190 c.cur().mon.AddWindow(1)
192- c.pulse.target = 1
193191 scene := check(map[WindowID]bool{1: false})
194- if scene.Draws(scene.Windows[0]) || c.pulse.drawable {
195- t.Fatalf("tile drawn %v, pulse drawable %v with no usable area", scene.Draws(scene.Windows[0]), c.pulse.drawable)
192+ if scene.Draws(scene.Windows[0]) {
193+ t.Fatalf("tile drawn with no usable area")
196194 }
197195 sc := c.cur()
198196 sc.shown = slices.Clone(sc.shownLayout())
199197 sc.shown[0].Leaving = true
200- scene, _, err := c.sceneFor(context.Background(), 0, sc, nil, nil, 0)
198+ scene, err := c.sceneFor(context.Background(), 0, sc, nil, nil)
201199 if err != nil {
202200 t.Fatal(err)
203201 }
@@ -184,20 +184,18 @@ func TestTileStopsAtPanel(t *testing.T) {
184 }184 }
185 }185 }
186 186
187-// Panels that leave no room draw no tile, a leaving one included, and the187+// Panels that leave no room draw no tile, a leaving one included.
188-// focus pulse has nothing to show.
189 func TestNoUsableAreaDrawsNoTile(t *testing.T) {188 func TestNoUsableAreaDrawsNoTile(t *testing.T) {
190 c, check := viewportCore(t, func(cfg *ports.Config) { cfg.Layout.MaxColumns = 1 }, Rect{})189 c, check := viewportCore(t, func(cfg *ports.Config) { cfg.Layout.MaxColumns = 1 }, Rect{})
191 c.cur().mon.AddWindow(1)190 c.cur().mon.AddWindow(1)
192- c.pulse.target = 1
193 scene := check(map[WindowID]bool{1: false})191 scene := check(map[WindowID]bool{1: false})
194- if scene.Draws(scene.Windows[0]) || c.pulse.drawable {192+ if scene.Draws(scene.Windows[0]) {
195- t.Fatalf("tile drawn %v, pulse drawable %v with no usable area", scene.Draws(scene.Windows[0]), c.pulse.drawable)193+ t.Fatalf("tile drawn with no usable area")
196 }194 }
197 sc := c.cur()195 sc := c.cur()
198 sc.shown = slices.Clone(sc.shownLayout())196 sc.shown = slices.Clone(sc.shownLayout())
199 sc.shown[0].Leaving = true197 sc.shown[0].Leaving = true
200- scene, _, err := c.sceneFor(context.Background(), 0, sc, nil, nil, 0)198+ scene, err := c.sceneFor(context.Background(), 0, sc, nil, nil)
201 if err != nil {199 if err != nil {
202 t.Fatal(err)200 t.Fatal(err)
203 }201 }
M internal/ports/config.go +11 -14
@@ -89,14 +89,6 @@ type OutputLayout struct {
8989 LayoutRules
9090 }
9191
92-// Focus indicator: the animation (Config.Focus.Animation) shapes in time
93-// the visual effect (Config.Focus.Effect) drawn on a newly focused window.
94-const (
95- FocusAnimationOff = "off"
96- FocusAnimationPulse = "pulse"
97- FocusEffectScreen = "screen"
98-)
99-
10092 // Config is the parsed compositor configuration (see the config adapter for keys).
10193 type Config struct {
10294 Keyboard struct {
@@ -148,7 +140,7 @@ type Config struct {
148140 // motion; the next motion shows it. 0 never hides it.
149141 Cursor struct{ HideAfter time.Duration }
150142 // Animations: On enables motion (off lands everything instantly);
151- // Slowdown scales every spring's duration (not the focus pulse), 0.1 to
143+ // Slowdown scales every spring's duration, 0.1 to
152144 // 10 (the config's animations.speed maps to it).
153145 Animations struct {
154146 On bool
@@ -158,11 +150,16 @@ type Config struct {
158150 // FollowMove shows the target workspace after a column or window
159151 // moves to it.
160152 FollowMove bool
161- // Animation and Effect mark a window that just got the focus:
162- // FocusAnimation* (off disables it) and FocusEffect*. Strength is
163- // the effect's peak, 0.01 to 0.2.
164- Animation, Effect string
165- Strength float64
153+ // Flash briefly draws an inner border on the entity an action changed
154+ // (window, column, output). FlashWidth is in logical pixels (1 to 8),
155+ // FlashOpacity the starting opacity (0.1 to 1), FlashDuration the fade
156+ // (50ms to 1s, not scaled by Animations.Slowdown), FlashColor #rrggbb or
157+ // empty for Border.Active.
158+ Flash bool
159+ FlashWidth int
160+ FlashOpacity float64
161+ FlashDuration time.Duration
162+ FlashColor string
166163 }
167164 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
168165 Workspaces []WorkspaceConfig
@@ -89,14 +89,6 @@ type OutputLayout struct {
89 LayoutRules89 LayoutRules
90 }90 }
91 91
92-// Focus indicator: the animation (Config.Focus.Animation) shapes in time
93-// the visual effect (Config.Focus.Effect) drawn on a newly focused window.
94-const (
95- FocusAnimationOff = "off"
96- FocusAnimationPulse = "pulse"
97- FocusEffectScreen = "screen"
98-)
99-
100 // Config is the parsed compositor configuration (see the config adapter for keys).92 // Config is the parsed compositor configuration (see the config adapter for keys).
101 type Config struct {93 type Config struct {
102 Keyboard struct {94 Keyboard struct {
@@ -148,7 +140,7 @@ type Config struct {
148 // motion; the next motion shows it. 0 never hides it.140 // motion; the next motion shows it. 0 never hides it.
149 Cursor struct{ HideAfter time.Duration }141 Cursor struct{ HideAfter time.Duration }
150 // Animations: On enables motion (off lands everything instantly);142 // Animations: On enables motion (off lands everything instantly);
151- // Slowdown scales every spring's duration (not the focus pulse), 0.1 to143+ // Slowdown scales every spring's duration, 0.1 to
152 // 10 (the config's animations.speed maps to it).144 // 10 (the config's animations.speed maps to it).
153 Animations struct {145 Animations struct {
154 On bool146 On bool
@@ -158,11 +150,16 @@ type Config struct {
158 // FollowMove shows the target workspace after a column or window150 // FollowMove shows the target workspace after a column or window
159 // moves to it.151 // moves to it.
160 FollowMove bool152 FollowMove bool
161- // Animation and Effect mark a window that just got the focus:153+ // Flash briefly draws an inner border on the entity an action changed
162- // FocusAnimation* (off disables it) and FocusEffect*. Strength is154+ // (window, column, output). FlashWidth is in logical pixels (1 to 8),
163- // the effect's peak, 0.01 to 0.2.155+ // FlashOpacity the starting opacity (0.1 to 1), FlashDuration the fade
164- Animation, Effect string156+ // (50ms to 1s, not scaled by Animations.Slowdown), FlashColor #rrggbb or
165- Strength float64157+ // empty for Border.Active.
158+ Flash bool
159+ FlashWidth int
160+ FlashOpacity float64
161+ FlashDuration time.Duration
162+ FlashColor string
166 }163 }
167 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.164 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
168 Workspaces []WorkspaceConfig165 Workspaces []WorkspaceConfig
M internal/ports/scene.go +18 -11
@@ -69,7 +69,10 @@ type Scene struct {
6969 // drawn over everything; nil when none. Captures never hold them: the
7070 // capture pipeline serves requests from a scene without them.
7171 CaptureIndicators []CaptureIndicator
72- // Animating is set by core when a spring or the focus pulse runs on
72+ // Flash is the focus flash drawn over everything of this output; nil
73+ // draws nothing.
74+ Flash *FocusFlash
75+ // Animating is set by core when a spring or a focus flash runs on
7376 // this output after this frame was computed: the output reports the
7477 // flip of the scene to core (OutputFrame), which moves the animation to
7578 // the next frame. A scene of a settled output carries false, so a flip
@@ -91,6 +94,9 @@ func (s Scene) SameAs(o Scene) bool {
9194 s.WorkspaceClip != o.WorkspaceClip || s.TileInset != o.TileInset || s.Dim != o.Dim || s.DimBehind != o.DimBehind {
9295 return false
9396 }
97+ if (s.Flash == nil) != (o.Flash == nil) || (s.Flash != nil && *s.Flash != *o.Flash) {
98+ return false
99+ }
94100 if s.CaptureScene != nil || o.CaptureScene != nil {
95101 return false
96102 }
@@ -176,6 +182,17 @@ type SceneLayer struct {
176182 Rect Rect
177183 }
178184
185+// FocusFlash is a brief inner border marking the entity an action just
186+// changed: a window, a column or a whole output. Rect is in logical
187+// pixels, Width the border width inside it, Color #rrggbb and Alpha its
188+// opacity at this frame (0 to 1).
189+type FocusFlash struct {
190+ Rect Rect
191+ Width int
192+ Color string
193+ Alpha float64
194+}
195+
179196 // SceneWindow carries core → renderer window placement.
180197 type SceneWindow struct {
181198 ID WindowID
@@ -195,16 +212,6 @@ type SceneWindow struct {
195212 // or leaving animates it; a faded window is composed, never scanned
196213 // out.
197214 Fade float64
198- // FocusEffect, above 0, is the focus indicator's effect at this frame
199- // (focus.animation, focus.effect): the window's surfaces are lifted
200- // toward white by that fraction (screen blend). Renderers without the
201- // effect ignore it.
202- //
203- // Only focus.effect = screen exists, so the effect kind is not carried:
204- // core ignores Config.Focus.Effect and the shaders always apply screen
205- // (compose.frag, compose_hdr.frag, push constant mapy.z). A second effect
206- // needs its kind here and in the push constants (a misc flag).
207- FocusEffect float64
208215 // Preview, above 0, draws the window's surfaces that much smaller in
209216 // Rect (an overview thumbnail): the client keeps its size. A preview
210217 // is a card: it never opens the floats.
@@ -69,7 +69,10 @@ type Scene struct {
69 // drawn over everything; nil when none. Captures never hold them: the69 // drawn over everything; nil when none. Captures never hold them: the
70 // capture pipeline serves requests from a scene without them.70 // capture pipeline serves requests from a scene without them.
71 CaptureIndicators []CaptureIndicator71 CaptureIndicators []CaptureIndicator
72- // Animating is set by core when a spring or the focus pulse runs on72+ // Flash is the focus flash drawn over everything of this output; nil
73+ // draws nothing.
74+ Flash *FocusFlash
75+ // Animating is set by core when a spring or a focus flash runs on
73 // this output after this frame was computed: the output reports the76 // this output after this frame was computed: the output reports the
74 // flip of the scene to core (OutputFrame), which moves the animation to77 // flip of the scene to core (OutputFrame), which moves the animation to
75 // the next frame. A scene of a settled output carries false, so a flip78 // the next frame. A scene of a settled output carries false, so a flip
@@ -91,6 +94,9 @@ func (s Scene) SameAs(o Scene) bool {
91 s.WorkspaceClip != o.WorkspaceClip || s.TileInset != o.TileInset || s.Dim != o.Dim || s.DimBehind != o.DimBehind {94 s.WorkspaceClip != o.WorkspaceClip || s.TileInset != o.TileInset || s.Dim != o.Dim || s.DimBehind != o.DimBehind {
92 return false95 return false
93 }96 }
97+ if (s.Flash == nil) != (o.Flash == nil) || (s.Flash != nil && *s.Flash != *o.Flash) {
98+ return false
99+ }
94 if s.CaptureScene != nil || o.CaptureScene != nil {100 if s.CaptureScene != nil || o.CaptureScene != nil {
95 return false101 return false
96 }102 }
@@ -176,6 +182,17 @@ type SceneLayer struct {
176 Rect Rect182 Rect Rect
177 }183 }
178 184
185+// FocusFlash is a brief inner border marking the entity an action just
186+// changed: a window, a column or a whole output. Rect is in logical
187+// pixels, Width the border width inside it, Color #rrggbb and Alpha its
188+// opacity at this frame (0 to 1).
189+type FocusFlash struct {
190+ Rect Rect
191+ Width int
192+ Color string
193+ Alpha float64
194+}
195+
179 // SceneWindow carries core → renderer window placement.196 // SceneWindow carries core → renderer window placement.
180 type SceneWindow struct {197 type SceneWindow struct {
181 ID WindowID198 ID WindowID
@@ -195,16 +212,6 @@ type SceneWindow struct {
195 // or leaving animates it; a faded window is composed, never scanned212 // or leaving animates it; a faded window is composed, never scanned
196 // out.213 // out.
197 Fade float64214 Fade float64
198- // FocusEffect, above 0, is the focus indicator's effect at this frame
199- // (focus.animation, focus.effect): the window's surfaces are lifted
200- // toward white by that fraction (screen blend). Renderers without the
201- // effect ignore it.
202- //
203- // Only focus.effect = screen exists, so the effect kind is not carried:
204- // core ignores Config.Focus.Effect and the shaders always apply screen
205- // (compose.frag, compose_hdr.frag, push constant mapy.z). A second effect
206- // needs its kind here and in the push constants (a misc flag).
207- FocusEffect float64
208 // Preview, above 0, draws the window's surfaces that much smaller in215 // Preview, above 0, draws the window's surfaces that much smaller in
209 // Rect (an overview thumbnail): the client keeps its size. A preview216 // Rect (an overview thumbnail): the client keeps its size. A preview
210 // is a card: it never opens the floats.217 // is a card: it never opens the floats.
M internal/ports/scene_same_test.go +8 -2
@@ -23,6 +23,7 @@ func sameSceneBase() Scene {
2323 Layers: []SceneLayer{{ID: 9, Rect: Rect{W: 100, H: 5}}},
2424 Capture: &SceneCapture{Shown: 1, Excluded: []WindowID{3}},
2525
26+ Flash: &FocusFlash{Rect: Rect{W: 20, H: 10}, Width: 2, Color: "#ff0000", Alpha: 0.6},
2627 CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}},
2728 }
2829 }
@@ -36,6 +37,11 @@ func TestSceneSameAs(t *testing.T) {
3637 assert.True(t, Scene{}.SameAs(Scene{Seq: 3}))
3738
3839 for name, edit := range map[string]func(*Scene){
40+ "flash nil": func(s *Scene) { s.Flash = nil },
41+ "flash rect": func(s *Scene) { s.Flash.Rect.X++ },
42+ "flash width": func(s *Scene) { s.Flash.Width++ },
43+ "flash color": func(s *Scene) { s.Flash.Color = "#ffffff" },
44+ "flash alpha": func(s *Scene) { s.Flash.Alpha = 0.2 },
3945 "window rect": func(s *Scene) { s.Windows[0].Rect.X++ },
4046 "window fade": func(s *Scene) { s.Windows[0].Fade = 0.5 },
4147 "window zoom": func(s *Scene) { s.Windows[0].Zoom = 0.9 },
@@ -83,9 +89,9 @@ func TestSceneSameAs(t *testing.T) {
8389 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:
8490 // update Scene.SameAs, then this count.
8591 func TestSceneSameAsCoversEveryField(t *testing.T) {
86- // 22 with Animating, which SameAs intentionally does not compare: it
92+ // 23 with Animating, which SameAs intentionally does not compare: it
8793 // only schedules the next frame, it draws nothing.
88- const fields = 22
94+ const fields = 23
8995 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
9096 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
9197 }
@@ -23,6 +23,7 @@ func sameSceneBase() Scene {
23 Layers: []SceneLayer{{ID: 9, Rect: Rect{W: 100, H: 5}}},23 Layers: []SceneLayer{{ID: 9, Rect: Rect{W: 100, H: 5}}},
24 Capture: &SceneCapture{Shown: 1, Excluded: []WindowID{3}},24 Capture: &SceneCapture{Shown: 1, Excluded: []WindowID{3}},
25 25
26+ Flash: &FocusFlash{Rect: Rect{W: 20, H: 10}, Width: 2, Color: "#ff0000", Alpha: 0.6},
26 CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}},27 CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}},
27 }28 }
28 }29 }
@@ -36,6 +37,11 @@ func TestSceneSameAs(t *testing.T) {
36 assert.True(t, Scene{}.SameAs(Scene{Seq: 3}))37 assert.True(t, Scene{}.SameAs(Scene{Seq: 3}))
37 38
38 for name, edit := range map[string]func(*Scene){39 for name, edit := range map[string]func(*Scene){
40+ "flash nil": func(s *Scene) { s.Flash = nil },
41+ "flash rect": func(s *Scene) { s.Flash.Rect.X++ },
42+ "flash width": func(s *Scene) { s.Flash.Width++ },
43+ "flash color": func(s *Scene) { s.Flash.Color = "#ffffff" },
44+ "flash alpha": func(s *Scene) { s.Flash.Alpha = 0.2 },
39 "window rect": func(s *Scene) { s.Windows[0].Rect.X++ },45 "window rect": func(s *Scene) { s.Windows[0].Rect.X++ },
40 "window fade": func(s *Scene) { s.Windows[0].Fade = 0.5 },46 "window fade": func(s *Scene) { s.Windows[0].Fade = 0.5 },
41 "window zoom": func(s *Scene) { s.Windows[0].Zoom = 0.9 },47 "window zoom": func(s *Scene) { s.Windows[0].Zoom = 0.9 },
@@ -83,9 +89,9 @@ func TestSceneSameAs(t *testing.T) {
83 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:89 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:
84 // update Scene.SameAs, then this count.90 // update Scene.SameAs, then this count.
85 func TestSceneSameAsCoversEveryField(t *testing.T) {91 func TestSceneSameAsCoversEveryField(t *testing.T) {
86- // 22 with Animating, which SameAs intentionally does not compare: it92+ // 23 with Animating, which SameAs intentionally does not compare: it
87 // only schedules the next frame, it draws nothing.93 // only schedules the next frame, it draws nothing.
88- const fields = 2294+ const fields = 23
89 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),95 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
90 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")96 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
91 }97 }

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