detached 2117e03

#1 feat: replace focus pulse with inner-border focus flash

state
merged · 2117e03 · fast-forward
branch
feat/focus-flash → main
opened
bnema ·
updated

Replaces the focus pulse with a focus flash: a thin inner border that marks the entity an action changed, then fades quickly.

Levels

  • Window: focus/move window up/down, consume/expel, toggle floating, overview exit, tile drop, un-stash.
  • Column: focus/move column left/right, column swipe landing.
  • Output: focus-monitor-, move-workspace-to-monitor- (destination output).
  • No flash: workspace switch (including edge fallthrough), mouse focus, fullscreen/maximize/width, new windows, activation, unlock.

150 ms settle, waits for the layout to settle, 1 s cooldown per target, cancelled by overview/fullscreen/hidden target, off when animations are off or the session is locked.

Config

focus.flash, focus.flash-width (1-8), focus.flash-opacity (0.1-1), focus.flash-duration (50ms-1s), focus.flash-color (empty = border.active). focus.animation, focus.effect, focus.strength are removed.

Rendering

Vulkan draws 4 inner-edge fills; damage is limited to the flash rect; direct scanout and overlays are disabled while a flash is visible. The compose shaders no longer carry the pulse lift.

Checks

make check, make race, make mocks-check, staticcheck, make spv-check. Headless e2e on 2 outputs: 10/10 cases pass (flash visible then gone; no flash on workspace switch, edge switch, fullscreen; overview cancels a running flash).

CommitSubjectAuthorDateSignatureStatus
2117e03perf(vulkan): limit focus flash damage to its rectbriceGPG Verified
97aa477fix(core): skip focus flash on edge workspace switchesbriceGPG Verified
ad49c58fix(core): cancel stale focus flashes and track cooldown per targetbriceGPG Verified
3b72e8efeat(vulkan): draw focus flash as inner borderbriceGPG Verified
4e6e449feat(core): add focus flash on window, column and output changesbriceGPG Verified
ade9e82refactor(core): remove focus pulse and add focus flash contractsbriceGPG Verified

Checks 2

49 files · +1246 -834
2/2
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
M internal/app/scenelog_test.go +2 -2
M internal/core/action.go +74 -0
M internal/core/core.go +11 -47
M internal/core/core_test.go +0 -2
M internal/core/drag.go +1 -0
A internal/core/flash.go +226 -0
A internal/core/flash_test.go +600 -0
D internal/core/frame_pulse_test.go +0 -69
M internal/core/gesture.go +17 -5
M internal/core/input.go +6 -0
M internal/core/overview.go +1 -0
M internal/core/persist_test.go +1 -1
D internal/core/pulse.go +0 -183
D internal/core/pulse_test.go +0 -319
M internal/core/scene.go +4 -12
M internal/core/scene_animating_test.go +9 -25
M internal/core/screens.go +0 -4
M internal/core/security.go +1 -1
M internal/core/transition.go +1 -2
M internal/core/viewport_test.go +4 -6
M internal/ports/config.go +11 -14
M internal/ports/scene.go +18 -11
M internal/ports/scene_same_test.go +8 -2

Comments0 open / 0

no comments yet.

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
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close