detached fd1b483

feat(scene): add SceneWindow.Fade, drawn by the vulkan walk and never scanned out

A window's Fade (0 opaque, 1 invisible) scales its body fill, its own
border lines, its dim and every content draw by 1-Fade, premultiplied
(solids re-encoded on HDR outputs like solidDraw); a window at Fade 1
draws nothing but still opens the floats. The DRM scanout refuses a
fading or zoomed fullscreen window (window_fade) and the overlay plane a
fading candidate. sceneDelta covers Fade by struct equality (the draw
extent stays inside Rect); a leaving entry appearing or disappearing
changes the list and redraws everything.
commit
fd1b483dfb39993b2820275e7fdd0645059ecce2
author
brice · · 2026-10-04 21:55 UTC
parent
5f7a9a5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +196 -13
M internal/adapters/drm/overlay.go +4 -4
@@ -82,10 +82,10 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
8282 // A viewport crop is composed: the plane would show the whole
8383 // buffer, and so is a colour the plane cannot show (raw PQ values
8484 // on an SDR output, SDR on HDR without a pipeline). A dimmed
85- // window (a peeking stashed one) needs the veil drawn over it, and
86- // one with the focus effect needs its lift; an overview preview is
87- // drawn smaller than its buffer.
88- if w.Dim <= 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) {
85+ // window (a peeking stashed one) needs the veil drawn over it, a
86+ // fading one its translucency, and one with the focus effect its
87+ // lift; an overview preview is drawn smaller than its buffer.
88+ if w.Dim <= 0 && w.Fade <= 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) {
8989 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {
9090 pick, mode = w, m
9191 continue
@@ -82,10 +82,10 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
82 // A viewport crop is composed: the plane would show the whole82 // A viewport crop is composed: the plane would show the whole
83 // buffer, and so is a colour the plane cannot show (raw PQ values83 // buffer, and so is a colour the plane cannot show (raw PQ values
84 // on an SDR output, SDR on HDR without a pipeline). A dimmed84 // on an SDR output, SDR on HDR without a pipeline). A dimmed
85- // window (a peeking stashed one) needs the veil drawn over it, and85+ // window (a peeking stashed one) needs the veil drawn over it, a
86- // one with the focus effect needs its lift; an overview preview is86+ // fading one its translucency, and one with the focus effect its
87- // drawn smaller than its buffer.87+ // lift; an overview preview is drawn smaller than its buffer.
88- if w.Dim <= 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) {88+ if w.Dim <= 0 && w.Fade <= 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) {
89 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {89 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {
90 pick, mode = w, m90 pick, mode = w, m
91 continue91 continue
M internal/adapters/drm/overlay_test.go +6 -0
@@ -113,6 +113,12 @@ func TestOverlayCandidate(t *testing.T) {
113113 if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
114114 t.Fatalf("dimmed window reason %q", reason)
115115 }
116+ // A fading window needs its translucency composed.
117+ s, _ = overlayScene()
118+ s.Windows[len(s.Windows)-1].Fade = 0.5
119+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
120+ t.Fatalf("fading window reason %q", reason)
121+ }
116122 // A window with the focus effect needs it composed.
117123 s, _ = overlayScene()
118124 s.Windows[len(s.Windows)-1].FocusEffect = 0.05
@@ -113,6 +113,12 @@ func TestOverlayCandidate(t *testing.T) {
113 if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {113 if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
114 t.Fatalf("dimmed window reason %q", reason)114 t.Fatalf("dimmed window reason %q", reason)
115 }115 }
116+ // A fading window needs its translucency composed.
117+ s, _ = overlayScene()
118+ s.Windows[len(s.Windows)-1].Fade = 0.5
119+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
120+ t.Fatalf("fading window reason %q", reason)
121+ }
116 // A window with the focus effect needs it composed.122 // A window with the focus effect needs it composed.
117 s, _ = overlayScene()123 s, _ = overlayScene()
118 s.Windows[len(s.Windows)-1].FocusEffect = 0.05124 s.Windows[len(s.Windows)-1].FocusEffect = 0.05
M internal/adapters/drm/scanout.go +5 -0
@@ -115,6 +115,11 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
115115 // Its veil is composed over it: the plane would show it bright.
116116 return ports.SurfaceContent{}, "dim"
117117 }
118+ if full.Fade > 0 || full.Preview > 0 {
119+ // A fading or zoomed window is composed: the plane would show it
120+ // whole and opaque.
121+ return ports.SurfaceContent{}, "window_fade"
122+ }
118123 // Core leaves the layers a fullscreen window hides out of the scene:
119124 // one above the windows here is drawn.
120125 for _, l := range s.Layers {
@@ -115,6 +115,11 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
115 // Its veil is composed over it: the plane would show it bright.115 // Its veil is composed over it: the plane would show it bright.
116 return ports.SurfaceContent{}, "dim"116 return ports.SurfaceContent{}, "dim"
117 }117 }
118+ if full.Fade > 0 || full.Preview > 0 {
119+ // A fading or zoomed window is composed: the plane would show it
120+ // whole and opaque.
121+ return ports.SurfaceContent{}, "window_fade"
122+ }
118 // Core leaves the layers a fullscreen window hides out of the scene:123 // Core leaves the layers a fullscreen window hides out of the scene:
119 // one above the windows here is drawn.124 // one above the windows here is drawn.
120 for _, l := range s.Layers {125 for _, l := range s.Layers {
M internal/adapters/drm/scanout_test.go +2 -0
@@ -27,6 +27,8 @@ func TestScanoutCandidate(t *testing.T) {
2727 {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"},
2828 {"rotated output", func(s *ports.Scene) { s.Transform = 1 }, nil, "output_transform"},
2929 {"dimmed", func(s *ports.Scene) { s.Windows[0].Dim = 0.5 }, nil, "dim"},
30+ {"window fade", func(s *ports.Scene) { s.Windows[0].Fade = 0.5 }, nil, "window_fade"},
31+ {"window zoom", func(s *ports.Scene) { s.Windows[0].Preview = 0.9 }, nil, "window_fade"},
3032 {"faded", nil, func(c *ports.SurfaceContent) { c.Fade = 0.5 }, "fade"},
3133 {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"},
3234 {"float above", func(s *ports.Scene) {
@@ -27,6 +27,8 @@ func TestScanoutCandidate(t *testing.T) {
27 {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"},27 {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"},
28 {"rotated output", func(s *ports.Scene) { s.Transform = 1 }, nil, "output_transform"},28 {"rotated output", func(s *ports.Scene) { s.Transform = 1 }, nil, "output_transform"},
29 {"dimmed", func(s *ports.Scene) { s.Windows[0].Dim = 0.5 }, nil, "dim"},29 {"dimmed", func(s *ports.Scene) { s.Windows[0].Dim = 0.5 }, nil, "dim"},
30+ {"window fade", func(s *ports.Scene) { s.Windows[0].Fade = 0.5 }, nil, "window_fade"},
31+ {"window zoom", func(s *ports.Scene) { s.Windows[0].Preview = 0.9 }, nil, "window_fade"},
30 {"faded", nil, func(c *ports.SurfaceContent) { c.Fade = 0.5 }, "fade"},32 {"faded", nil, func(c *ports.SurfaceContent) { c.Fade = 0.5 }, "fade"},
31 {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"},33 {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"},
32 {"float above", func(s *ports.Scene) {34 {"float above", func(s *ports.Scene) {
M internal/adapters/vulkan/compose.go +16 -0
@@ -326,6 +326,22 @@ func (r *Renderer) solidDraw(rect image.Rectangle, content *ports.SurfaceContent
326326 return dr
327327 }
328328
329+// fadeSolid scales a solid draw's premultiplied color by k (a window's
330+// fade). The shader decodes a solid's rgb on HDR outputs: there the rgb is
331+// re-encoded so the decode yields the scaled linear premultiplied color, as
332+// solidDraw does.
333+func (r *Renderer) fadeSolid(dr *draw, k float32) {
334+ c := &dr.pc.color
335+ if r.hdrNits > 0 {
336+ for i := range 3 {
337+ c[i] = float32(linearToSRGB(srgbToLinear(float64(c[i])) * float64(k)))
338+ }
339+ } else {
340+ c[0], c[1], c[2] = c[0]*k, c[1]*k, c[2]*k
341+ }
342+ c[3] *= k
343+}
344+
329345 // dimDraw is premultiplied black: blending ONE, ONE_MINUS_SRC_ALPHA
330346 // leaves the destination at (1-alpha) of its previous value.
331347 func (r *Renderer) dimDraw(rect image.Rectangle, alpha float64) draw {
@@ -326,6 +326,22 @@ func (r *Renderer) solidDraw(rect image.Rectangle, content *ports.SurfaceContent
326 return dr326 return dr
327 }327 }
328 328
329+// fadeSolid scales a solid draw's premultiplied color by k (a window's
330+// fade). The shader decodes a solid's rgb on HDR outputs: there the rgb is
331+// re-encoded so the decode yields the scaled linear premultiplied color, as
332+// solidDraw does.
333+func (r *Renderer) fadeSolid(dr *draw, k float32) {
334+ c := &dr.pc.color
335+ if r.hdrNits > 0 {
336+ for i := range 3 {
337+ c[i] = float32(linearToSRGB(srgbToLinear(float64(c[i])) * float64(k)))
338+ }
339+ } else {
340+ c[0], c[1], c[2] = c[0]*k, c[1]*k, c[2]*k
341+ }
342+ c[3] *= k
343+}
344+
329 // dimDraw is premultiplied black: blending ONE, ONE_MINUS_SRC_ALPHA345 // dimDraw is premultiplied black: blending ONE, ONE_MINUS_SRC_ALPHA
330 // leaves the destination at (1-alpha) of its previous value.346 // leaves the destination at (1-alpha) of its previous value.
331 func (r *Renderer) dimDraw(rect image.Rectangle, alpha float64) draw {347 func (r *Renderer) dimDraw(rect image.Rectangle, alpha float64) draw {
M internal/adapters/vulkan/damage_delta_test.go +40 -1
@@ -26,7 +26,7 @@ func TestSceneFieldCount(t *testing.T) {
2626 // sceneDelta compares windows by struct equality, so a new field is covered
2727 // as long as it only changes where or how that window draws (inside Rect).
2828 func TestSceneWindowFieldCount(t *testing.T) {
29- const fields = 13
29+ const fields = 14
3030 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))),
3131 "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count")
3232 }
@@ -337,3 +337,42 @@ func sceneDeltaMatchesFullRedraw(t *testing.T, tc deltaCase, reuse bool) {
337337 }
338338 assert.Positive(t, partial, "no frame was redrawn partially")
339339 }
340+
341+// A window's Fade changes only how it draws inside its rect: the delta is
342+// that rect. A leaving entry (core keeps a closed or hidden window in the
343+// scene while it fades) changes the window list when it appears and when
344+// it goes: both redraw everything, like any list change; while it fades,
345+// only its rect is redrawn.
346+func TestDamageDeltaFadeAndLeavingEntry(t *testing.T) {
347+ bounds := image.Rect(0, 0, 1000, 500)
348+ settled := deltaScene(1, win(1, 0, 0, 500, 500), win(2, 500, 0, 500, 500))
349+
350+ faded := deltaScene(2, win(1, 0, 0, 500, 500), win(2, 500, 0, 500, 500))
351+ faded.Windows[1].Fade = 0.5
352+ d := newDamage(held(settled), faded, bounds)
353+ require.False(t, d.all())
354+ assert.Equal(t, image.Rect(500, 0, 1000, 500), d.area)
355+
356+ // Window 2 was unmapped: the layout re-flows and a leaving entry is
357+ // appended. The list changed: full redraw.
358+ leaving := deltaScene(3, win(1, 0, 0, 1000, 500), win(2, 500, 0, 500, 500))
359+ leaving.Windows[1].Fade = 0.1
360+ assert.True(t, newDamage(held(settled), leaving, bounds).all())
361+
362+ // The fade advances: its rect only, the neighbours untouched.
363+ next := deltaScene(4, win(1, 0, 0, 1000, 500), win(2, 500, 0, 500, 500))
364+ next.Windows[1].Fade = 0.6
365+ d = newDamage(held(leaving), next, bounds)
366+ require.False(t, d.all())
367+ assert.Equal(t, image.Rect(500, 0, 1000, 500), d.area)
368+
369+ // The entry settled and is dropped: full redraw (its pixels are stale).
370+ gone := deltaScene(5, win(1, 0, 0, 1000, 500))
371+ assert.True(t, newDamage(held(next), gone, bounds).all())
372+
373+ // A leaving float opens the floats where it is appended: the tile
374+ // lines and the veil move under it, so floatsStart differs.
375+ float := deltaScene(6, win(1, 0, 0, 1000, 500), win(2, 100, 100, 200, 200))
376+ float.Windows[1].Floating, float.Windows[1].Fade = true, 0.1
377+ assert.True(t, newDamage(held(gone), float, bounds).all())
378+}
@@ -26,7 +26,7 @@ func TestSceneFieldCount(t *testing.T) {
26 // sceneDelta compares windows by struct equality, so a new field is covered26 // sceneDelta compares windows by struct equality, so a new field is covered
27 // as long as it only changes where or how that window draws (inside Rect).27 // as long as it only changes where or how that window draws (inside Rect).
28 func TestSceneWindowFieldCount(t *testing.T) {28 func TestSceneWindowFieldCount(t *testing.T) {
29- const fields = 1329+ const fields = 14
30 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))),30 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))),
31 "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count")31 "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count")
32 }32 }
@@ -337,3 +337,42 @@ func sceneDeltaMatchesFullRedraw(t *testing.T, tc deltaCase, reuse bool) {
337 }337 }
338 assert.Positive(t, partial, "no frame was redrawn partially")338 assert.Positive(t, partial, "no frame was redrawn partially")
339 }339 }
340+
341+// A window's Fade changes only how it draws inside its rect: the delta is
342+// that rect. A leaving entry (core keeps a closed or hidden window in the
343+// scene while it fades) changes the window list when it appears and when
344+// it goes: both redraw everything, like any list change; while it fades,
345+// only its rect is redrawn.
346+func TestDamageDeltaFadeAndLeavingEntry(t *testing.T) {
347+ bounds := image.Rect(0, 0, 1000, 500)
348+ settled := deltaScene(1, win(1, 0, 0, 500, 500), win(2, 500, 0, 500, 500))
349+
350+ faded := deltaScene(2, win(1, 0, 0, 500, 500), win(2, 500, 0, 500, 500))
351+ faded.Windows[1].Fade = 0.5
352+ d := newDamage(held(settled), faded, bounds)
353+ require.False(t, d.all())
354+ assert.Equal(t, image.Rect(500, 0, 1000, 500), d.area)
355+
356+ // Window 2 was unmapped: the layout re-flows and a leaving entry is
357+ // appended. The list changed: full redraw.
358+ leaving := deltaScene(3, win(1, 0, 0, 1000, 500), win(2, 500, 0, 500, 500))
359+ leaving.Windows[1].Fade = 0.1
360+ assert.True(t, newDamage(held(settled), leaving, bounds).all())
361+
362+ // The fade advances: its rect only, the neighbours untouched.
363+ next := deltaScene(4, win(1, 0, 0, 1000, 500), win(2, 500, 0, 500, 500))
364+ next.Windows[1].Fade = 0.6
365+ d = newDamage(held(leaving), next, bounds)
366+ require.False(t, d.all())
367+ assert.Equal(t, image.Rect(500, 0, 1000, 500), d.area)
368+
369+ // The entry settled and is dropped: full redraw (its pixels are stale).
370+ gone := deltaScene(5, win(1, 0, 0, 1000, 500))
371+ assert.True(t, newDamage(held(next), gone, bounds).all())
372+
373+ // A leaving float opens the floats where it is appended: the tile
374+ // lines and the veil move under it, so floatsStart differs.
375+ float := deltaScene(6, win(1, 0, 0, 1000, 500), win(2, 100, 100, 200, 200))
376+ float.Windows[1].Floating, float.Windows[1].Fade = true, 0.1
377+ assert.True(t, newDamage(held(gone), float, bounds).all())
378+}
M internal/adapters/vulkan/scene.go +29 -8
@@ -22,7 +22,11 @@ type sceneWalk struct {
2222 zoom float64
2323 // pulse is the focus effect of the window being placed
2424 // (ports.SceneWindow.FocusEffect); 0 otherwise.
25- pulse float32
25+ pulse float32
26+ // alpha is the opacity of the window being drawn, 1 - its Fade
27+ // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by
28+ // it (premultiplied); 1 otherwise.
29+ alpha float32
2630 bounds image.Rectangle
2731 draws []draw
2832 }
@@ -33,7 +37,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
3337 // The walk stays in scene-physical space (the target's size, swapped for
3438 // a rotated output); orient maps the draws to the target afterwards.
3539 sw, sh := r.sceneSize(s.Transform)
36- w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, zoom: 1, bounds: image.Rect(0, 0, sw, sh), draws: r.scratchDraws[:0]}
40+ w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, zoom: 1, alpha: 1, bounds: image.Rect(0, 0, sw, sh), draws: r.scratchDraws[:0]}
3741 r.scratchCovers = r.scratchCovers[:0]
3842 if w.scale <= 0 {
3943 w.scale = 1
@@ -129,21 +133,25 @@ func (w *sceneWalk) physRectF(x, y, width, height float64) image.Rectangle {
129133 return image.Rect(p(x), p(y), p(x+width), p(y+height))
130134 }
131135
132-// fill draws a solid rect (physical pixels).
136+// fill draws a solid rect (physical pixels), at the walk's alpha.
133137 func (w *sceneWalk) fill(rect image.Rectangle, c [3]uint8) {
134138 rect = rect.Intersect(w.bounds)
135139 if rect.Empty() {
136140 return
137141 }
138- w.draws = append(w.draws, w.r.fillDraw(rect, c))
142+ dr := w.r.fillDraw(rect, c)
143+ if w.alpha < 1 {
144+ w.r.fadeSolid(&dr, w.alpha)
145+ }
146+ w.draws = append(w.draws, dr)
139147 }
140148
141149 // dim paints a translucent black quad over rect (physical), clipped to the
142-// output. Damage clipping later limits it to the region being repainted,
143-// just like other fills.
150+// output, at alpha scaled by the walk's alpha. Damage clipping later limits
151+// it to the region being repainted, just like other fills.
144152 func (w *sceneWalk) dim(rect image.Rectangle, alpha float64) {
145153 if rect = rect.Intersect(w.bounds); !rect.Empty() {
146- w.draws = append(w.draws, w.r.dimDraw(rect, alpha))
154+ w.draws = append(w.draws, w.r.dimDraw(rect, alpha*float64(w.alpha)))
147155 }
148156 }
149157
@@ -205,6 +213,12 @@ func (w *sceneWalk) windows() {
205213 w.dim(w.bounds, w.s.Dim)
206214 }
207215 }
216+ if win.Fade >= 1 {
217+ // Faded out: it still opens the floats (its lines and veil are
218+ // painted under where it would be), but draws nothing.
219+ continue
220+ }
221+ w.alpha = 1 - float32(max(0, win.Fade))
208222 // Content sits inside the border; core sized the client to match.
209223 b, inset := 0, ports.Sides(0)
210224 if !win.Fullscreen {
@@ -231,6 +245,7 @@ func (w *sceneWalk) windows() {
231245 // A stashed window peeking in: dimmed with its border.
232246 w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim)
233247 }
248+ w.alpha = 1
234249 }
235250 if !tileLines {
236251 w.separators(0)
@@ -389,7 +404,11 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
389404 return
390405 }
391406 if content.Solid != nil {
392- if dr := r.solidDraw(rect, content); dr.pc.color[3] > 0 {
407+ dr := r.solidDraw(rect, content)
408+ if w.alpha < 1 {
409+ r.fadeSolid(&dr, w.alpha)
410+ }
411+ if dr.pc.color[3] > 0 {
393412 w.draws = append(w.draws, dr)
394413 }
395414 return
@@ -402,6 +421,7 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
402421 }
403422 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque)
404423 r.setContentColor(&dr, content)
424+ dr.pc.color[3] *= w.alpha
405425 dr.pc.mapy[2] = w.pulse
406426 if im.yuv {
407427 dr.pc.misc[1] |= flagYUV
@@ -439,6 +459,7 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
439459 }
440460 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque)
441461 r.setContentColor(&dr, content)
462+ dr.pc.color[3] *= w.alpha
442463 dr.pc.mapy[2] = w.pulse
443464 dr.set = c.set
444465 dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0}
@@ -22,7 +22,11 @@ type sceneWalk struct {
22 zoom float6422 zoom float64
23 // pulse is the focus effect of the window being placed23 // pulse is the focus effect of the window being placed
24 // (ports.SceneWindow.FocusEffect); 0 otherwise.24 // (ports.SceneWindow.FocusEffect); 0 otherwise.
25- pulse float3225+ pulse float32
26+ // alpha is the opacity of the window being drawn, 1 - its Fade
27+ // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by
28+ // it (premultiplied); 1 otherwise.
29+ alpha float32
26 bounds image.Rectangle30 bounds image.Rectangle
27 draws []draw31 draws []draw
28 }32 }
@@ -33,7 +37,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
33 // The walk stays in scene-physical space (the target's size, swapped for37 // The walk stays in scene-physical space (the target's size, swapped for
34 // a rotated output); orient maps the draws to the target afterwards.38 // a rotated output); orient maps the draws to the target afterwards.
35 sw, sh := r.sceneSize(s.Transform)39 sw, sh := r.sceneSize(s.Transform)
36- w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, zoom: 1, bounds: image.Rect(0, 0, sw, sh), draws: r.scratchDraws[:0]}40+ w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, zoom: 1, alpha: 1, bounds: image.Rect(0, 0, sw, sh), draws: r.scratchDraws[:0]}
37 r.scratchCovers = r.scratchCovers[:0]41 r.scratchCovers = r.scratchCovers[:0]
38 if w.scale <= 0 {42 if w.scale <= 0 {
39 w.scale = 143 w.scale = 1
@@ -129,21 +133,25 @@ func (w *sceneWalk) physRectF(x, y, width, height float64) image.Rectangle {
129 return image.Rect(p(x), p(y), p(x+width), p(y+height))133 return image.Rect(p(x), p(y), p(x+width), p(y+height))
130 }134 }
131 135
132-// fill draws a solid rect (physical pixels).136+// fill draws a solid rect (physical pixels), at the walk's alpha.
133 func (w *sceneWalk) fill(rect image.Rectangle, c [3]uint8) {137 func (w *sceneWalk) fill(rect image.Rectangle, c [3]uint8) {
134 rect = rect.Intersect(w.bounds)138 rect = rect.Intersect(w.bounds)
135 if rect.Empty() {139 if rect.Empty() {
136 return140 return
137 }141 }
138- w.draws = append(w.draws, w.r.fillDraw(rect, c))142+ dr := w.r.fillDraw(rect, c)
143+ if w.alpha < 1 {
144+ w.r.fadeSolid(&dr, w.alpha)
145+ }
146+ w.draws = append(w.draws, dr)
139 }147 }
140 148
141 // dim paints a translucent black quad over rect (physical), clipped to the149 // dim paints a translucent black quad over rect (physical), clipped to the
142-// output. Damage clipping later limits it to the region being repainted,150+// output, at alpha scaled by the walk's alpha. Damage clipping later limits
143-// just like other fills.151+// it to the region being repainted, just like other fills.
144 func (w *sceneWalk) dim(rect image.Rectangle, alpha float64) {152 func (w *sceneWalk) dim(rect image.Rectangle, alpha float64) {
145 if rect = rect.Intersect(w.bounds); !rect.Empty() {153 if rect = rect.Intersect(w.bounds); !rect.Empty() {
146- w.draws = append(w.draws, w.r.dimDraw(rect, alpha))154+ w.draws = append(w.draws, w.r.dimDraw(rect, alpha*float64(w.alpha)))
147 }155 }
148 }156 }
149 157
@@ -205,6 +213,12 @@ func (w *sceneWalk) windows() {
205 w.dim(w.bounds, w.s.Dim)213 w.dim(w.bounds, w.s.Dim)
206 }214 }
207 }215 }
216+ if win.Fade >= 1 {
217+ // Faded out: it still opens the floats (its lines and veil are
218+ // painted under where it would be), but draws nothing.
219+ continue
220+ }
221+ w.alpha = 1 - float32(max(0, win.Fade))
208 // Content sits inside the border; core sized the client to match.222 // Content sits inside the border; core sized the client to match.
209 b, inset := 0, ports.Sides(0)223 b, inset := 0, ports.Sides(0)
210 if !win.Fullscreen {224 if !win.Fullscreen {
@@ -231,6 +245,7 @@ func (w *sceneWalk) windows() {
231 // A stashed window peeking in: dimmed with its border.245 // A stashed window peeking in: dimmed with its border.
232 w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim)246 w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim)
233 }247 }
248+ w.alpha = 1
234 }249 }
235 if !tileLines {250 if !tileLines {
236 w.separators(0)251 w.separators(0)
@@ -389,7 +404,11 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
389 return404 return
390 }405 }
391 if content.Solid != nil {406 if content.Solid != nil {
392- if dr := r.solidDraw(rect, content); dr.pc.color[3] > 0 {407+ dr := r.solidDraw(rect, content)
408+ if w.alpha < 1 {
409+ r.fadeSolid(&dr, w.alpha)
410+ }
411+ if dr.pc.color[3] > 0 {
393 w.draws = append(w.draws, dr)412 w.draws = append(w.draws, dr)
394 }413 }
395 return414 return
@@ -402,6 +421,7 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
402 }421 }
403 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque)422 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque)
404 r.setContentColor(&dr, content)423 r.setContentColor(&dr, content)
424+ dr.pc.color[3] *= w.alpha
405 dr.pc.mapy[2] = w.pulse425 dr.pc.mapy[2] = w.pulse
406 if im.yuv {426 if im.yuv {
407 dr.pc.misc[1] |= flagYUV427 dr.pc.misc[1] |= flagYUV
@@ -439,6 +459,7 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
439 }459 }
440 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque)460 dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque)
441 r.setContentColor(&dr, content)461 r.setContentColor(&dr, content)
462+ dr.pc.color[3] *= w.alpha
442 dr.pc.mapy[2] = w.pulse463 dr.pc.mapy[2] = w.pulse
443 dr.set = c.set464 dr.set = c.set
444 dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0}465 dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0}
A internal/adapters/vulkan/scene_fade_test.go +88 -0
@@ -0,0 +1,88 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "math"
6+ "slices"
7+ "testing"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// A window's Fade scales its body fill, its own border lines, its dim and
13+// its content by 1-Fade (premultiplied); at 1 the window draws nothing. The
14+// walk needs no Vulkan device: a solid single-pixel buffer imports nothing.
15+func TestSceneWindowFade(t *testing.T) {
16+ near := func(a, b float32) bool { return math.Abs(float64(a-b)) < 1e-6 }
17+ walk := func(r *Renderer, fade float64) []draw {
18+ s := ports.Scene{
19+ Background: "#ffffff", Dim: 0, Border: ports.Border{Width: 1, Active: "#ffffff", Inactive: "#808080"},
20+ Windows: []ports.SceneWindow{
21+ {ID: 1, Rect: ports.Rect{W: 40, H: 60}},
22+ {ID: 2, Floating: true, Rect: ports.Rect{X: 40, W: 20, H: 20}, Inset: ports.SideLeft | ports.SideRight | ports.SideTop | ports.SideBottom, Fade: fade, Dim: 0.5},
23+ },
24+ Separators: []ports.Separator{{Rect: ports.Rect{X: 40, W: 1, H: 20}, Window: 2}},
25+ }
26+ contents := map[ports.WindowID]ports.SurfaceContent{
27+ 2: {ID: 2, Width: 1, Height: 1, LogicalW: 18, LogicalH: 18, Solid: &ports.SolidColor{R: 1, G: 0.5, B: 0, A: 1}},
28+ }
29+ // The renderer reuses its draw buffer: each walk's result is kept.
30+ return slices.Clone(r.draws(s, contents, newDamage(&target{}, s, image.Rect(0, 0, 80, 60))))
31+ }
32+ r := &Renderer{width: 80, height: 60}
33+ opaque := walk(r, 0)
34+ // Tile fill, float body fill, float content, float line, float dim.
35+ if len(opaque) != 5 {
36+ t.Fatalf("draws = %d, want 5: %+v", len(opaque), opaque)
37+ }
38+ half := walk(r, 0.5)
39+ if len(half) != 5 {
40+ t.Fatalf("faded draws = %d, want 5", len(half))
41+ }
42+ if half[0].pc.color != opaque[0].pc.color {
43+ t.Fatalf("the tile's fill changed with the float's fade: %v", half[0].pc.color)
44+ }
45+ for i := 1; i < 5; i++ {
46+ for ch := range 4 {
47+ if !near(half[i].pc.color[ch], opaque[i].pc.color[ch]*0.5) {
48+ t.Fatalf("draw %d channel %d: %v, want half of %v", i, ch, half[i].pc.color, opaque[i].pc.color)
49+ }
50+ }
51+ if half[i].pc.rect != opaque[i].pc.rect {
52+ t.Fatalf("draw %d moved with the fade: %v vs %v", i, half[i].pc.rect, opaque[i].pc.rect)
53+ }
54+ }
55+ if got := half[2].pc.misc[0]; got != modeSolid {
56+ t.Fatalf("content draw mode %d", got)
57+ }
58+ // Faded out: nothing of the float, the tile unchanged.
59+ gone := walk(r, 1)
60+ if len(gone) != 1 || gone[0].pc.color != opaque[0].pc.color {
61+ t.Fatalf("faded-out draws = %+v, want the tile only", gone)
62+ }
63+ // A second window after the faded one draws at full alpha.
64+ s := ports.Scene{Background: "#ffffff", Windows: []ports.SceneWindow{
65+ {ID: 1, Rect: ports.Rect{W: 40, H: 60}, Fade: 0.25},
66+ {ID: 2, Rect: ports.Rect{X: 40, W: 40, H: 60}},
67+ }}
68+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))
69+ if len(ds) != 2 || !near(ds[0].pc.color[3], 0.75) || ds[1].pc.color[3] != 1 {
70+ t.Fatalf("alpha leaked past the faded window: %+v", ds)
71+ }
72+}
73+
74+// On an HDR output a solid's rgb is re-encoded so the shader's decode
75+// yields the faded linear premultiplied color.
76+func TestSceneWindowFadeHDR(t *testing.T) {
77+ r := &Renderer{width: 80, height: 60, hdrNits: 203}
78+ s := ports.Scene{Background: "#ffffff", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 40, H: 60}, Fade: 0.5}}}
79+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))
80+ if len(ds) != 1 {
81+ t.Fatalf("draws = %d", len(ds))
82+ }
83+ want := float32(linearToSRGB(0.5))
84+ c := ds[0].pc.color
85+ if math.Abs(float64(c[0]-want)) > 1e-6 || c[0] != c[1] || c[1] != c[2] || c[3] != 0.5 {
86+ t.Fatalf("hdr faded white = %v, want rgb %v alpha 0.5", c, want)
87+ }
88+}
@@ -0,0 +1,88 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "math"
6+ "slices"
7+ "testing"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// A window's Fade scales its body fill, its own border lines, its dim and
13+// its content by 1-Fade (premultiplied); at 1 the window draws nothing. The
14+// walk needs no Vulkan device: a solid single-pixel buffer imports nothing.
15+func TestSceneWindowFade(t *testing.T) {
16+ near := func(a, b float32) bool { return math.Abs(float64(a-b)) < 1e-6 }
17+ walk := func(r *Renderer, fade float64) []draw {
18+ s := ports.Scene{
19+ Background: "#ffffff", Dim: 0, Border: ports.Border{Width: 1, Active: "#ffffff", Inactive: "#808080"},
20+ Windows: []ports.SceneWindow{
21+ {ID: 1, Rect: ports.Rect{W: 40, H: 60}},
22+ {ID: 2, Floating: true, Rect: ports.Rect{X: 40, W: 20, H: 20}, Inset: ports.SideLeft | ports.SideRight | ports.SideTop | ports.SideBottom, Fade: fade, Dim: 0.5},
23+ },
24+ Separators: []ports.Separator{{Rect: ports.Rect{X: 40, W: 1, H: 20}, Window: 2}},
25+ }
26+ contents := map[ports.WindowID]ports.SurfaceContent{
27+ 2: {ID: 2, Width: 1, Height: 1, LogicalW: 18, LogicalH: 18, Solid: &ports.SolidColor{R: 1, G: 0.5, B: 0, A: 1}},
28+ }
29+ // The renderer reuses its draw buffer: each walk's result is kept.
30+ return slices.Clone(r.draws(s, contents, newDamage(&target{}, s, image.Rect(0, 0, 80, 60))))
31+ }
32+ r := &Renderer{width: 80, height: 60}
33+ opaque := walk(r, 0)
34+ // Tile fill, float body fill, float content, float line, float dim.
35+ if len(opaque) != 5 {
36+ t.Fatalf("draws = %d, want 5: %+v", len(opaque), opaque)
37+ }
38+ half := walk(r, 0.5)
39+ if len(half) != 5 {
40+ t.Fatalf("faded draws = %d, want 5", len(half))
41+ }
42+ if half[0].pc.color != opaque[0].pc.color {
43+ t.Fatalf("the tile's fill changed with the float's fade: %v", half[0].pc.color)
44+ }
45+ for i := 1; i < 5; i++ {
46+ for ch := range 4 {
47+ if !near(half[i].pc.color[ch], opaque[i].pc.color[ch]*0.5) {
48+ t.Fatalf("draw %d channel %d: %v, want half of %v", i, ch, half[i].pc.color, opaque[i].pc.color)
49+ }
50+ }
51+ if half[i].pc.rect != opaque[i].pc.rect {
52+ t.Fatalf("draw %d moved with the fade: %v vs %v", i, half[i].pc.rect, opaque[i].pc.rect)
53+ }
54+ }
55+ if got := half[2].pc.misc[0]; got != modeSolid {
56+ t.Fatalf("content draw mode %d", got)
57+ }
58+ // Faded out: nothing of the float, the tile unchanged.
59+ gone := walk(r, 1)
60+ if len(gone) != 1 || gone[0].pc.color != opaque[0].pc.color {
61+ t.Fatalf("faded-out draws = %+v, want the tile only", gone)
62+ }
63+ // A second window after the faded one draws at full alpha.
64+ s := ports.Scene{Background: "#ffffff", Windows: []ports.SceneWindow{
65+ {ID: 1, Rect: ports.Rect{W: 40, H: 60}, Fade: 0.25},
66+ {ID: 2, Rect: ports.Rect{X: 40, W: 40, H: 60}},
67+ }}
68+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))
69+ if len(ds) != 2 || !near(ds[0].pc.color[3], 0.75) || ds[1].pc.color[3] != 1 {
70+ t.Fatalf("alpha leaked past the faded window: %+v", ds)
71+ }
72+}
73+
74+// On an HDR output a solid's rgb is re-encoded so the shader's decode
75+// yields the faded linear premultiplied color.
76+func TestSceneWindowFadeHDR(t *testing.T) {
77+ r := &Renderer{width: 80, height: 60, hdrNits: 203}
78+ s := ports.Scene{Background: "#ffffff", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 40, H: 60}, Fade: 0.5}}}
79+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))
80+ if len(ds) != 1 {
81+ t.Fatalf("draws = %d", len(ds))
82+ }
83+ want := float32(linearToSRGB(0.5))
84+ c := ds[0].pc.color
85+ if math.Abs(float64(c[0]-want)) > 1e-6 || c[0] != c[1] || c[1] != c[2] || c[3] != 0.5 {
86+ t.Fatalf("hdr faded white = %v, want rgb %v alpha 0.5", c, want)
87+ }
88+}
M internal/ports/scene.go +5 -0
@@ -414,6 +414,11 @@ type SceneWindow struct {
414414 // Dim darkens the window, border included, with black at this
415415 // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing.
416416 Dim float64
417+ // Fade makes the whole window translucent, border and content: 0 is
418+ // opaque, 1 invisible (like SurfaceContent.Fade). A window appearing
419+ // or leaving animates it; a faded window is composed, never scanned
420+ // out.
421+ Fade float64
417422 // FocusEffect, above 0, is the focus indicator's effect at this frame
418423 // (focus.animation, focus.effect): the window's surfaces are lifted
419424 // toward white by that fraction (screen blend). Renderers without the
@@ -414,6 +414,11 @@ type SceneWindow struct {
414 // Dim darkens the window, border included, with black at this414 // Dim darkens the window, border included, with black at this
415 // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing.415 // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing.
416 Dim float64416 Dim float64
417+ // Fade makes the whole window translucent, border and content: 0 is
418+ // opaque, 1 invisible (like SurfaceContent.Fade). A window appearing
419+ // or leaving animates it; a faded window is composed, never scanned
420+ // out.
421+ Fade float64
417 // FocusEffect, above 0, is the focus indicator's effect at this frame422 // FocusEffect, above 0, is the focus indicator's effect at this frame
418 // (focus.animation, focus.effect): the window's surfaces are lifted423 // (focus.animation, focus.effect): the window's surfaces are lifted
419 // toward white by that fraction (screen blend). Renderers without the424 // toward white by that fraction (screen blend). Renderers without the
M internal/ports/scene_same_test.go +1 -0
@@ -37,6 +37,7 @@ func TestSceneSameAs(t *testing.T) {
3737
3838 for name, edit := range map[string]func(*Scene){
3939 "window rect": func(s *Scene) { s.Windows[0].Rect.X++ },
40+ "window fade": func(s *Scene) { s.Windows[0].Fade = 0.5 },
4041 "window count": func(s *Scene) { s.Windows = nil },
4142 "security": func(s *Scene) { s.Security.Generation++ },
4243 "background": func(s *Scene) { s.Background = "#111111" },
@@ -37,6 +37,7 @@ func TestSceneSameAs(t *testing.T) {
37 37
38 for name, edit := range map[string]func(*Scene){38 for name, edit := range map[string]func(*Scene){
39 "window rect": func(s *Scene) { s.Windows[0].Rect.X++ },39 "window rect": func(s *Scene) { s.Windows[0].Rect.X++ },
40+ "window fade": func(s *Scene) { s.Windows[0].Fade = 0.5 },
40 "window count": func(s *Scene) { s.Windows = nil },41 "window count": func(s *Scene) { s.Windows = nil },
41 "security": func(s *Scene) { s.Security.Generation++ },42 "security": func(s *Scene) { s.Security.Generation++ },
42 "background": func(s *Scene) { s.Background = "#111111" },43 "background": func(s *Scene) { s.Background = "#111111" },

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 23

Tags 10

↑↓ moveenter openesc close