detached 2117e03

perf(vulkan): limit focus flash damage to its rect

commit
2117e03a08536cead35d1280676b7a8517276e54
author
brice · · 2026-10-19 05:11 UTC
parent
97aa477
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
2/2
4 files · +66 -16
M internal/adapters/vulkan/damage.go +8 -1
@@ -276,7 +276,6 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim
276276 old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil ||
277277 !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) ||
278278 !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) ||
279- (old.Flash == nil) != (cur.Flash == nil) || (old.Flash != nil && *old.Flash != *cur.Flash) ||
280279 len(old.Windows) != len(cur.Windows) {
281280 return image.Rectangle{}, false
282281 }
@@ -287,6 +286,14 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim
287286 return image.Rectangle{}, false
288287 }
289288 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+ }
290297 for i := range old.Windows {
291298 a, b := old.Windows[i], cur.Windows[i]
292299 if a.ID != b.ID {
@@ -276,7 +276,6 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim
276 old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil ||276 old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil ||
277 !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) ||277 !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) ||
278 !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) ||278 !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) ||
279- (old.Flash == nil) != (cur.Flash == nil) || (old.Flash != nil && *old.Flash != *cur.Flash) ||
280 len(old.Windows) != len(cur.Windows) {279 len(old.Windows) != len(cur.Windows) {
281 return image.Rectangle{}, false280 return image.Rectangle{}, false
282 }281 }
@@ -287,6 +286,14 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim
287 return image.Rectangle{}, false286 return image.Rectangle{}, false
288 }287 }
289 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+ }
290 for i := range old.Windows {297 for i := range old.Windows {
291 a, b := old.Windows[i], cur.Windows[i]298 a, b := old.Windows[i], cur.Windows[i]
292 if a.ID != b.ID {299 if a.ID != b.ID {
M internal/adapters/vulkan/damage_delta_test.go +32 -0
@@ -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+}
@@ -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+}
M internal/adapters/vulkan/scene.go +7 -15
@@ -73,10 +73,12 @@ func (w *sceneWalk) focusFlash() {
7373 }
7474 alpha := w.alpha
7575 w.alpha = float32(f.Alpha)
76- defer func() { w.alpha = alpha }()
77- t := f.Rect
78- col := parseColor(f.Color)
79- b := min(f.Width, t.W/2, t.H/2)
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) {
8082 if b <= 0 {
8183 w.fill(w.physRect(t.X, t.Y, t.W, t.H), col)
8284 return
@@ -107,17 +109,7 @@ func (w *sceneWalk) captureIndicators() {
107109 w.pill(w.physRect(t.X, t.Y, t.W, t.H), col)
108110 continue
109111 }
110- b := ports.CaptureBorderOf(t.W, t.H)
111- if b <= 0 {
112- // Too thin for a border (core inflates such marks, so this is a
113- // 1-pixel output): a listed mark always draws something.
114- w.fill(w.physRect(t.X, t.Y, t.W, t.H), col)
115- continue
116- }
117- w.fill(w.physRect(t.X, t.Y, t.W, b), col)
118- w.fill(w.physRect(t.X, t.Y+t.H-b, t.W, b), col)
119- w.fill(w.physRect(t.X, t.Y+b, b, t.H-2*b), col)
120- 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)
121113 }
122114 }
123115
@@ -73,10 +73,12 @@ func (w *sceneWalk) focusFlash() {
73 }73 }
74 alpha := w.alpha74 alpha := w.alpha
75 w.alpha = float32(f.Alpha)75 w.alpha = float32(f.Alpha)
76- defer func() { w.alpha = alpha }()76+ w.innerBorder(f.Rect, min(f.Width, f.Rect.W/2, f.Rect.H/2), parseColor(f.Color))
77- t := f.Rect77+ w.alpha = alpha
78- col := parseColor(f.Color)78+}
79- b := min(f.Width, t.W/2, t.H/2)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) {
80 if b <= 0 {82 if b <= 0 {
81 w.fill(w.physRect(t.X, t.Y, t.W, t.H), col)83 w.fill(w.physRect(t.X, t.Y, t.W, t.H), col)
82 return84 return
@@ -107,17 +109,7 @@ func (w *sceneWalk) captureIndicators() {
107 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)
108 continue110 continue
109 }111 }
110- b := ports.CaptureBorderOf(t.W, t.H)112+ w.innerBorder(t, ports.CaptureBorderOf(t.W, t.H), col)
111- if b <= 0 {
112- // Too thin for a border (core inflates such marks, so this is a
113- // 1-pixel output): a listed mark always draws something.
114- w.fill(w.physRect(t.X, t.Y, t.W, t.H), col)
115- continue
116- }
117- w.fill(w.physRect(t.X, t.Y, t.W, b), col)
118- w.fill(w.physRect(t.X, t.Y+t.H-b, t.W, b), col)
119- w.fill(w.physRect(t.X, t.Y+b, b, t.H-2*b), col)
120- w.fill(w.physRect(t.X+t.W-b, t.Y+b, b, t.H-2*b), col)
121 }113 }
122 }114 }
123 115

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