xbintsc 0.5.41 → 0.5.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/src/cli/help.d.ts +1 -1
  2. package/dist/src/driver/version.d.ts +1 -1
  3. package/dist/src/driver/version.js +1 -1
  4. package/dist/tests/e2e/gui-data-url.test.d.ts +10 -0
  5. package/dist/tests/e2e/gui-data-url.test.js +127 -0
  6. package/dist/tests/e2e/gui-data-url.test.js.map +1 -0
  7. package/dist/tests/e2e/gui-effects.test.js +144 -4
  8. package/dist/tests/e2e/gui-effects.test.js.map +1 -1
  9. package/dist/tests/e2e/gui-paint.test.js +77 -0
  10. package/dist/tests/e2e/gui-paint.test.js.map +1 -1
  11. package/doc/gui/api.md +1 -1
  12. package/doc/gui/canvas.md +6 -2
  13. package/doc/gui/known-issues.md +18 -10
  14. package/doc/gui/media.md +11 -2
  15. package/doc/gui/rendering.md +48 -19
  16. package/doc/gui.md +12 -8
  17. package/doc/zh-CN/gui/api.md +1 -1
  18. package/doc/zh-CN/gui/canvas.md +6 -4
  19. package/doc/zh-CN/gui/known-issues.md +12 -8
  20. package/doc/zh-CN/gui/media.md +9 -2
  21. package/doc/zh-CN/gui/rendering.md +37 -15
  22. package/doc/zh-CN/gui.md +7 -6
  23. package/package.json +7 -7
  24. package/runtime/ext_gui/dom_api_canvas.cpp +42 -24
  25. package/runtime/ext_gui/gradient_values.cpp +44 -48
  26. package/runtime/ext_gui/gui_events.cpp +16 -13
  27. package/runtime/ext_gui/image.cpp +180 -25
  28. package/runtime/ext_gui/image.h +6 -4
  29. package/runtime/ext_gui/paint.cpp +69 -4
  30. package/runtime/ext_gui/paint.h +57 -1
  31. package/runtime/ext_gui/paint_background.cpp +197 -41
  32. package/runtime/ext_gui/paint_effects.cpp +2 -1
  33. package/runtime/ext_gui/paint_internal.h +5 -1
  34. package/runtime/ext_gui/renderer.cpp +81 -13
  35. package/runtime/ext_gui/renderer.h +19 -5
  36. package/runtime/ext_gui/renderer_gradient.cpp +156 -0
  37. package/runtime/ext_gui/renderer_image.cpp +6 -1
  38. package/runtime/ext_gui/renderer_internal.h +48 -0
  39. package/runtime/ext_gui/renderer_layer.cpp +552 -0
  40. package/runtime/ext_gui/style.h +29 -1
  41. package/runtime/ext_gui/style_print.cpp +10 -2
  42. package/runtime/ext_gui/style_values.cpp +10 -0
  43. package/src/driver/version.ts +1 -1
@@ -471,23 +471,26 @@ void xt_gui_render_window(XtGuiWindow *win) {
471
471
  }
472
472
  win->present_deferred = 0;
473
473
  xtgui::g_perf_frame.presents += 1;
474
- SDL_GPUColorTargetInfo target;
475
- memset(&target, 0, sizeof(target));
476
- target.texture = swapchain;
477
- target.clear_color.r = win->background[0];
478
- target.clear_color.g = win->background[1];
479
- target.clear_color.b = win->background[2];
480
- target.clear_color.a = win->background[3];
481
- target.load_op = SDL_GPU_LOADOP_CLEAR;
482
- target.store_op = SDL_GPU_STOREOP_STORE;
483
- SDL_GPURenderPass *pass = SDL_BeginGPURenderPass(cmd, &target, 1, NULL);
484
474
  if (can_paint) {
485
475
  float density = SDL_GetWindowPixelDensity(win->window);
486
476
  if (density <= 0.0f) density = 1.0f;
487
- xt_gui_renderer_draw(cmd, pass, &win->geometry, &win->text_geometry,
488
- &win->image_geometry, viewport_width, viewport_height, density);
477
+ xt_gui_renderer_draw(cmd, swapchain, win->background, &win->geometry,
478
+ &win->text_geometry, &win->image_geometry,
479
+ win->display_list, viewport_width, viewport_height, density);
480
+ } else {
481
+ /* No renderer (no usable shader backend): still clear the window. */
482
+ SDL_GPUColorTargetInfo target;
483
+ memset(&target, 0, sizeof(target));
484
+ target.texture = swapchain;
485
+ target.clear_color.r = win->background[0];
486
+ target.clear_color.g = win->background[1];
487
+ target.clear_color.b = win->background[2];
488
+ target.clear_color.a = win->background[3];
489
+ target.load_op = SDL_GPU_LOADOP_CLEAR;
490
+ target.store_op = SDL_GPU_STOREOP_STORE;
491
+ SDL_GPURenderPass *pass = SDL_BeginGPURenderPass(cmd, &target, 1, NULL);
492
+ if (pass != NULL) SDL_EndGPURenderPass(pass);
489
493
  }
490
- SDL_EndGPURenderPass(pass);
491
494
  firstFrame = !win->ready;
492
495
  win->ready = 1;
493
496
  } else {
@@ -64,13 +64,84 @@ std::string normalizePath(const std::string &input) {
64
64
  return out;
65
65
  }
66
66
 
67
- /** Decode a `data:` URL's base64 payload (the only encoding the engine emits
68
- * and the common one a page assigns). */
69
- bool decodeDataUrl(const std::string &url, std::vector<unsigned char> *out) {
70
- const size_t comma = url.find(',');
71
- if (comma == std::string::npos) return false;
72
- const std::string header = url.substr(0, comma);
73
- if (header.find(";base64") == std::string::npos) return false;
67
+ /** One classified `data:` URL: whether its payload is SVG and the intrinsic
68
+ * size parsed from it (or from the raster header). Classification happens once
69
+ * per URL and is cached, so the renderer's vector probe does not decode the
70
+ * payload on every frame. */
71
+ struct DataUrlEntry {
72
+ bool svg = false;
73
+ SizeEntry size;
74
+ };
75
+
76
+ std::unordered_map<std::string, DataUrlEntry> &dataUrlCache() {
77
+ static std::unordered_map<std::string, DataUrlEntry> cache;
78
+ return cache;
79
+ }
80
+
81
+ std::string lowercase(std::string value) {
82
+ for (char &c : value) c = (char)std::tolower((unsigned char)c);
83
+ return value;
84
+ }
85
+
86
+ std::string trim(const std::string &text) {
87
+ size_t start = 0;
88
+ while (start < text.size() && std::isspace((unsigned char)text[start])) start++;
89
+ size_t end = text.size();
90
+ while (end > start && std::isspace((unsigned char)text[end - 1])) end--;
91
+ return text.substr(start, end - start);
92
+ }
93
+
94
+ /** Decode `%XX` escapes. `+` stays a literal plus: a data URL uses URL
95
+ * percent-encoding, not form encoding. A malformed escape is left verbatim. */
96
+ std::string percentDecode(const std::string &text) {
97
+ std::string out;
98
+ out.reserve(text.size());
99
+ for (size_t i = 0; i < text.size(); i++) {
100
+ if (text[i] == '%' && i + 2 < text.size() && std::isxdigit((unsigned char)text[i + 1]) &&
101
+ std::isxdigit((unsigned char)text[i + 2])) {
102
+ auto hex = [](char c) -> int {
103
+ if (c >= '0' && c <= '9') return c - '0';
104
+ return std::tolower((unsigned char)c) - 'a' + 10;
105
+ };
106
+ out.push_back((char)(hex(text[i + 1]) * 16 + hex(text[i + 2])));
107
+ i += 2;
108
+ } else {
109
+ out.push_back(text[i]);
110
+ }
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /** True when one of the header's `;`-separated parameters is `base64`
116
+ * (case-insensitive, as the data-URL grammar specifies). */
117
+ bool headerHasBase64(const std::string &header) {
118
+ size_t start = 0;
119
+ while (start <= header.size()) {
120
+ size_t end = header.find(';', start);
121
+ if (end == std::string::npos) end = header.size();
122
+ if (lowercase(trim(header.substr(start, end - start))) == "base64") return true;
123
+ if (end == header.size()) break;
124
+ start = end + 1;
125
+ }
126
+ return false;
127
+ }
128
+
129
+ /** The MIME type part of a data URL's header, lowercased. */
130
+ std::string headerMime(const std::string &header) {
131
+ size_t semi = header.find(';');
132
+ return lowercase(trim(header.substr(0, semi)));
133
+ }
134
+
135
+ /** A cheap content sniff for a data URL whose MIME type is generic: look for
136
+ * `<svg` in the first chunk (after an XML declaration, doctype or comment). */
137
+ bool bytesLookLikeSvg(const std::vector<unsigned char> &bytes) {
138
+ if (bytes.empty()) return false;
139
+ const size_t limit = bytes.size() < 1024 ? bytes.size() : 1024;
140
+ const std::string prefix(reinterpret_cast<const char *>(bytes.data()), limit);
141
+ return prefix.find("<svg") != std::string::npos;
142
+ }
143
+
144
+ bool base64Decode(const std::string &text, std::vector<unsigned char> *out) {
74
145
  static const signed char kDecode[128] = {
75
146
  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
76
147
  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
@@ -81,12 +152,11 @@ bool decodeDataUrl(const std::string &url, std::vector<unsigned char> *out) {
81
152
  -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
82
153
  41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1};
83
154
  out->clear();
84
- out->reserve((url.size() - comma) * 3 / 4 + 4);
155
+ out->reserve(text.size() * 3 / 4 + 4);
85
156
  uint32_t accumulator = 0;
86
157
  int bits = 0;
87
- for (size_t i = comma + 1; i < url.size(); i++) {
88
- const unsigned char c = (unsigned char)url[i];
89
- if (c == '=' || c == '\n' || c == '\r' || c == ' ') continue;
158
+ for (unsigned char c : text) {
159
+ if (c == '=' || std::isspace(c)) continue;
90
160
  if (c >= 128) return false;
91
161
  const int value = kDecode[c];
92
162
  if (value < 0) return false;
@@ -100,33 +170,107 @@ bool decodeDataUrl(const std::string &url, std::vector<unsigned char> *out) {
100
170
  return !out->empty();
101
171
  }
102
172
 
173
+ /** Decode a `data:` URL's payload. Both `;base64` and percent-encoded (or
174
+ * plain) bodies are accepted; in the base64 case the body is percent-decoded
175
+ * first, which is what a browser does. `mime` receives the lowercased header
176
+ * type (empty when the URL omits one). */
177
+ bool decodeDataUrl(const std::string &url, std::vector<unsigned char> *out, std::string *mime) {
178
+ const size_t comma = url.find(',');
179
+ if (comma == std::string::npos || comma < 5) return false;
180
+ const std::string header = url.substr(5, comma - 5);
181
+ if (mime != nullptr) *mime = headerMime(header);
182
+ const std::string body = percentDecode(url.substr(comma + 1));
183
+ if (headerHasBase64(header)) return base64Decode(body, out);
184
+ out->assign(body.begin(), body.end());
185
+ return !out->empty();
186
+ }
187
+
188
+ /** Classify (`SVG` vs raster) and measure a `data:` URL once. */
189
+ DataUrlEntry &dataUrlEntry(const std::string &url) {
190
+ auto &cache = dataUrlCache();
191
+ auto found = cache.find(url);
192
+ if (found != cache.end()) return found->second;
193
+
194
+ DataUrlEntry entry;
195
+ std::vector<unsigned char> bytes;
196
+ std::string mime;
197
+ if (decodeDataUrl(url, &bytes, &mime)) {
198
+ /* Trust an explicit raster MIME type; sniff only when the type is absent
199
+ * or generic, so a PNG that happens to contain `<svg` in a text chunk is
200
+ * not mistaken for vector art. */
201
+ const bool generic_mime =
202
+ mime.empty() || mime == "text/plain" || mime == "application/octet-stream";
203
+ entry.svg = mime == "image/svg+xml" || (generic_mime && bytesLookLikeSvg(bytes));
204
+ if (entry.svg) {
205
+ const std::string markup(bytes.begin(), bytes.end());
206
+ int width = 0;
207
+ int height = 0;
208
+ if (xt_svg_markup_size(markup, &width, &height)) {
209
+ entry.size.valid = true;
210
+ entry.size.width = width;
211
+ entry.size.height = height;
212
+ }
213
+ } else {
214
+ int width = 0;
215
+ int height = 0;
216
+ int channels = 0;
217
+ if (stbi_info_from_memory(bytes.data(), (int)bytes.size(), &width, &height, &channels) != 0) {
218
+ entry.size.valid = true;
219
+ entry.size.width = width;
220
+ entry.size.height = height;
221
+ }
222
+ }
223
+ }
224
+ return cache.emplace(url, std::move(entry)).first->second;
225
+ }
226
+
103
227
  } // namespace
104
228
 
105
229
  const Image *xt_image_load(const std::string &raw_path, int target_width, int target_height) {
106
230
  /* A `data:` URL carries its bytes inline (a canvas used as a CSS
107
231
  * `background-image` reaches this path through `toDataURL()`). */
108
232
  if (raw_path.rfind("data:", 0) == 0) {
233
+ const DataUrlEntry &info = dataUrlEntry(raw_path);
234
+ int width = target_width;
235
+ int height = target_height;
236
+ if (info.svg && width <= 0 && height <= 0 && info.size.valid) {
237
+ width = info.size.width;
238
+ height = info.size.height;
239
+ }
240
+ /* A vector source is rasterised at the drawn size, so that size is part of
241
+ * its cache identity. A raster data URL decodes once at its intrinsic
242
+ * resolution. */
243
+ std::string key = raw_path;
244
+ if (info.svg) {
245
+ key = "data-svg:" + std::to_string(width) + "x" + std::to_string(height) + ":" + raw_path;
246
+ }
109
247
  auto &cache = imageCache();
110
- auto found = cache.find(raw_path);
248
+ auto found = cache.find(key);
111
249
  if (found != cache.end()) {
112
250
  return found->second.width > 0 ? &found->second : nullptr;
113
251
  }
114
252
  Image image;
115
253
  std::vector<unsigned char> bytes;
116
- if (decodeDataUrl(raw_path, &bytes)) {
117
- int width = 0;
118
- int height = 0;
119
- int channels = 0;
120
- unsigned char *data = stbi_load_from_memory(bytes.data(), (int)bytes.size(), &width, &height,
121
- &channels, 4);
122
- if (data != nullptr) {
123
- image.width = width;
124
- image.height = height;
125
- image.pixels.assign(data, data + (size_t)width * (size_t)height * 4);
126
- stbi_image_free(data);
254
+ std::string mime;
255
+ if (decodeDataUrl(raw_path, &bytes, &mime)) {
256
+ if (info.svg) {
257
+ const std::string markup(bytes.begin(), bytes.end());
258
+ xt_svg_markup_render(markup, width, height, &image);
259
+ } else {
260
+ int decoded_width = 0;
261
+ int decoded_height = 0;
262
+ int channels = 0;
263
+ unsigned char *data = stbi_load_from_memory(bytes.data(), (int)bytes.size(),
264
+ &decoded_width, &decoded_height, &channels, 4);
265
+ if (data != nullptr) {
266
+ image.width = decoded_width;
267
+ image.height = decoded_height;
268
+ image.pixels.assign(data, data + (size_t)decoded_width * (size_t)decoded_height * 4);
269
+ stbi_image_free(data);
270
+ }
127
271
  }
128
272
  }
129
- auto inserted = cache.emplace(raw_path, std::move(image));
273
+ auto inserted = cache.emplace(key, std::move(image));
130
274
  return inserted.first->second.width > 0 ? &inserted.first->second : nullptr;
131
275
  }
132
276
  std::string path = normalizePath(raw_path);
@@ -165,6 +309,13 @@ const Image *xt_image_load(const std::string &raw_path, int target_width, int ta
165
309
 
166
310
  bool xt_image_size(const std::string &raw_path, int *width, int *height) {
167
311
  if (raw_path.rfind("data:", 0) == 0) {
312
+ const DataUrlEntry &info = dataUrlEntry(raw_path);
313
+ if (info.size.valid) {
314
+ if (width != nullptr) *width = info.size.width;
315
+ if (height != nullptr) *height = info.size.height;
316
+ return true;
317
+ }
318
+ if (info.svg) return false;
168
319
  const Image *image = xt_image_load(raw_path);
169
320
  if (image == nullptr) return false;
170
321
  if (width != nullptr) *width = image->width;
@@ -202,11 +353,15 @@ bool xt_image_decode(const unsigned char *data, size_t size, Image *out) {
202
353
  return true;
203
354
  }
204
355
 
205
- bool xt_image_is_vector(const std::string &path) { return xt_svg_is_svg(normalizePath(path)); }
356
+ bool xt_image_is_vector(const std::string &path) {
357
+ if (path.rfind("data:", 0) == 0) return dataUrlEntry(path).svg;
358
+ return xt_svg_is_svg(normalizePath(path));
359
+ }
206
360
 
207
361
  void xt_image_shutdown() {
208
362
  imageCache().clear();
209
363
  sizeCache().clear();
364
+ dataUrlCache().clear();
210
365
  xt_svg_shutdown();
211
366
  }
212
367
 
@@ -23,9 +23,11 @@ struct Image {
23
23
  std::vector<unsigned char> pixels;
24
24
  };
25
25
 
26
- /** Decode an image file (PNG/JPEG/BMP/GIF/TGA or SVG) to RGBA8. Returns
27
- * nullptr on failure. Results (including failures) are cached by path. A
28
- * `file://` prefix and URL percent-encoding are handled.
26
+ /** Decode an image source (PNG/JPEG/BMP/GIF/TGA or SVG, from a file path or a
27
+ * `data:` URL) to RGBA8. Returns nullptr on failure. Results (including
28
+ * failures) are cached. A `file://` prefix and URL percent-encoding are
29
+ * handled for paths; a `data:` URL accepts a `;base64` or percent-encoded
30
+ * payload and is classified by MIME type/content (SVG or raster).
29
31
  *
30
32
  * `target_width`/`target_height` are the pixel size the image will be drawn at.
31
33
  * Raster formats ignore them (they decode at their intrinsic resolution), but
@@ -40,7 +42,7 @@ const Image *xt_image_load(const std::string &path, int target_width = 0, int ta
40
42
  * viewBox). Returns false when the file cannot be read/parsed. Cached. */
41
43
  bool xt_image_size(const std::string &path, int *width, int *height);
42
44
 
43
- /** Decode an in-memory buffer to RGBA8 (e.g. a future `data:` URI). */
45
+ /** Decode an in-memory raster buffer to RGBA8 (used by the app icon). */
44
46
  bool xt_image_decode(const unsigned char *data, size_t size, Image *out);
45
47
 
46
48
  /** True when `path` is a vector source (SVG) that is rasterised on demand
@@ -9,6 +9,7 @@
9
9
  #include "paint.h"
10
10
 
11
11
  #include <algorithm>
12
+ #include <cstddef>
12
13
  #include <cstdio>
13
14
  #include <utility>
14
15
 
@@ -56,6 +57,9 @@ int zIndexOf(const LayoutBox *box) {
56
57
  void paintBox(const LayoutBox *box, DisplayList &out, const PaintClip &clip,
57
58
  const paint_detail::FilterChain &parentChain, float viewportWidth,
58
59
  float viewportHeight);
60
+ void paintBoxInner(const LayoutBox *box, DisplayList &out, const PaintClip &clip,
61
+ const paint_detail::FilterChain &parentChain, float viewportWidth,
62
+ float viewportHeight);
59
63
 
60
64
  /** The clip a box imposes on its content: its padding box, intersected with the
61
65
  * clip already in effect. Only `overflow: hidden/scroll/auto` clips. A
@@ -149,9 +153,9 @@ void addTextRuns(const LayoutBox *box, const XtStyle &style, DisplayList &out,
149
153
  }
150
154
  }
151
155
 
152
- void paintBox(const LayoutBox *box, DisplayList &out, const PaintClip &clip,
153
- const paint_detail::FilterChain &parentChain, float viewportWidth,
154
- float viewportHeight) {
156
+ void paintBoxInner(const LayoutBox *box, DisplayList &out, const PaintClip &clip,
157
+ const paint_detail::FilterChain &parentChain, float viewportWidth,
158
+ float viewportHeight) {
155
159
  if (box == nullptr) return;
156
160
  const XtStyle *style = box->style;
157
161
 
@@ -166,6 +170,11 @@ void paintBox(const LayoutBox *box, DisplayList &out, const PaintClip &clip,
166
170
  chain = paint_detail::extendFilter(parentChain, style->filters, *style, viewportWidth,
167
171
  viewportHeight);
168
172
  }
173
+ /* `opacity` is not inherited, but its effect is: every shape painted by the
174
+ * box or its descendants gets its alpha multiplied by the product down the
175
+ * chain. This makes fractional opacity visible instead of treating anything
176
+ * above zero as fully opaque. */
177
+ if (style != nullptr && !box->is_text) chain.opacity *= style->opacity;
169
178
 
170
179
  PaintClip own = clip;
171
180
  if (style != nullptr && box->display != Display::None && !box->is_text) {
@@ -252,6 +261,32 @@ void paintBox(const LayoutBox *box, DisplayList &out, const PaintClip &clip,
252
261
  paintChildren(box, out, clipForChildren(box, own), childChain, viewportWidth, viewportHeight);
253
262
  }
254
263
 
264
+ /** Paint one box, wrapping it in a `mix-blend-mode` group when it declares one.
265
+ * The group's ops are bounded by `LayerBegin`/`LayerEnd`, so the renderer can
266
+ * draw the subtree to an offscreen texture and blend it over the backdrop as
267
+ * one unit (overlapping children must not blend twice). */
268
+ void paintBox(const LayoutBox *box, DisplayList &out, const PaintClip &clip,
269
+ const paint_detail::FilterChain &parentChain, float viewportWidth,
270
+ float viewportHeight) {
271
+ if (box == nullptr) return;
272
+ const XtStyle *style = box->style;
273
+ const bool group = style != nullptr && !box->is_text && box->display != Display::None &&
274
+ style->mix_blend_mode != MixBlendMode::Normal;
275
+ if (!group) {
276
+ paintBoxInner(box, out, clip, parentChain, viewportWidth, viewportHeight);
277
+ return;
278
+ }
279
+ size_t first = out.ops.size();
280
+ paintBoxInner(box, out, clip, parentChain, viewportWidth, viewportHeight);
281
+ if (out.ops.size() == first) return; /* nothing painted (e.g. opacity: 0) */
282
+ PaintLayer layer;
283
+ layer.blend = style->mix_blend_mode;
284
+ uint32_t index = (uint32_t)out.layers.size();
285
+ out.layers.push_back(layer);
286
+ out.ops.insert(out.ops.begin() + (ptrdiff_t)first, PaintOp{PaintKind::LayerBegin, index, clip});
287
+ out.ops.push_back(PaintOp{PaintKind::LayerEnd, 0, clip});
288
+ }
289
+
255
290
  } // namespace
256
291
 
257
292
  void xt_paint_build(const LayoutBox *root, DisplayList &out, float viewportWidth,
@@ -311,6 +346,21 @@ std::string DisplayList::dump() const {
311
346
  }
312
347
  prefix += " ";
313
348
  }
349
+ if (op.kind == PaintKind::LayerBegin) {
350
+ static const char *const names[] = {
351
+ "normal", "multiply", "screen", "overlay", "darken",
352
+ "lighten", "color-dodge", "color-burn", "hard-light", "soft-light",
353
+ "difference", "exclusion", "hue", "saturation", "color",
354
+ "luminosity"};
355
+ const char *name = "normal";
356
+ if (op.index < layers.size()) name = names[(int)layers[op.index].blend];
357
+ text += prefix + "layer blend=" + name + "\n";
358
+ continue;
359
+ }
360
+ if (op.kind == PaintKind::LayerEnd) {
361
+ text += prefix + "end-layer\n";
362
+ continue;
363
+ }
314
364
  std::string suffix;
315
365
  if (op.kind == PaintKind::Rect) {
316
366
  suffix = transformSuffix(rects[op.index].transform);
@@ -330,7 +380,22 @@ std::string DisplayList::dump() const {
330
380
  } else {
331
381
  const PaintImage &image = images[op.index];
332
382
  suffix = transformSuffix(image.transform);
333
- if (image.media_id != 0) {
383
+ if (image.gradient != nullptr) {
384
+ const PaintGradient &gradient = *image.gradient;
385
+ std::snprintf(buffer, sizeof(buffer),
386
+ "image x=%.1f y=%.1f w=%.1f h=%.1f gradient=%s center=%.1f,%.1f "
387
+ "radius=%.1f,%.1f stops=",
388
+ image.x, image.y, image.width, image.height,
389
+ gradient.repeating ? "repeating-radial" : "radial", gradient.center_x,
390
+ gradient.center_y, gradient.radius_x, gradient.radius_y);
391
+ text += prefix + buffer;
392
+ for (size_t stop = 0; stop < gradient.stops.size(); stop++) {
393
+ std::snprintf(buffer, sizeof(buffer), "%s%s@%.4f", stop == 0 ? "" : ",",
394
+ formatColor(gradient.stops[stop].color).c_str(),
395
+ gradient.stops[stop].offset);
396
+ text += buffer;
397
+ }
398
+ } else if (image.media_id != 0) {
334
399
  std::snprintf(buffer, sizeof(buffer), "image x=%.1f y=%.1f w=%.1f h=%.1f media=%llu",
335
400
  image.x, image.y, image.width, image.height,
336
401
  (unsigned long long)image.media_id);
@@ -14,6 +14,7 @@
14
14
  #define XT_GUI_PAINT_H
15
15
 
16
16
  #include <cstdint>
17
+ #include <memory>
17
18
  #include <string>
18
19
  #include <vector>
19
20
 
@@ -24,6 +25,35 @@
24
25
 
25
26
  namespace xtgui {
26
27
 
28
+ /** One resolved colour stop of a gradient. `offset` is a fraction of the
29
+ * gradient line and non-decreasing, as in CSS. */
30
+ struct GradientStop {
31
+ float offset = 0.0f;
32
+ Color color;
33
+ };
34
+
35
+ /** A radial `background-image` layer resolved by the paint pass: the renderer
36
+ * rasterises it into one RGBA texture and draws that as a single image quad.
37
+ *
38
+ * Nesting translucent bands instead composites every band's alpha once per band
39
+ * — each band covers the whole interior — so a vignette whose stops fade to
40
+ * `transparent` turns into an opaque disc that hides the scene behind it.
41
+ * Resolving the ramp per pixel composites it exactly once. */
42
+ struct PaintGradient {
43
+ std::vector<GradientStop> stops;
44
+ /** Centre in box-local logical pixels; the drawn quad spans the whole box. */
45
+ float center_x = 0.0f;
46
+ float center_y = 0.0f;
47
+ float radius_x = 0.0f;
48
+ float radius_y = 0.0f;
49
+ /** `repeating-radial-gradient()`: the stop pattern restarts every `period`
50
+ * (the last stop's offset) instead of clamping at the outer ellipse. */
51
+ bool repeating = false;
52
+ float period = 1.0f;
53
+ /** Identity of the ramp (stops and geometry) for the renderer's texture cache. */
54
+ std::string key;
55
+ };
56
+
27
57
  /** A solid, optionally rounded, rectangle. Coordinates are viewport-relative. */
28
58
  struct PaintRect {
29
59
  float x = 0;
@@ -71,6 +101,10 @@ struct PaintImage {
71
101
  /** Modulating colour for the image shader (white normally; a filter's
72
102
  * multiplicative effect may tint it). */
73
103
  Color tint{1.0f, 1.0f, 1.0f, 1.0f};
104
+ /** A `background-image` radial gradient: the renderer rasterises it into the
105
+ * quad instead of loading `src`/`svg`. Its stops already carry the colour
106
+ * functions of the filter chain; `tint` still carries brightness/opacity. */
107
+ std::shared_ptr<const PaintGradient> gradient;
74
108
  };
75
109
 
76
110
  /** One triangle of a clip path in device-space logical pixels. The paint pass
@@ -102,7 +136,14 @@ struct PaintClip {
102
136
  int paths[2] = {-1, -1};
103
137
  };
104
138
 
105
- enum class PaintKind { Rect, Text, Image };
139
+ enum class PaintKind { Rect, Text, Image, LayerBegin, LayerEnd };
140
+
141
+ /** One `mix-blend-mode` compositing group. The ops between its `LayerBegin`
142
+ * and `LayerEnd` markers are rendered to an offscreen texture and composited
143
+ * over the backdrop as a group, so overlapping children blend once. */
144
+ struct PaintLayer {
145
+ MixBlendMode blend = MixBlendMode::Normal;
146
+ };
106
147
 
107
148
  /** One entry in painter order: which kind of shape, its index in the matching
108
149
  * payload vector, and the clip in effect. */
@@ -121,6 +162,8 @@ struct DisplayList {
121
162
  std::vector<PaintOp> ops;
122
163
  /** Device-space clip-path polygons shared by an element's ops. */
123
164
  std::vector<ClipPathGeometry> clip_paths;
165
+ /** `mix-blend-mode` groups referenced by `LayerBegin` ops. */
166
+ std::vector<PaintLayer> layers;
124
167
 
125
168
  void clear() {
126
169
  rects.clear();
@@ -128,12 +171,25 @@ struct DisplayList {
128
171
  images.clear();
129
172
  ops.clear();
130
173
  clip_paths.clear();
174
+ layers.clear();
131
175
  }
132
176
  bool empty() const { return rects.empty() && texts.empty() && images.empty(); }
133
177
  /** Human-readable dump, one shape per line, in painter order (diagnostics/tests). */
134
178
  std::string dump() const;
135
179
  };
136
180
 
181
+ namespace paint_detail {
182
+
183
+ /** Interpolate a gradient's colour at `t` (a fraction of the gradient line; any
184
+ * value when `repeating`). Stops interpolate in premultiplied alpha, as CSS
185
+ * does, so a `transparent` stop does not darken the ramp towards an opaque
186
+ * colour; the result is converted back to straight alpha, which is what both the
187
+ * band and the texture paths blend. */
188
+ Color gradientColorAt(const std::vector<GradientStop> &stops, bool repeating, float period,
189
+ float t);
190
+
191
+ } // namespace paint_detail
192
+
137
193
  /** Build the display list for a laid-out tree and append it to `out`. The
138
194
  * viewport size resolves `vw`/`vh` units inside `clip-path` and shadows; pass 0
139
195
  * to take it from the root box's containing block. */