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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 82 | 82 | // A viewport crop is composed: the plane would show the whole | |
| 83 | 83 | // buffer, and so is a colour the plane cannot show (raw PQ values | |
| 84 | 84 | // 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) { | |
| 89 | 89 | if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" { | |
| 90 | 90 | pick, mode = w, m | |
| 91 | 91 | 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 whole | 82 | // A viewport crop is composed: the plane would show the whole | ||
| 83 | // buffer, and so is a colour the plane cannot show (raw PQ values | 83 | // 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 dimmed | 84 | // 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 | 85 | + | // window (a peeking stashed one) needs the veil drawn over it, a |
| 86 | - | // one with the focus effect needs its lift; an overview preview is | 86 | + | // 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, m | 90 | pick, mode = w, m | ||
| 91 | continue | 91 | continue | ||
M internal/adapters/drm/overlay_test.go +6 -0
| @@ -113,6 +113,12 @@ func TestOverlayCandidate(t *testing.T) { | |||
| 113 | 113 | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" { | |
| 114 | 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 | 122 | // A window with the focus effect needs it composed. | |
| 117 | 123 | s, _ = overlayScene() | |
| 118 | 124 | 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.05 | 124 | 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 | |||
| 115 | 115 | // Its veil is composed over it: the plane would show it bright. | |
| 116 | 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 | 123 | // Core leaves the layers a fullscreen window hides out of the scene: | |
| 119 | 124 | // one above the windows here is drawn. | |
| 120 | 125 | 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) { | |||
| 27 | 27 | {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"}, | |
| 28 | 28 | {"rotated output", func(s *ports.Scene) { s.Transform = 1 }, nil, "output_transform"}, | |
| 29 | 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 | 32 | {"faded", nil, func(c *ports.SurfaceContent) { c.Fade = 0.5 }, "fade"}, | |
| 31 | 33 | {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"}, | |
| 32 | 34 | {"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 | |||
| 326 | 326 | 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 | 345 | // dimDraw is premultiplied black: blending ONE, ONE_MINUS_SRC_ALPHA | |
| 330 | 346 | // leaves the destination at (1-alpha) of its previous value. | |
| 331 | 347 | 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 dr | 326 | 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_ALPHA | 345 | // 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) { | |||
| 26 | 26 | // sceneDelta compares windows by struct equality, so a new field is covered | |
| 27 | 27 | // as long as it only changes where or how that window draws (inside Rect). | |
| 28 | 28 | func TestSceneWindowFieldCount(t *testing.T) { | |
| 29 | - | const fields = 13 | |
| 29 | + | const fields = 14 | |
| 30 | 30 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))), | |
| 31 | 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 | 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 | + | } | |
| @@ -26,7 +26,7 @@ func TestSceneFieldCount(t *testing.T) { | |||||
| 26 | // sceneDelta compares windows by struct equality, so a new field is covered | 26 | // 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 = 13 | 29 | + | 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 { | |||
| 22 | 22 | zoom float64 | |
| 23 | 23 | // pulse is the focus effect of the window being placed | |
| 24 | 24 | // (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 | |
| 26 | 30 | bounds image.Rectangle | |
| 27 | 31 | draws []draw | |
| 28 | 32 | } | |
| @@ -33,7 +37,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||
| 33 | 37 | // The walk stays in scene-physical space (the target's size, swapped for | |
| 34 | 38 | // a rotated output); orient maps the draws to the target afterwards. | |
| 35 | 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 | 41 | r.scratchCovers = r.scratchCovers[:0] | |
| 38 | 42 | if w.scale <= 0 { | |
| 39 | 43 | w.scale = 1 | |
| @@ -129,21 +133,25 @@ func (w *sceneWalk) physRectF(x, y, width, height float64) image.Rectangle { | |||
| 129 | 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 | 137 | func (w *sceneWalk) fill(rect image.Rectangle, c [3]uint8) { | |
| 134 | 138 | rect = rect.Intersect(w.bounds) | |
| 135 | 139 | if rect.Empty() { | |
| 136 | 140 | 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 | 149 | // 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. | |
| 144 | 152 | func (w *sceneWalk) dim(rect image.Rectangle, alpha float64) { | |
| 145 | 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 | 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 | 222 | // Content sits inside the border; core sized the client to match. | |
| 209 | 223 | b, inset := 0, ports.Sides(0) | |
| 210 | 224 | if !win.Fullscreen { | |
| @@ -231,6 +245,7 @@ func (w *sceneWalk) windows() { | |||
| 231 | 245 | // A stashed window peeking in: dimmed with its border. | |
| 232 | 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 | 250 | if !tileLines { | |
| 236 | 251 | w.separators(0) | |
| @@ -389,7 +404,11 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon | |||
| 389 | 404 | return | |
| 390 | 405 | } | |
| 391 | 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 | 412 | w.draws = append(w.draws, dr) | |
| 394 | 413 | } | |
| 395 | 414 | return | |
| @@ -402,6 +421,7 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon | |||
| 402 | 421 | } | |
| 403 | 422 | dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeImage, content.Opaque) | |
| 404 | 423 | r.setContentColor(&dr, content) | |
| 424 | + | dr.pc.color[3] *= w.alpha | |
| 405 | 425 | dr.pc.mapy[2] = w.pulse | |
| 406 | 426 | if im.yuv { | |
| 407 | 427 | dr.pc.misc[1] |= flagYUV | |
| @@ -439,6 +459,7 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon | |||
| 439 | 459 | } | |
| 440 | 460 | dr := r.contentDraw(rect, full, content.Width, content.Height, content.Source, content.Transform, modeBuffer, content.Opaque) | |
| 441 | 461 | r.setContentColor(&dr, content) | |
| 462 | + | dr.pc.color[3] *= w.alpha | |
| 442 | 463 | dr.pc.mapy[2] = w.pulse | |
| 443 | 464 | dr.set = c.set | |
| 444 | 465 | dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0} | |
| @@ -22,7 +22,11 @@ type sceneWalk struct { | |||||
| 22 | zoom float64 | 22 | zoom float64 | ||
| 23 | // pulse is the focus effect of the window being placed | 23 | // pulse is the focus effect of the window being placed | ||
| 24 | // (ports.SceneWindow.FocusEffect); 0 otherwise. | 24 | // (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 | |||
| 26 | bounds image.Rectangle | 30 | bounds image.Rectangle | ||
| 27 | draws []draw | 31 | 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 for | 37 | // 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 = 1 | 43 | 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 | return | 140 | 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 the | 149 | // 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 | return | 404 | 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 | return | 414 | 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.pulse | 425 | dr.pc.mapy[2] = w.pulse | ||
| 406 | if im.yuv { | 426 | if im.yuv { | ||
| 407 | dr.pc.misc[1] |= flagYUV | 427 | 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.pulse | 463 | dr.pc.mapy[2] = w.pulse | ||
| 443 | dr.set = c.set | 464 | 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 { | |||
| 414 | 414 | // Dim darkens the window, border included, with black at this | |
| 415 | 415 | // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing. | |
| 416 | 416 | 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 | 422 | // FocusEffect, above 0, is the focus indicator's effect at this frame | |
| 418 | 423 | // (focus.animation, focus.effect): the window's surfaces are lifted | |
| 419 | 424 | // 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 this | 414 | // 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 float64 | 416 | 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 frame | 422 | // FocusEffect, above 0, is the focus indicator's effect at this frame | ||
| 418 | // (focus.animation, focus.effect): the window's surfaces are lifted | 423 | // (focus.animation, focus.effect): the window's surfaces are lifted | ||
| 419 | // toward white by that fraction (screen blend). Renderers without the | 424 | // 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) { | |||
| 37 | 37 | ||
| 38 | 38 | for name, edit := range map[string]func(*Scene){ | |
| 39 | 39 | "window rect": func(s *Scene) { s.Windows[0].Rect.X++ }, | |
| 40 | + | "window fade": func(s *Scene) { s.Windows[0].Fade = 0.5 }, | |
| 40 | 41 | "window count": func(s *Scene) { s.Windows = nil }, | |
| 41 | 42 | "security": func(s *Scene) { s.Security.Generation++ }, | |
| 42 | 43 | "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" }, | ||