1package vulkan
2
3import (
4 "image"
5 "image/color"
6 "math"
7 "testing"
8
9 "github.com/bnema/neferwl/internal/ports"
10)
11
12// These walk tests need no Vulkan device: placeholder windows and solid
13// separators produce draw commands without importing client buffers.
14func TestSceneDimOrder(t *testing.T) {
15 r := &Renderer{width: 80, height: 60}
16 s := ports.Scene{
17 Dim: 0.4, Border: ports.Border{Active: "#ffffff"},
18 Windows: []ports.SceneWindow{
19 {ID: 1, Rect: ports.Rect{W: 20, H: 20}},
20 {ID: 2, Floating: true, Hidden: true, Rect: ports.Rect{W: 20, H: 20}},
21 {ID: 3, Floating: true, Popup: true, Rect: ports.Rect{W: 20, H: 20}},
22 {ID: 4, Floating: true, Rect: ports.Rect{W: 0, H: 20}},
23 {ID: 5, Floating: true, Rect: ports.Rect{X: 30, W: 20, H: 20}},
24 {ID: 6, Floating: true, Rect: ports.Rect{X: 50, W: 20, H: 20}},
25 },
26 Separators: []ports.Separator{{Rect: ports.Rect{W: 80, H: 1}, Active: true}},
27 }
28 walk := func(s ports.Scene) []draw {
29 return r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))
30 }
31 got := walk(s)
32 if len(got) != 5 { // tile, tile lines, veil, two floats
33 t.Fatalf("draws = %d, want 5", len(got))
34 }
35 veil := got[2]
36 if veil.pc.rect != [4]int32{0, 0, 80, 60} || veil.pc.color != [4]float32{0, 0, 0, float32(s.Dim)} || veil.pc.misc[0] != modeSolid || veil.pc.misc[1] != 0 {
37 t.Fatalf("veil = %+v", veil.pc)
38 }
39 if got[1].pc.color != [4]float32{1, 1, 1, 1} || got[3].pc.rect[0] != 30 || got[4].pc.rect[0] != 50 {
40 t.Fatalf("tile line / float order: %+v", got)
41 }
42 for _, tc := range []struct {
43 name string
44 dim float64
45 windows []ports.SceneWindow
46 wantCount int
47 wantAlpha float32
48 }{
49 {"zero", 0, s.Windows, 4, 0},
50 {"negative", -1, s.Windows, 4, 0},
51 {"clamped", 2, s.Windows, 5, 1},
52 {"no float", 0.4, s.Windows[:4], 2, 0},
53 } {
54 t.Run(tc.name, func(t *testing.T) {
55 s.Dim, s.Windows = tc.dim, tc.windows
56 ds := walk(s)
57 if len(ds) != tc.wantCount {
58 t.Fatalf("draws = %d, want %d", len(ds), tc.wantCount)
59 }
60 if tc.wantAlpha != 0 && ds[2].pc.color[3] != tc.wantAlpha {
61 t.Fatalf("alpha = %v, want %v", ds[2].pc.color[3], tc.wantAlpha)
62 }
63 })
64 }
65}
66
67// A dimmed window gets a veil over its own rect, border included, right
68// after it: windows drawn later stay bright.
69func TestSceneWindowDim(t *testing.T) {
70 r := &Renderer{width: 80, height: 60}
71 s := ports.Scene{Border: ports.Border{Width: 1, Inactive: "#ffffff"},
72 Windows: []ports.SceneWindow{
73 {ID: 1, Floating: true, Dim: 0.5, Inset: ports.SideAll, Rect: ports.Rect{X: -10, W: 20, H: 20}},
74 {ID: 2, Floating: true, Inset: ports.SideAll, Rect: ports.Rect{X: 20, W: 20, H: 20}},
75 },
76 Separators: []ports.Separator{{Window: 1, Rect: ports.Rect{X: -10, W: 20, H: 1}}},
77 }
78 ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))
79 // Window 1, its border, its veil (clipped to the output), window 2.
80 if len(ds) != 4 {
81 t.Fatalf("draws = %d, want 4", len(ds))
82 }
83 if v := ds[2].pc; v.rect != [4]int32{0, 0, 10, 20} || v.color != [4]float32{0, 0, 0, 0.5} {
84 t.Fatalf("veil = %+v", v)
85 }
86 if ds[3].pc.rect[0] != 21 {
87 t.Fatalf("window 2 not last: %+v", ds[3].pc)
88 }
89}
90
91// HDR blends linear light: the dim alpha is raised so reference white
92// lands where SDR's encoded dim puts it.
93func TestDimAlpha(t *testing.T) {
94 if got := dimAlpha(0.5, false); got != 0.5 {
95 t.Fatalf("sdr %v", got)
96 }
97 for _, a := range []float64{0, 0.3, 0.5, 1} {
98 got := dimAlpha(a, true)
99 if want := 1 - srgbToLinear(1-a); math.Abs(got-want) > 1e-12 || got < a {
100 t.Fatalf("hdr %v: %v want %v", a, got, want)
101 }
102 }
103 if dimAlpha(2, true) != 1 || dimAlpha(-1, true) != 0 {
104 t.Fatal("not clamped")
105 }
106}
107
108func TestSceneDimPartialDamage(t *testing.T) {
109 r := &Renderer{width: 80, height: 60}
110 s := ports.Scene{Seq: 8, Dim: 0.5, Windows: []ports.SceneWindow{
111 {ID: 1, Rect: ports.Rect{W: 40, H: 40}},
112 {ID: 2, Floating: true, Rect: ports.Rect{X: 50, W: 20, H: 20}},
113 }}
114 tg := &target{valid: true, sceneSeq: 8, windows: map[ports.WindowID]heldWindow{1: {seq: 1, rect: image.Rect(0, 0, 40, 40)}, 2: {seq: 1, rect: image.Rect(50, 0, 70, 20)}}}
115 contents := map[ports.WindowID]ports.SurfaceContent{1: {Seq: 2}, 2: {Seq: 1}}
116 dmg := newDamage(tg, s, image.Rect(0, 0, 80, 60))
117 ds := r.draws(s, contents, dmg)
118 dmg.finish()
119 if dmg.all() || dmg.area != image.Rect(0, 0, 40, 40) {
120 t.Fatalf("damage = %v (full=%v)", dmg.area, dmg.all())
121 }
122 ds = dmg.clip(ds)
123 if len(ds) != 2 || ds[1].pc.color != [4]float32{0, 0, 0, 0.5} || ds[1].pc.rect != [4]int32{0, 0, 40, 40} {
124 t.Fatalf("clipped draws = %+v", ds)
125 }
126}
127
128// The actual blend must darken already painted pixels without darkening
129// floats, window popups or top layers. Also exercise a retained target:
130// content damage beneath the veil must replay it over that region.
131func TestRendererDimBlendAndDamage(t *testing.T) {
132 r, err := New(64, 48)
133 if err != nil {
134 t.Skipf("Vulkan unavailable: %v", err)
135 }
136 defer r.Close()
137 s := ports.Scene{Seq: 10, Background: "#ffffff", Dim: 0.5,
138 Windows: []ports.SceneWindow{
139 {ID: 1, Rect: ports.Rect{W: 20, H: 20}},
140 {ID: 2, Floating: true, Rect: ports.Rect{X: 30, W: 10, H: 10}},
141 {ID: 3, Popup: true, Rect: ports.Rect{X: 42, W: 8, H: 8}},
142 },
143 Layers: []ports.SceneLayer{{ID: 4, Layer: ports.LayerTop, Rect: ports.Rect{X: 52, W: 8, H: 8}}},
144 }
145 white := solidContent(t, 20, 20, color.RGBA{255, 255, 255, 255})
146 white.Seq = 1
147 popup := solidContent(t, 8, 8, color.RGBA{255, 255, 255, 255})
148 top := solidContent(t, 8, 8, color.RGBA{255, 255, 255, 255})
149 contents := map[ports.WindowID]ports.SurfaceContent{1: white, 3: popup, 4: top}
150 check := func() {
151 t.Helper()
152 if err := render(r, s, contents); err != nil {
153 t.Fatal(err)
154 }
155 for _, tc := range []struct {
156 x, y int
157 want color.RGBA
158 }{
159 {60, 40, color.RGBA{128, 128, 128, 255}}, // background
160 {5, 5, color.RGBA{128, 128, 128, 255}}, // tile
161 {43, 5, color.RGBA{255, 255, 255, 255}}, // window popup
162 {53, 5, color.RGBA{255, 255, 255, 255}}, // top layer
163 } {
164 if got := readPixels(t, r).RGBAAt(tc.x, tc.y); !near(got, tc.want, 2) {
165 t.Errorf("pixel (%d,%d) = %v, want %v", tc.x, tc.y, got, tc.want)
166 }
167 }
168 }
169 check()
170 // Reuse the target with unchanged scene Seq, but changed tile content.
171 // Red becomes 128 after the black veil, not 255 (missing veil).
172 for y := 2; y < 5; y++ {
173 for x := 2; x < 5; x++ {
174 if _, err := white.SHM.File.WriteAt([]byte{0, 0, 255, 255}, int64((y*20+x)*4)); err != nil {
175 t.Fatal(err)
176 }
177 }
178 }
179 white.Seq, white.Version = 2, 2
180 white.DamageHistory = []ports.SeqDamage{{Seq: 1, Full: true}, {Seq: 2, Rects: []ports.Rect{{X: 2, Y: 2, W: 3, H: 3}}}}
181 contents[1] = white
182 before := r.redrawn
183 if err := render(r, s, contents); err != nil {
184 t.Fatal(err)
185 }
186 if r.redrawn-before >= 64*48 {
187 t.Fatal("content change unexpectedly triggered full redraw")
188 }
189 if got := readPixels(t, r).RGBAAt(3, 3); !near(got, color.RGBA{128, 0, 0, 255}, 2) {
190 t.Fatalf("damaged tile beneath veil = %v, want dark red", got)
191 }
192}
193
194// A per-window veil darkens that window and its border only, also when
195// its content alone is redrawn on a retained target.
196func TestRendererWindowDimBlendAndDamage(t *testing.T) {
197 r, err := New(64, 48)
198 if err != nil {
199 t.Skipf("Vulkan unavailable: %v", err)
200 }
201 defer r.Close()
202 s := ports.Scene{Seq: 20, Background: "#ffffff", Border: ports.Border{Width: 1, Inactive: "#ffffff"},
203 Windows: []ports.SceneWindow{
204 {ID: 1, Floating: true, Dim: 0.5, Inset: ports.SideAll, Rect: ports.Rect{W: 20, H: 20}},
205 {ID: 2, Floating: true, Inset: ports.SideAll, Rect: ports.Rect{X: 30, W: 20, H: 20}},
206 },
207 Separators: []ports.Separator{
208 {Window: 1, Rect: ports.Rect{W: 20, H: 1}},
209 {Window: 2, Rect: ports.Rect{X: 30, W: 20, H: 1}},
210 },
211 }
212 peek := solidContent(t, 18, 18, color.RGBA{255, 255, 255, 255})
213 peek.Seq = 1
214 sel := solidContent(t, 18, 18, color.RGBA{255, 255, 255, 255})
215 contents := map[ports.WindowID]ports.SurfaceContent{1: peek, 2: sel}
216 grey, white := color.RGBA{128, 128, 128, 255}, color.RGBA{255, 255, 255, 255}
217 if err := render(r, s, contents); err != nil {
218 t.Fatal(err)
219 }
220 for _, tc := range []struct {
221 x, y int
222 want color.RGBA
223 }{{5, 5, grey}, {5, 0, grey}, {35, 5, white}, {35, 0, white}, {25, 30, white}} {
224 if got := readPixels(t, r).RGBAAt(tc.x, tc.y); !near(got, tc.want, 2) {
225 t.Errorf("pixel (%d,%d) = %v, want %v", tc.x, tc.y, got, tc.want)
226 }
227 }
228 // Damage inside the peek replays its veil: red becomes dark red.
229 if _, err := peek.SHM.File.WriteAt([]byte{0, 0, 255, 255}, int64((3*18+3)*4)); err != nil {
230 t.Fatal(err)
231 }
232 peek.Seq, peek.Version = 2, 2
233 peek.DamageHistory = []ports.SeqDamage{{Seq: 1, Full: true}, {Seq: 2, Rects: []ports.Rect{{X: 3, Y: 3, W: 1, H: 1}}}}
234 contents[1] = peek
235 if err := render(r, s, contents); err != nil {
236 t.Fatal(err)
237 }
238 if got := readPixels(t, r).RGBAAt(4, 4); !near(got, color.RGBA{128, 0, 0, 255}, 2) {
239 t.Fatalf("damaged peek = %v, want dark red", got)
240 }
241}