1//go:build linux
2
3// Rect exercises presentation directly, without the immediate API.
4package main
5
6import (
7 "context"
8 "errors"
9 "flag"
10 "fmt"
11 "image"
12 "image/color"
13 "math"
14 "os"
15 "os/signal"
16 "path/filepath"
17 "runtime"
18 "strconv"
19 "strings"
20 "time"
21
22 "github.com/bnema/nefergui/internal/css"
23 "github.com/bnema/nefergui/internal/layout"
24 "github.com/bnema/nefergui/internal/presentation/session"
25 "github.com/bnema/nefergui/internal/presentation/vkdevice"
26 "github.com/bnema/nefergui/internal/render"
27 "github.com/bnema/nefergui/internal/text"
28)
29
30func main() {
31 var frames, captureFrame int
32 var size, textString string
33 var textScale float64
34 var transparent, static, list, styles, imageMode, changingImage bool
35 flag.BoolVar(&static, "static", false, "fixed rectangle and color for visual acceptance tests")
36 flag.BoolVar(&list, "list", false, "draw three overlapping instanced quads")
37 flag.BoolVar(&styles, "styles", false, "draw CSS-style rounded border, outline and shadow")
38 flag.BoolVar(&imageMode, "image", false, "draw two scaled checkerboard images")
39 flag.BoolVar(&changingImage, "changing-image", false, "image mode: pass a new image value each frame")
40 flag.StringVar(&textString, "text", "", "draw glyphs using the GPU atlas")
41 flag.Float64Var(&textScale, "scale", 1, "text mode: physical pixels per logical pixel")
42 flag.IntVar(&frames, "frames", 0, "frames before exit (0 = until close)")
43 flag.IntVar(&captureFrame, "capture-frame", 0, "static: committed frame count for capture request (0 = frames-8)")
44 flag.StringVar(&size, "size", "400x300", "initial logical window width x height")
45 flag.BoolVar(&transparent, "transparent", false, "use ARGB and premultiplied transparency")
46 flag.Parse()
47 width, height, err := parseSize(size)
48 if err == nil && static && frames > 0 {
49 if captureFrame == 0 {
50 captureFrame = frames - 8
51 }
52 if captureFrame < 1 || captureFrame >= frames {
53 err = fmt.Errorf("--capture-frame must be between 1 and --frames-1")
54 }
55 }
56 if err == nil && (textString != "" || imageMode || styles) && (textScale <= 0 || math.IsNaN(textScale) || math.IsInf(textScale, 0)) {
57 err = fmt.Errorf("invalid text scale")
58 }
59 if err != nil {
60 fmt.Fprintln(os.Stderr, err)
61 os.Exit(2)
62 }
63 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
64 defer stop()
65 ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
66 defer cancel()
67 s, err := session.Open("", width, height, transparent)
68 if err != nil {
69 fmt.Fprintln(os.Stderr, "presentation:", err)
70 os.Exit(1)
71 }
72 closed := false
73 defer func() {
74 if !closed {
75 _ = s.Close()
76 }
77 }()
78 fmt.Printf("presentation: ready main_device=%d render=%d:%d logical=%dx%d physical=%dx%d transparent=%v\n", s.Window.MainDevice, s.Device.Render.Major, s.Device.Render.Minor, s.Window.Width, s.Window.Height, s.Width, s.Height, transparent)
79 var requested time.Time
80 draw := func(i int) vkdevice.Rect {
81 if static {
82 // The render pass clears to transparent black (#00000000). XRGB
83 // displays that as opaque black. Straight #ff8000 at alpha 1
84 // reads back as #ff8000ff; at alpha 0.5 the shader writes
85 // premultiplied RGB (~128,64,0) and alpha ~128.
86 alpha := float32(1)
87 if transparent {
88 alpha = 0.5
89 }
90 return vkdevice.Rect{Bounds: [4]float32{0.25, 0.25, 0.5, 0.5}, Color: [4]float32{1, 128.0 / 255, 0, alpha}}
91 }
92 phase := float64(i) * 0.08
93 alpha := float32(1)
94 if transparent {
95 alpha = 0.5
96 }
97 return vkdevice.Rect{Bounds: [4]float32{float32(0.25 + 0.2*math.Sin(phase)), 0.25, 0.5, 0.5}, Color: [4]float32{float32(0.5 + 0.5*math.Sin(phase)), 0.25, 0.8, alpha}}
98 }
99 committed := func(committed int) error {
100 if static && committed == captureFrame && os.Getenv("NEFERGUI_DEBUG_DIR") != "" {
101 // Publish only a complete timestamp: the harness may observe this
102 // file while the client is still presenting subsequent frames.
103 dir := os.Getenv("NEFERGUI_DEBUG_DIR")
104 path := filepath.Join(dir, "capture-request")
105 tmp := path + ".tmp"
106 if err := os.WriteFile(tmp, []byte(strconv.FormatInt(time.Now().UnixNano(), 10)+"\n"), 0600); err != nil {
107 return err
108 }
109 if err := os.Rename(tmp, path); err != nil {
110 return err
111 }
112 requested = time.Now()
113 }
114 // Stay mapped long enough for the harness to see two later screenshots.
115 if static && committed == frames && !requested.IsZero() {
116 time.Sleep(time.Until(requested.Add(250 * time.Millisecond)))
117 }
118 return nil
119 }
120 var count int
121 if imageMode {
122 count, err = s.RunPreparedListWithCommit(ctx, frames, func(i int) (render.Frame, error) {
123 return s.Preparer.Prepare(imageCommands(i, changingImage, float64(s.Width)/textScale, float64(s.Height)/textScale), textScale, int(s.Width), int(s.Height))
124 }, committed)
125 } else if textString != "" {
126 _, source, _, _ := runtime.Caller(0)
127 catalog, e := text.Load(text.DirectorySource(filepath.Join(filepath.Dir(source), "..", "..", "testdata", "fonts")))
128 if e != nil {
129 fmt.Fprintln(os.Stderr, "text fonts:", e)
130 os.Exit(1)
131 }
132 measured, e := text.NewEngine(catalog).Measure(textString, text.Request{Families: []string{"Noto Sans"}, Size: 32}, 0)
133 if e != nil {
134 fmt.Fprintln(os.Stderr, "text measure:", e)
135 os.Exit(1)
136 }
137 cmd := layout.Command{Op: "text", Color: css.Color{R: 1, G: 1, B: 1, A: 1}, Opacity: 1}
138 for _, line := range measured.Lines {
139 for _, shaped := range line.Runs {
140 r := layout.Run{Face: shaped.Face, FaceID: shaped.Face.ID, Size: 32}
141 for _, g := range shaped.Glyphs {
142 r.Glyphs = append(r.Glyphs, layout.Glyph{ID: uint32(g.ID), X: 20 + g.X, Y: 40 + g.Y})
143 }
144 cmd.Runs = append(cmd.Runs, r)
145 }
146 }
147 count, err = s.RunPreparedListWithCommit(ctx, frames, func(int) (render.Frame, error) {
148 return s.Preparer.Prepare([]layout.Command{{Op: "rect", Rect: layout.Rect{W: float64(s.Width) / textScale, H: float64(s.Height) / textScale}, Color: css.Color{R: 17.0 / 255, G: 17.0 / 255, B: 17.0 / 255, A: 1}, Opacity: 1}, cmd}, textScale, int(s.Width), int(s.Height))
149 }, committed)
150 } else if styles {
151 count, err = s.RunPreparedListWithCommit(ctx, frames, func(int) (render.Frame, error) {
152 return s.Preparer.Prepare(styleCommands(), textScale, int(s.Width), int(s.Height))
153 }, committed)
154 } else if list {
155 count, err = s.RunListWithCommit(ctx, frames, func(int) []vkdevice.Instance { return listQuads(s.Width, s.Height) }, committed)
156 } else {
157 count, err = s.RunWithCommit(ctx, frames, draw, committed)
158 }
159 windowClosed := s.Window.Closed
160 closed = true
161 if cerr := s.Close(); cerr != nil && (err == nil || errors.Is(err, context.Canceled)) {
162 err = cerr
163 }
164 fmt.Printf("presentation: frames=%d closed=%v err=%v\n", count, windowClosed, err)
165 if err != nil && !errors.Is(err, context.Canceled) {
166 os.Exit(1)
167 }
168}
169
170// imageCommands mixes ordinary and image quads to exercise painter's order.
171func imageCommands(frame int, changing bool, width, height float64) []layout.Command {
172 checker := image.NewNRGBA(image.Rect(0, 0, 2, 2))
173 checker.SetNRGBA(0, 0, color.NRGBA{R: 255, A: 255})
174 checker.SetNRGBA(1, 0, color.NRGBA{G: 255, A: 255})
175 checker.SetNRGBA(0, 1, color.NRGBA{B: 255, A: 255})
176 checker.SetNRGBA(1, 1, color.NRGBA{R: 255, G: 255, A: 255})
177 if changing {
178 checker.Pix[0] = uint8(frame)
179 }
180 second := image.NewNRGBA(image.Rect(0, 0, 2, 2))
181 for y := 0; y < 2; y++ {
182 for x := 0; x < 2; x++ {
183 second.SetNRGBA(x, y, color.NRGBA{R: 255, G: 128, A: 255})
184 }
185 }
186 return []layout.Command{
187 {Op: "rect", Rect: layout.Rect{W: width, H: height}, Color: css.Color{R: 17.0 / 255, G: 17.0 / 255, B: 17.0 / 255, A: 1}, Opacity: 1},
188 {Op: "image", Rect: layout.Rect{X: 100, Y: 100, W: 64, H: 64}, Radii: [4]float64{8, 8, 8, 8}, Image: checker, Opacity: .5},
189 {Op: "rect", Rect: layout.Rect{X: 180, Y: 100, W: 20, H: 20}, Color: css.Color{R: 1, A: 1}, Opacity: 1},
190 {Op: "image", Rect: layout.Rect{X: 210, Y: 100, W: 64, H: 64}, Image: second, Opacity: 1},
191 }
192}
193
194func parseSize(size string) (int32, int32, error) {
195 w, h, ok := strings.Cut(size, "x")
196 if !ok {
197 return 0, 0, fmt.Errorf("invalid --size %q", size)
198 }
199 x, err := strconv.ParseInt(w, 10, 32)
200 if err != nil || x <= 0 {
201 return 0, 0, fmt.Errorf("invalid --size %q", size)
202 }
203 y, err := strconv.ParseInt(h, 10, 32)
204 if err != nil || y <= 0 {
205 return 0, 0, fmt.Errorf("invalid --size %q", size)
206 }
207 return int32(x), int32(y), nil
208}
209
210// listQuads uses physical pixels and keeps each probe away from antialiased edges.
211func listQuads(w, h int32) []vkdevice.Instance {
212 clip := [4]float32{0, 0, float32(w), float32(h)}
213 return []vkdevice.Instance{
214 {Bounds: [4]float32{100, 100, 200, 200}, Clip: clip, Radii: [4]float32{20, 20, 20, 20}, Color: [4]float32{1, 0, 0, 1}},
215 {Bounds: [4]float32{220, 120, 200, 200}, Clip: [4]float32{220, 120, 100, 200}, Color: [4]float32{0, 1, 0, 1}},
216 {Bounds: [4]float32{140, 180, 100, 100}, Clip: clip, Color: [4]float32{0, 0, 1, .5}},
217 }
218}
219
220// styleCommands uses logical coordinates; --scale converts them to physical.
221// Outset shadows precede the fill, while outlines follow the border.
222func styleCommands() []layout.Command {
223 box := layout.Rect{X: 120, Y: 120, W: 160, H: 120}
224 radius := [4]float64{20, 20, 20, 20}
225 sh := css.Shadow{X: css.Length{Value: 0, Unit: "px"}, Y: css.Length{Value: 8, Unit: "px"}, Blur: css.Length{Value: 12, Unit: "px"}, Color: css.Color{A: .9}}
226 return []layout.Command{
227 {Op: "rect", Rect: layout.Rect{W: 800, H: 600}, Color: css.Color{R: 17.0 / 255, G: 17.0 / 255, B: 17.0 / 255, A: 1}, Opacity: 1},
228 {Op: "shadow", Rect: box, Radii: radius, Shadow: &sh, Opacity: 1},
229 {Op: "rect", Rect: box, Radii: radius, Color: css.Color{R: 1, G: 1, B: 1, A: 1}, Opacity: 1},
230 {Op: "border", Rect: box, Radii: radius, Widths: layout.Edges{Top: 8, Right: 8, Bottom: 8, Left: 8}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Right: css.Color{G: 1, A: 1}, Bottom: css.Color{B: 1, A: 1}, Left: css.Color{R: 1, G: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1},
231 // Zero-width sides retain deliberately contrasting colors to catch
232 // accidental corner contributions from unpainted edges.
233 {Op: "border", Rect: layout.Rect{X: 300, Y: 40, W: 80, H: 50}, Widths: layout.Edges{Top: 8}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Left: css.Color{G: 1, A: 1}, Right: css.Color{G: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1},
234 {Op: "border", Rect: layout.Rect{X: 300, Y: 240, W: 80, H: 50}, Widths: layout.Edges{Top: 8, Left: 2}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Left: css.Color{B: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1},
235 // Layout has already expanded this rect by the 4px outline offset.
236 {Op: "outline", Rect: layout.Rect{X: 116, Y: 116, W: 168, H: 128}, Radii: radius, Widths: layout.Edges{Top: 3, Right: 3, Bottom: 3, Left: 3}, Color: css.Color{R: 1, G: 0, B: 1, A: 1}, Opacity: 1},
237 }
238}