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// DimBehind puts the veil first, under every window, floats included, and
68// only once.
69func TestSceneDimBehind(t *testing.T) {
70 r := &Renderer{width: 80, height: 60}
71 s := ports.Scene{Dim: 0.3, DimBehind: true, Windows: []ports.SceneWindow{
72 {ID: 1, Preview: 0.5, Rect: ports.Rect{X: 10, W: 20, H: 20}},
73 {ID: 2, Floating: true, Rect: ports.Rect{X: 40, W: 20, H: 20}},
74 }}
75 ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))
76 if len(ds) != 3 { // veil, two windows
77 t.Fatalf("draws = %d, want 3", len(ds))
78 }
79 if v := ds[0].pc; v.rect != [4]int32{0, 0, 80, 60} || v.color != [4]float32{0, 0, 0, 0.3} {
80 t.Fatalf("veil = %+v", v)
81 }
82 if ds[1].pc.rect[0] != 10 || ds[2].pc.rect[0] != 40 {
83 t.Fatalf("windows not over the veil: %+v", ds)
84 }
85}
86
87// A dimmed window gets a veil over its own rect, border included, right
88// after it: windows drawn later stay bright.
89func TestSceneWindowDim(t *testing.T) {
90 r := &Renderer{width: 80, height: 60}
91 s := ports.Scene{Border: ports.Border{Width: 1, Inactive: "#ffffff"},
92 Windows: []ports.SceneWindow{
93 {ID: 1, Floating: true, Dim: 0.5, Inset: ports.SideAll, Rect: ports.Rect{X: -10, W: 20, H: 20}},
94 {ID: 2, Floating: true, Inset: ports.SideAll, Rect: ports.Rect{X: 20, W: 20, H: 20}},
95 },
96 Separators: []ports.Separator{{Window: 1, Rect: ports.Rect{X: -10, W: 20, H: 1}}},
97 }
98 ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))
99 // Window 1, its border, its veil (clipped to the output), window 2.
100 if len(ds) != 4 {
101 t.Fatalf("draws = %d, want 4", len(ds))
102 }
103 if v := ds[2].pc; v.rect != [4]int32{0, 0, 10, 20} || v.color != [4]float32{0, 0, 0, 0.5} {
104 t.Fatalf("veil = %+v", v)
105 }
106 if ds[3].pc.rect[0] != 21 {
107 t.Fatalf("window 2 not last: %+v", ds[3].pc)
108 }
109}
110
111// HDR blends linear light: the dim alpha is raised so reference white
112// lands where SDR's encoded dim puts it.
113func TestDimAlpha(t *testing.T) {
114 if got := dimAlpha(0.5, false); got != 0.5 {
115 t.Fatalf("sdr %v", got)
116 }
117 for _, a := range []float64{0, 0.3, 0.5, 1} {
118 got := dimAlpha(a, true)
119 if want := 1 - srgbToLinear(1-a); math.Abs(got-want) > 1e-12 || got < a {
120 t.Fatalf("hdr %v: %v want %v", a, got, want)
121 }
122 }
123 if dimAlpha(2, true) != 1 || dimAlpha(-1, true) != 0 {
124 t.Fatal("not clamped")
125 }
126}
127
128func TestSceneDimPartialDamage(t *testing.T) {
129 r := &Renderer{width: 80, height: 60}
130 s := ports.Scene{Seq: 8, Dim: 0.5, Windows: []ports.SceneWindow{
131 {ID: 1, Rect: ports.Rect{W: 40, H: 40}},
132 {ID: 2, Floating: true, Rect: ports.Rect{X: 50, W: 20, H: 20}},
133 }}
134 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)}}}
135 contents := map[ports.WindowID]ports.SurfaceContent{1: {Seq: 2}, 2: {Seq: 1}}
136 dmg := newDamage(tg, s, image.Rect(0, 0, 80, 60))
137 ds := r.draws(s, contents, dmg)
138 dmg.finish()
139 if dmg.all() || dmg.area != image.Rect(0, 0, 40, 40) {
140 t.Fatalf("damage = %v (full=%v)", dmg.area, dmg.all())
141 }
142 ds = dmg.clip(ds)
143 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} {
144 t.Fatalf("clipped draws = %+v", ds)
145 }
146}
147
148// The actual blend must darken already painted pixels without darkening
149// floats, window popups or top layers. Also exercise a retained target:
150// content damage beneath the veil must replay it over that region.
151func TestRendererDimBlendAndDamage(t *testing.T) {
152 r, err := New(64, 48)
153 if err != nil {
154 t.Skipf("Vulkan unavailable: %v", err)
155 }
156 defer r.Close()
157 s := ports.Scene{Seq: 10, Background: "#ffffff", Dim: 0.5,
158 Windows: []ports.SceneWindow{
159 {ID: 1, Rect: ports.Rect{W: 20, H: 20}},
160 {ID: 2, Floating: true, Rect: ports.Rect{X: 30, W: 10, H: 10}},
161 {ID: 3, Popup: true, Rect: ports.Rect{X: 42, W: 8, H: 8}},
162 },
163 Layers: []ports.SceneLayer{{ID: 4, Layer: ports.LayerTop, Rect: ports.Rect{X: 52, W: 8, H: 8}}},
164 }
165 white := solidContent(t, 20, 20, color.RGBA{255, 255, 255, 255})
166 white.Seq = 1
167 popup := solidContent(t, 8, 8, color.RGBA{255, 255, 255, 255})
168 top := solidContent(t, 8, 8, color.RGBA{255, 255, 255, 255})
169 contents := map[ports.WindowID]ports.SurfaceContent{1: white, 3: popup, 4: top}
170 check := func() {
171 t.Helper()
172 if err := render(r, s, contents); err != nil {
173 t.Fatal(err)
174 }
175 for _, tc := range []struct {
176 x, y int
177 want color.RGBA
178 }{
179 {60, 40, color.RGBA{128, 128, 128, 255}}, // background
180 {5, 5, color.RGBA{128, 128, 128, 255}}, // tile
181 {43, 5, color.RGBA{255, 255, 255, 255}}, // window popup
182 {53, 5, color.RGBA{255, 255, 255, 255}}, // top layer
183 } {
184 if got := readPixels(t, r).RGBAAt(tc.x, tc.y); !near(got, tc.want, 2) {
185 t.Errorf("pixel (%d,%d) = %v, want %v", tc.x, tc.y, got, tc.want)
186 }
187 }
188 }
189 check()
190 // Reuse the target with unchanged scene Seq, but changed tile content.
191 // Red becomes 128 after the black veil, not 255 (missing veil).
192 for y := 2; y < 5; y++ {
193 for x := 2; x < 5; x++ {
194 if _, err := white.SHM.File.WriteAt([]byte{0, 0, 255, 255}, int64((y*20+x)*4)); err != nil {
195 t.Fatal(err)
196 }
197 }
198 }
199 white.Seq, white.Version = 2, 2
200 white.DamageHistory = []ports.SeqDamage{{Seq: 1, Full: true}, {Seq: 2, Rects: []ports.Rect{{X: 2, Y: 2, W: 3, H: 3}}}}
201 contents[1] = white
202 before := r.redrawn
203 if err := render(r, s, contents); err != nil {
204 t.Fatal(err)
205 }
206 if r.redrawn-before >= 64*48 {
207 t.Fatal("content change unexpectedly triggered full redraw")
208 }
209 if got := readPixels(t, r).RGBAAt(3, 3); !near(got, color.RGBA{128, 0, 0, 255}, 2) {
210 t.Fatalf("damaged tile beneath veil = %v, want dark red", got)
211 }
212}
213
214// A per-window veil darkens that window and its border only, also when
215// its content alone is redrawn on a retained target.
216func TestRendererWindowDimBlendAndDamage(t *testing.T) {
217 r, err := New(64, 48)
218 if err != nil {
219 t.Skipf("Vulkan unavailable: %v", err)
220 }
221 defer r.Close()
222 s := ports.Scene{Seq: 20, Background: "#ffffff", Border: ports.Border{Width: 1, Inactive: "#ffffff"},
223 Windows: []ports.SceneWindow{
224 {ID: 1, Floating: true, Dim: 0.5, Inset: ports.SideAll, Rect: ports.Rect{W: 20, H: 20}},
225 {ID: 2, Floating: true, Inset: ports.SideAll, Rect: ports.Rect{X: 30, W: 20, H: 20}},
226 },
227 Separators: []ports.Separator{
228 {Window: 1, Rect: ports.Rect{W: 20, H: 1}},
229 {Window: 2, Rect: ports.Rect{X: 30, W: 20, H: 1}},
230 },
231 }
232 peek := solidContent(t, 18, 18, color.RGBA{255, 255, 255, 255})
233 peek.Seq = 1
234 sel := solidContent(t, 18, 18, color.RGBA{255, 255, 255, 255})
235 contents := map[ports.WindowID]ports.SurfaceContent{1: peek, 2: sel}
236 grey, white := color.RGBA{128, 128, 128, 255}, color.RGBA{255, 255, 255, 255}
237 if err := render(r, s, contents); err != nil {
238 t.Fatal(err)
239 }
240 for _, tc := range []struct {
241 x, y int
242 want color.RGBA
243 }{{5, 5, grey}, {5, 0, grey}, {35, 5, white}, {35, 0, white}, {25, 30, white}} {
244 if got := readPixels(t, r).RGBAAt(tc.x, tc.y); !near(got, tc.want, 2) {
245 t.Errorf("pixel (%d,%d) = %v, want %v", tc.x, tc.y, got, tc.want)
246 }
247 }
248 // Damage inside the peek replays its veil: red becomes dark red.
249 if _, err := peek.SHM.File.WriteAt([]byte{0, 0, 255, 255}, int64((3*18+3)*4)); err != nil {
250 t.Fatal(err)
251 }
252 peek.Seq, peek.Version = 2, 2
253 peek.DamageHistory = []ports.SeqDamage{{Seq: 1, Full: true}, {Seq: 2, Rects: []ports.Rect{{X: 3, Y: 3, W: 1, H: 1}}}}
254 contents[1] = peek
255 if err := render(r, s, contents); err != nil {
256 t.Fatal(err)
257 }
258 if got := readPixels(t, r).RGBAAt(4, 4); !near(got, color.RGBA{128, 0, 0, 255}, 2) {
259 t.Fatalf("damaged peek = %v, want dark red", got)
260 }
261}