drawnui-react 0.1.0-preview.16 → 0.1.0-preview.17

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 (50) hide show
  1. package/README.md +1 -1
  2. package/SKIPPED.md +5 -5
  3. package/dist/controls/AnimatedFramesRenderer.d.ts +7 -0
  4. package/dist/controls/AnimatedFramesRenderer.js +16 -1
  5. package/dist/controls/DrawnGame.d.ts +4 -1
  6. package/dist/controls/DrawnGame.js +7 -3
  7. package/dist/controls/GridStructure.d.ts +13 -1
  8. package/dist/controls/GridStructure.js +38 -2
  9. package/dist/controls/SkiaCarousel.d.ts +1 -1
  10. package/dist/controls/SkiaCarousel.js +3 -2
  11. package/dist/controls/SkiaEditor.d.ts +5 -1
  12. package/dist/controls/SkiaEditor.js +19 -17
  13. package/dist/controls/SkiaGif.d.ts +3 -0
  14. package/dist/controls/SkiaGif.js +11 -1
  15. package/dist/controls/SkiaImage.js +15 -10
  16. package/dist/controls/SkiaLabel.d.ts +52 -3
  17. package/dist/controls/SkiaLabel.js +222 -50
  18. package/dist/controls/SkiaLayout.d.ts +5 -0
  19. package/dist/controls/SkiaLayout.js +7 -4
  20. package/dist/controls/SkiaLottie.js +3 -1
  21. package/dist/controls/SkiaProgress.d.ts +2 -1
  22. package/dist/controls/SkiaProgress.js +4 -1
  23. package/dist/controls/SkiaScroll.d.ts +12 -0
  24. package/dist/controls/SkiaScroll.js +76 -8
  25. package/dist/controls/SkiaShape.d.ts +13 -2
  26. package/dist/controls/SkiaShape.js +50 -10
  27. package/dist/controls/SkiaSlider.d.ts +5 -1
  28. package/dist/controls/SkiaSlider.js +7 -1
  29. package/dist/controls/SkiaSprite.d.ts +1 -0
  30. package/dist/controls/SkiaSprite.js +4 -0
  31. package/dist/controls/SnappingLayout.d.ts +5 -0
  32. package/dist/controls/SnappingLayout.js +8 -0
  33. package/dist/core/Accessibility.d.ts +13 -0
  34. package/dist/core/Accessibility.js +10 -4
  35. package/dist/core/Animators.d.ts +28 -1
  36. package/dist/core/Animators.js +76 -8
  37. package/dist/core/Canvas.d.ts +21 -4
  38. package/dist/core/Canvas.js +122 -23
  39. package/dist/core/Easing.d.ts +12 -1
  40. package/dist/core/Easing.js +25 -1
  41. package/dist/core/Gestures.d.ts +24 -1
  42. package/dist/core/Gestures.js +75 -2
  43. package/dist/core/Hover.d.ts +14 -4
  44. package/dist/core/Hover.js +38 -22
  45. package/dist/core/ScrollAnimators.js +9 -0
  46. package/dist/core/SkiaControl.d.ts +17 -5
  47. package/dist/core/SkiaControl.js +69 -37
  48. package/dist/react/SkiaShell.js +33 -14
  49. package/dist/react/index.js +29 -2
  50. package/package.json +15 -1
@@ -24,6 +24,8 @@ export class SkiaLabel extends SkiaControl {
24
24
  verticalTextAlignment = "Start";
25
25
  lineSpacing = 1;
26
26
  lineHeight = 1;
27
+ paragraphSpacing = 0;
28
+ characterSpacing = 1;
27
29
  textTransform = "None";
28
30
  padding = Thickness.Zero;
29
31
  /** Styled fragments; when not empty they replace `Text` (same precedence as C#). */
@@ -79,6 +81,18 @@ export class SkiaLabel extends SkiaControl {
79
81
  /** Multiplier applied to the natural line height (ascent + descent). */
80
82
  get LineHeight() { return this.lineHeight; }
81
83
  set LineHeight(v) { this.Set("lineHeight", v); }
84
+ /**
85
+ * Space above every paragraph after the first, in lines (C# ParagraphSpacing; 0 here as DrawnUi.Rust, C# 0.25). A
86
+ * paragraph starts after "\n"; U+2028 breaks the line inside a paragraph.
87
+ */
88
+ get ParagraphSpacing() { return this.paragraphSpacing; }
89
+ set ParagraphSpacing(v) { this.Set("paragraphSpacing", v); }
90
+ /**
91
+ * C# CharacterSpacing: 1 = the font's own spacing; every glyph is followed by (CharacterSpacing - 1) points more
92
+ * (less below 1), the line measures and wraps at the width it draws (C# 7cf1007c, DrawnUi.Rust label).
93
+ */
94
+ get CharacterSpacing() { return this.characterSpacing; }
95
+ set CharacterSpacing(v) { this.Set("characterSpacing", v); }
82
96
  get TextTransform() { return this.textTransform; }
83
97
  set TextTransform(v) { this.Set("textTransform", v); }
84
98
  get Padding() { return this.padding; }
@@ -113,6 +127,8 @@ export class SkiaLabel extends SkiaControl {
113
127
  mainFonts;
114
128
  fontsCache = new Map();
115
129
  runCache = new Map();
130
+ /** CharacterSpacing in pixels at the measured scale: added after every glyph of a run (its Width includes them). */
131
+ glyphSpacing = 0;
116
132
  ResolveFonts(family, weight, italic, sizePx) {
117
133
  const key = `${family}|${this.fontFamilyFallback}|${weight}|${italic}|${sizePx}`;
118
134
  let f = this.fontsCache.get(key);
@@ -142,12 +158,24 @@ export class SkiaLabel extends SkiaControl {
142
158
  const weight = span.FontWeight > 0 ? span.FontWeight : span.IsBold ? 700 : this.fontWeight > 0 ? this.fontWeight : bold ? 700 : 0;
143
159
  return this.ResolveFonts(span.FontFamily ?? this.fontFamily, weight, italic, (span.FontSize ?? this.fontSize) * scale);
144
160
  }
145
- static Advance(font, text) {
161
+ Advance(font, text) {
146
162
  let w = 0;
147
163
  for (const adv of font.getGlyphWidths(font.getGlyphIDs(text)))
148
- w += adv;
164
+ w += adv + this.glyphSpacing;
149
165
  return w;
150
166
  }
167
+ static Ignorable = /[\u200B-\u200F\u2060-\u2064\uFE00-\uFE0F\u{E0000}-\u{E0FFF}]/u;
168
+ /**
169
+ * Default-ignorable code points (zero width space, joiners, direction marks, word joiners, variation selectors such
170
+ * as U+FE0F after an emoji, tags) that none of the fonts has draw nothing and take no room, never the missing-glyph
171
+ * box (DrawnUi.Rust label_ignorables; text is not shaped here, C# hides them by shaping).
172
+ */
173
+ static DropIgnorables(text, main, fallbacks) {
174
+ if (!SkiaLabel.Ignorable.test(text))
175
+ return text;
176
+ const has = (cp) => main.getGlyphIDs(cp)[0] !== 0 || fallbacks.some((f) => f.getGlyphIDs(cp)[0] !== 0);
177
+ return Array.from(text).filter((cp) => !SkiaLabel.Ignorable.test(cp) || has(cp)).join("");
178
+ }
151
179
  /**
152
180
  * Splits text into runs by glyph availability: the main font, or the first fallback that has a glyph where the
153
181
  * main font has glyph 0. Spaces always stay on the main font (fallback faces often carry very wide spaces).
@@ -161,8 +189,13 @@ export class SkiaLabel extends SkiaControl {
161
189
  const main = fonts.Main;
162
190
  const fbs = fonts.Fallbacks;
163
191
  cached = [];
192
+ text = SkiaLabel.DropIgnorables(text, main, fbs);
193
+ if (text.length === 0) {
194
+ this.runCache.set(cacheKey, cached);
195
+ return cached;
196
+ }
164
197
  if (fbs.length === 0) {
165
- cached.push({ Text: text, Font: main, Width: SkiaLabel.Advance(main, text), Fonts: fonts });
198
+ cached.push({ Text: text, Font: main, Width: this.Advance(main, text), Fonts: fonts });
166
199
  }
167
200
  else {
168
201
  const cps = Array.from(text);
@@ -181,13 +214,13 @@ export class SkiaLabel extends SkiaControl {
181
214
  const f = fontFor(i);
182
215
  if (f !== current) {
183
216
  const t = cps.slice(start, i).join("");
184
- cached.push({ Text: t, Font: current, Width: SkiaLabel.Advance(current, t), Fonts: fonts });
217
+ cached.push({ Text: t, Font: current, Width: this.Advance(current, t), Fonts: fonts });
185
218
  start = i;
186
219
  current = f;
187
220
  }
188
221
  }
189
222
  const t = cps.slice(start).join("");
190
- cached.push({ Text: t, Font: current, Width: SkiaLabel.Advance(current, t), Fonts: fonts });
223
+ cached.push({ Text: t, Font: current, Width: this.Advance(current, t), Fonts: fonts });
191
224
  }
192
225
  this.runCache.set(cacheKey, cached);
193
226
  }
@@ -195,6 +228,76 @@ export class SkiaLabel extends SkiaControl {
195
228
  }
196
229
  Width(runs) { let w = 0; for (const r of runs)
197
230
  w += r.Width; return w; }
231
+ /** A line never starts with these (closing punctuation, small kana, the long vowel mark): JIS X 4051 kinsoku (C#). */
232
+ static NoBreakBefore = "、。,.・:;?!゛゜ヽヾゝゞ々〻ー」』)〕]}〉》】〗〙〟⦆»’”‐゠–〜~ぁぃぅぇぉっゃゅょゎゕゖァィゥェォッャュョヮヵヶㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ。」、・ーァィゥェォャュョッ)]},.!?:;%";
233
+ /** A line never ends with these (opening brackets and quotes). */
234
+ static NoBreakAfter = "「『(〔[{〈《【〖〘〝⦅«‘“「([{";
235
+ /** Chinese / Japanese: ideographs, kana, CJK punctuation, full-width forms (C# IsCjkAt). Korean breaks at spaces only. */
236
+ static Cjk = /[\u3000-\u312F\u3190-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF\u{20000}-\u{3FFFF}]/u;
237
+ static CanBreakBetween(before, after) {
238
+ return (SkiaLabel.Cjk.test(before) || SkiaLabel.Cjk.test(after)) && !SkiaLabel.NoBreakBefore.includes(after) && !SkiaLabel.NoBreakAfter.includes(before);
239
+ }
240
+ /**
241
+ * C# CanBreakInsideWord: whether a line may break between text[index - 1] and text[index] (UTF-16 indexes) inside a
242
+ * space-free run: next to a Chinese or Japanese character, never before closing punctuation or small kana, never
243
+ * after an opening bracket, never inside a surrogate pair.
244
+ */
245
+ static CanBreakInsideWord(text, index) {
246
+ if (index <= 0 || index >= text.length)
247
+ return false;
248
+ const hi = (k) => { const c = text.charCodeAt(k); return c >= 0xD800 && c <= 0xDBFF; };
249
+ if (hi(index - 1))
250
+ return false; // inside a surrogate pair
251
+ const before = String.fromCodePoint(text.codePointAt(hi(index - 2) && index >= 2 ? index - 2 : index - 1));
252
+ const after = String.fromCodePoint(text.codePointAt(index));
253
+ return SkiaLabel.CanBreakBetween(before, after);
254
+ }
255
+ /**
256
+ * C# FitAtBreak: the last place inside the word where a Chinese / Japanese line may break and `prefix` plus the word
257
+ * up to there still fits; code points from the word's start, 0 = none.
258
+ */
259
+ FitCjk(word, prefix, maxWidth) {
260
+ if (!word.some((r) => SkiaLabel.Cjk.test(r.Text)))
261
+ return 0;
262
+ const sp = this.glyphSpacing;
263
+ let x = prefix, best = 0, before = "", k = 0;
264
+ for (const r of word) {
265
+ const cps = Array.from(r.Text), widths = r.Font.getGlyphWidths(r.Font.getGlyphIDs(r.Text, cps.length));
266
+ for (let i = 0; i < cps.length; i++) {
267
+ if (k > 0 && SkiaLabel.CanBreakBetween(before, cps[i])) {
268
+ if (x - sp > maxWidth)
269
+ return best;
270
+ best = k;
271
+ }
272
+ x += widths[i] + sp;
273
+ before = cps[i];
274
+ k++;
275
+ }
276
+ }
277
+ return best;
278
+ }
279
+ /** The word cut after `count` code points: the head goes on a line, the rest follows. */
280
+ SplitWord(word, count) {
281
+ const head = [], rest = [];
282
+ let left = count;
283
+ for (const r of word) {
284
+ const cps = Array.from(r.Text);
285
+ if (left >= cps.length) {
286
+ head.push(r);
287
+ left -= cps.length;
288
+ continue;
289
+ }
290
+ if (left > 0) {
291
+ const a = cps.slice(0, left).join(""), b = cps.slice(left).join("");
292
+ head.push({ ...r, Text: a, Width: this.Advance(r.Font, a) });
293
+ rest.push({ ...r, Text: b, Width: this.Advance(r.Font, b) });
294
+ left = 0;
295
+ continue;
296
+ }
297
+ rest.push(r);
298
+ }
299
+ return [head, rest];
300
+ }
198
301
  Transform(text) {
199
302
  switch (this.textTransform) {
200
303
  case "Uppercase": return text.toUpperCase();
@@ -210,10 +313,16 @@ export class SkiaLabel extends SkiaControl {
210
313
  Tokenize(scale) {
211
314
  const paragraphs = [[]];
212
315
  const add = (text, fonts, span) => {
213
- const parts = this.Transform(text).split("\n");
316
+ // "\n" starts a paragraph, U+2028 breaks the line inside one
317
+ const pieces = this.Transform(text).split(/(\n|\u2028)/);
318
+ const parts = pieces.filter((_, i) => i % 2 === 0), seps = pieces.filter((_, i) => i % 2 === 1);
214
319
  for (let p = 0; p < parts.length; p++) {
215
- if (p > 0)
216
- paragraphs.push([]);
320
+ if (p > 0) {
321
+ const next = [];
322
+ if (seps[p - 1] === "\u2028")
323
+ next.LineBreak = true;
324
+ paragraphs.push(next);
325
+ }
217
326
  const para = paragraphs[paragraphs.length - 1];
218
327
  const words = parts[p].split(" ");
219
328
  for (let w = 0; w < words.length; w++) {
@@ -259,13 +368,21 @@ export class SkiaLabel extends SkiaControl {
259
368
  line.Descent = r.Fonts.Descent;
260
369
  }
261
370
  }
262
- /** Word-wraps tokens into lines that fit maxWidth (Infinity = no wrap), applies MaxLines with a tail ellipsis. */
263
- LayoutLines(maxWidth, scale) {
371
+ /**
372
+ * Word-wraps tokens into lines that fit maxWidth (Infinity = no wrap); keeps the lines that fit maxHeight (at least one)
373
+ * and MaxLines, the last kept one ending with a tail ellipsis (C# / DrawnUi.Rust: a label in a box too short for its
374
+ * text cuts it, not only MaxLines).
375
+ */
376
+ LayoutLines(maxWidth, scale, maxHeight = Infinity) {
264
377
  const wrap = this.lineBreakMode !== "NoWrap" && isFinite(maxWidth);
378
+ // run widths carry the gap after every glyph; the one after a line's last glyph is not drawn
379
+ const sp = this.glyphSpacing;
265
380
  const out = [];
266
381
  const empty = (fonts) => { const l = this.NewLine(); l.Ascent = fonts.Ascent; l.Descent = fonts.Descent; return l; };
267
382
  for (const para of this.Tokenize(scale)) {
268
383
  let line = this.NewLine();
384
+ if (out.length > 0 && !para.LineBreak)
385
+ line.Para = true;
269
386
  let lastFonts = this.mainFonts;
270
387
  for (let i = 0; i < para.length; i++) {
271
388
  const tok = para[i];
@@ -276,46 +393,71 @@ export class SkiaLabel extends SkiaControl {
276
393
  i++;
277
394
  word = word.concat(this.Segment(para[i].Text, para[i].Fonts, para[i].Span));
278
395
  }
279
- const space = tok.SpaceBefore && line.Runs.length > 0 ? this.Segment(" ", tok.Fonts, tok.Span) : [];
280
- const spaceW = this.Width(space), wordW = this.Width(word);
281
- if (!wrap || line.Width + spaceW + wordW <= maxWidth || (line.Runs.length === 0 && wordW <= maxWidth)) {
282
- this.Append(line, space);
283
- this.Append(line, word);
284
- continue;
285
- }
286
- if (line.Runs.length > 0) {
287
- out.push(line);
288
- line = this.NewLine();
289
- }
290
- if (wordW <= maxWidth) {
291
- this.Append(line, word);
292
- continue;
293
- }
294
- // word longer than the line: break by code points
295
- for (const r of word) {
296
- for (const ch of Array.from(r.Text)) {
297
- const run = this.Segment(ch, r.Fonts, r.Span);
298
- const chW = this.Width(run);
299
- if (line.Runs.length > 0 && line.Width + chW > maxWidth) {
300
- out.push(line);
301
- line = this.NewLine();
396
+ for (;;) {
397
+ const space = tok.SpaceBefore && line.Runs.length > 0 ? this.Segment(" ", tok.Fonts, tok.Span) : [];
398
+ const spaceW = this.Width(space), wordW = this.Width(word);
399
+ if (!wrap || line.Width + spaceW + wordW - sp <= maxWidth || (line.Runs.length === 0 && wordW - sp <= maxWidth)) {
400
+ this.Append(line, space);
401
+ this.Append(line, word);
402
+ break;
403
+ }
404
+ // Chinese / Japanese: the word fills the line up to its last break that fits (C# FitAtBreak)
405
+ const k = this.FitCjk(word, line.Width + spaceW, maxWidth);
406
+ if (k > 0) {
407
+ const [head, rest] = this.SplitWord(word, k);
408
+ this.Append(line, space);
409
+ this.Append(line, head);
410
+ out.push(line);
411
+ line = this.NewLine();
412
+ word = rest;
413
+ continue;
414
+ }
415
+ if (line.Runs.length > 0) {
416
+ out.push(line);
417
+ line = this.NewLine();
418
+ continue;
419
+ }
420
+ // word longer than the line: break by code points
421
+ for (const r of word) {
422
+ for (const ch of Array.from(r.Text)) {
423
+ const run = this.Segment(ch, r.Fonts, r.Span);
424
+ const chW = this.Width(run);
425
+ if (line.Runs.length > 0 && line.Width + chW - sp > maxWidth) {
426
+ out.push(line);
427
+ line = this.NewLine();
428
+ }
429
+ this.Append(line, run);
302
430
  }
303
- this.Append(line, run);
304
431
  }
432
+ break;
305
433
  }
306
434
  }
307
435
  out.push(line.Runs.length > 0 ? line : empty(lastFonts));
308
436
  }
309
- if (this.maxLines > 0 && out.length > this.maxLines) {
310
- out.length = this.maxLines;
437
+ let kept = this.maxLines > 0 ? Math.min(out.length, this.maxLines) : out.length;
438
+ if (isFinite(maxHeight)) {
439
+ let used = 0, fit = 0;
440
+ for (let i = 0; i < out.length; i++) {
441
+ const lh = this.LineHeightPx(out[i]);
442
+ // the block height of the first i + 1 lines: spacing after every line but the last, paragraph space above
443
+ const total = used + lh + (i > 0 ? this.ParagraphSpace(out[i]) : 0);
444
+ if (total > maxHeight && fit > 0)
445
+ break;
446
+ fit++;
447
+ used = total - lh + lh * this.lineSpacing;
448
+ }
449
+ kept = Math.min(kept, fit);
450
+ }
451
+ if (kept < out.length) {
452
+ out.length = kept;
311
453
  const truncates = this.lineBreakMode === "TailTruncation" || this.lineBreakMode === "HeadTruncation" || this.lineBreakMode === "MiddleTruncation";
312
454
  if (truncates) {
313
- const last = out[this.maxLines - 1];
455
+ const last = out[kept - 1];
314
456
  const tailRun = last.Runs[last.Runs.length - 1];
315
457
  const fonts = tailRun ? tailRun.Fonts : this.mainFonts;
316
458
  const ell = this.Segment("…", fonts, tailRun?.Span);
317
459
  const ellW = this.Width(ell);
318
- while (last.Runs.length > 0 && last.Width + ellW > maxWidth) {
460
+ while (last.Runs.length > 0 && last.Width + ellW - sp > maxWidth) {
319
461
  const r = last.Runs[last.Runs.length - 1];
320
462
  const cps = Array.from(r.Text);
321
463
  cps.pop();
@@ -327,27 +469,39 @@ export class SkiaLabel extends SkiaControl {
327
469
  continue;
328
470
  }
329
471
  r.Text = cps.join("");
330
- r.Width = SkiaLabel.Advance(r.Font, r.Text);
472
+ r.Width = this.Advance(r.Font, r.Text);
331
473
  last.Width += r.Width;
332
474
  }
333
475
  this.Append(last, ell);
334
476
  }
335
477
  }
478
+ if (sp !== 0)
479
+ for (const l of out)
480
+ if (l.Runs.length > 0)
481
+ l.Width -= sp;
336
482
  return out;
337
483
  }
338
484
  LineHeightPx(line) { return (line.Ascent + line.Descent) * this.lineHeight; }
485
+ /** ParagraphSpacing above a line that starts a paragraph (after the first): that many of its lines, with their spacing. */
486
+ ParagraphSpace(line) { return line.Para && this.paragraphSpacing ? this.LineHeightPx(line) * this.lineSpacing * this.paragraphSpacing : 0; }
339
487
  BlockHeight() {
340
488
  let h = 0;
341
489
  for (let i = 0; i < this.lines.length; i++) {
342
490
  const lh = this.LineHeightPx(this.lines[i]);
343
- h += i < this.lines.length - 1 ? lh * this.lineSpacing : lh;
491
+ h += (i < this.lines.length - 1 ? lh * this.lineSpacing : lh) + this.ParagraphSpace(this.lines[i]);
344
492
  }
345
493
  return h;
346
494
  }
347
- MeasureAbsolute(widthConstraint, _heightConstraint, scale) {
495
+ MeasureAbsolute(widthConstraint, heightConstraint, scale) {
348
496
  this.mainFonts = this.ResolveMainFonts(scale);
497
+ const glyphSpacing = scale * (this.characterSpacing - 1);
498
+ if (glyphSpacing !== this.glyphSpacing) {
499
+ this.glyphSpacing = glyphSpacing;
500
+ this.runCache.clear();
501
+ }
349
502
  const px = this.padding.HorizontalThickness * scale, py = this.padding.VerticalThickness * scale;
350
- this.lines = this.text || this.Spans.length > 0 ? this.LayoutLines(widthConstraint - px, scale) : [];
503
+ const extraH = this.EffectsExtra(scale).H;
504
+ this.lines = this.text || this.Spans.length > 0 ? this.LayoutLines(widthConstraint - px, scale, heightConstraint - py - extraH) : [];
351
505
  let width = 0;
352
506
  for (const l of this.lines)
353
507
  width = Math.max(width, l.Width);
@@ -378,6 +532,7 @@ export class SkiaLabel extends SkiaControl {
378
532
  y += extra.Stroke / 2;
379
533
  const out = [];
380
534
  for (const line of this.lines) {
535
+ y += this.ParagraphSpace(line);
381
536
  const lh = this.LineHeightPx(line);
382
537
  const lineW = line.Width + extra.W;
383
538
  let x = left;
@@ -430,7 +585,7 @@ export class SkiaLabel extends SkiaControl {
430
585
  cps.forEach((cp, k) => {
431
586
  const w = widths[k] ?? 0;
432
587
  out.push({ Index: index, Length: cp.length, Line: li, Left: x, Top: g.y, Width: w, Height: g.h });
433
- x += w;
588
+ x += w + this.glyphSpacing;
434
589
  index += cp.length;
435
590
  });
436
591
  }
@@ -512,9 +667,25 @@ export class SkiaLabel extends SkiaControl {
512
667
  return paint;
513
668
  };
514
669
  const canvas = ctx.Context.Canvas;
670
+ const sp = this.glyphSpacing;
671
+ // spaced text: every glyph placed with the gap after it (C# MeasureLineGlyphs positions)
672
+ const drawRun = (run, rx, ry, paint) => {
673
+ if (sp === 0) {
674
+ canvas.drawText(run.Text, rx, ry, paint, run.Font);
675
+ return;
676
+ }
677
+ const ids = run.Font.getGlyphIDs(run.Text), widths = run.Font.getGlyphWidths(ids);
678
+ const positions = new Float32Array(ids.length * 2);
679
+ for (let k = 0, gx = 0; k < ids.length; k++) {
680
+ positions[k * 2] = gx;
681
+ gx += widths[k] + sp;
682
+ }
683
+ canvas.drawGlyphs(ids, positions, rx, ry, run.Font, paint);
684
+ };
515
685
  const gradient = this.FillGradient;
516
686
  const textRect = new SKRect(left, top, right, bottom);
517
687
  for (const line of this.lines) {
688
+ y += this.ParagraphSpace(line);
518
689
  const lh = this.LineHeightPx(line);
519
690
  const lineW = line.Width + extra.W;
520
691
  let x = left;
@@ -530,22 +701,23 @@ export class SkiaLabel extends SkiaControl {
530
701
  this.SetupGradient(strokePaint, this.StrokeGradient, gradientRect ?? new SKRect(x, y, x + line.Width, y + lh));
531
702
  for (const run of line.Runs) {
532
703
  const span = run.Span;
704
+ const drawnW = run.Width - sp; // the gap after the run's last glyph is not part of it
533
705
  if (span) {
534
- span.Rects.push(new SKRect(x - d.Left, y - d.Top, x + run.Width - d.Left, y + lh - d.Top));
706
+ span.Rects.push(new SKRect(x - d.Left, y - d.Top, x + drawnW - d.Left, y + lh - d.Top));
535
707
  if (span.BackgroundColor)
536
- canvas.drawRect(CK.LTRBRect(x, y, x + run.Width, y + lh), paintFor(span.BackgroundColor));
708
+ canvas.drawRect(CK.LTRBRect(x, y, x + drawnW, y + lh), paintFor(span.BackgroundColor));
537
709
  }
538
710
  const color = span?.TextColor ?? this.textColor;
539
711
  if (run.Text) {
540
712
  // C# DrawText order: drop shadow, stroke, fill
541
713
  if (shadowPaint)
542
- canvas.drawText(run.Text, x + shadowDx, baseline + shadowDy, shadowPaint, run.Font);
714
+ drawRun(run, x + shadowDx, baseline + shadowDy, shadowPaint);
543
715
  if (strokePaint)
544
- canvas.drawText(run.Text, x, baseline, strokePaint, run.Font);
716
+ drawRun(run, x, baseline, strokePaint);
545
717
  const paint = paintFor(color);
546
718
  if (gradient && gradientRect)
547
719
  this.SetupGradient(paint, gradient, gradientRect);
548
- canvas.drawText(run.Text, x, baseline, paint, run.Font);
720
+ drawRun(run, x, baseline, paint);
549
721
  }
550
722
  if (span?.HasDecorations) {
551
723
  // same geometry as C# DrawSpanDecorations; CanvasKit exposes no underline/strikeout/x-height metrics,
@@ -553,11 +725,11 @@ export class SkiaLabel extends SkiaControl {
553
725
  if (span.Underline && span.UnderlineWidth !== 0) {
554
726
  const w = span.UnderlineWidth > 0 ? span.UnderlineWidth * scale : -span.UnderlineWidth;
555
727
  const yl = Math.round(baseline + scale);
556
- canvas.drawLine(x, yl, x + run.Width, yl, paintFor(color, w));
728
+ canvas.drawLine(x, yl, x + drawnW, yl, paintFor(color, w));
557
729
  }
558
730
  if (span.Strikeout) {
559
731
  const yl = Math.round(baseline - (run.Fonts.SizePx * 0.52) / 2);
560
- canvas.drawLine(x, yl, x + run.Width, yl, paintFor(span.StrikeoutColor, span.StrikeoutWidth * scale));
732
+ canvas.drawLine(x, yl, x + drawnW, yl, paintFor(span.StrikeoutColor, span.StrikeoutWidth * scale));
561
733
  }
562
734
  }
563
735
  x += run.Width;
@@ -165,6 +165,11 @@ export declare class SkiaLayout extends SkiaControl {
165
165
  private MvScheduleBackground;
166
166
  /** Binds a cell to items[index] (through the adapter, so MeasureFirst's cell 0 stays realized) and measures it. */
167
167
  private MeasureItem;
168
+ /**
169
+ * The item's offset and height are real, not estimated (C# LastMeasuredIndexLocal >= index): MeasureVisible knows
170
+ * them once the measured prefix passed the item; the other strategies always do.
171
+ */
172
+ IsItemMeasured(index: number): boolean;
168
173
  /** Pixel offset of item index from the top of the layout content (inside padding). */
169
174
  GetItemOffsetPixels(index: number): number;
170
175
  private ItemHeightPixels;
@@ -731,6 +731,11 @@ export class SkiaLayout extends SkiaControl {
731
731
  this.ChildrenFactory.ReleaseViewAt(index);
732
732
  return h;
733
733
  }
734
+ /**
735
+ * The item's offset and height are real, not estimated (C# LastMeasuredIndexLocal >= index): MeasureVisible knows
736
+ * them once the measured prefix passed the item; the other strategies always do.
737
+ */
738
+ IsItemMeasured(index) { return this.MeasureItemsStrategy !== "MeasureVisible" || index < this.mvMeasured; }
734
739
  /** Pixel offset of item index from the top of the layout content (inside padding). */
735
740
  GetItemOffsetPixels(index) {
736
741
  const scale = this.RenderingScale;
@@ -823,12 +828,10 @@ export class SkiaLayout extends SkiaControl {
823
828
  return;
824
829
  }
825
830
  const composing = this.IsRenderingWithComposition;
826
- // inside a composite that redraws only some areas, a child entirely outside them is not even traversed
827
- const culling = !composing && SkiaControl.CompositionCulling > 0;
828
831
  for (const v of this.GetOrderedSubviews()) {
829
- if (composing ? !this.DirtyChildrenInternal.has(v) : culling && v.IsVisible && v.OutsideCompositionClip(ctx.Context.Canvas))
832
+ if (composing && !this.DirtyChildrenInternal.has(v))
830
833
  continue;
831
- v.Render(ctx);
834
+ v.Render(ctx); // a child entirely outside the clip skips itself
832
835
  }
833
836
  }
834
837
  GetCompositeChildren() { return this.IsTemplatedList || this.IsRecycledLayout ? [] : this.GetOrderedSubviews(); }
@@ -32,7 +32,9 @@ export class SkiaLottie extends AnimatedFramesRenderer {
32
32
  Animation;
33
33
  constructor() {
34
34
  super();
35
- this.UseCache = "ImageDoubleBuffered";
35
+ // C# ImageDoubleBuffered pays off where a thread bakes the bitmap; on the web it is one offscreen pass per frame
36
+ // while playing, so the web engines default to Operations (drawnui-cross rule 3)
37
+ this.UseCache = "Operations";
36
38
  }
37
39
  get Source() { return this.source; }
38
40
  set Source(v) { if (this.source !== v) {
@@ -36,5 +36,6 @@ export declare class SkiaProgress extends SkiaControl {
36
36
  get Ratio(): number;
37
37
  protected MeasureAbsolute(w: number, _h: number, scale: number): ScaledSize;
38
38
  protected Paint(ctx: DrawingContext): void;
39
- protected DefaultAccessibilityLabel(): string | undefined;
39
+ /** Value between Min and Max, read as "65%"; the name is the app's AccessibilityLabel only (drawnui-cross 6c). */
40
+ get AccessibilityValue(): import("../core/Accessibility").AccessibilityValue;
40
41
  }
@@ -76,5 +76,8 @@ export class SkiaProgress extends SkiaControl {
76
76
  }
77
77
  paint.delete();
78
78
  }
79
- DefaultAccessibilityLabel() { return `${Math.round(this.Ratio * 100)}%`; }
79
+ /** Value between Min and Max, read as "65%"; the name is the app's AccessibilityLabel only (drawnui-cross 6c). */
80
+ get AccessibilityValue() {
81
+ return { Now: this.value, Min: this.min, Max: this.max, Step: 0, Text: `${Math.round(this.Ratio * 100)}%` };
82
+ }
80
83
  }
@@ -218,6 +218,18 @@ export declare class SkiaScroll extends SkiaControl {
218
218
  * templated layout (the recycled list is the scroll's only child; a header goes above the scroll or in Header).
219
219
  */
220
220
  ScrollToIndex(index: number, animate: boolean, option?: RelativePositionType): void;
221
+ /**
222
+ * An open ScrollToIndex (C# OrderedScrollToIndex, DrawnUi.Rust scroll_to_index): a row not measured yet is gone to by
223
+ * the sizes as they are estimated, and the order is aimed again every frame until the row has its real place and the
224
+ * viewport stands on it; an animated move goes on to a moved target in the time it has left, later corrections jump.
225
+ * A press, the wheel or another scroll drops it; while it is open LoadMore waits.
226
+ */
227
+ private indexOrder?;
228
+ private pursuing;
229
+ /** One step of the open order (C# ExecuteScrollToIndexOrder, Rust `pursue`), at the start of every paint. */
230
+ private PursueIndexOrder;
231
+ /** The offset (points) that puts item `index` where `option` says, by the sizes the list knows now; Exact = its place is measured. */
232
+ private AimIndex;
221
233
  private StopAnimators;
222
234
  /** DrawnUi DecelerationRatio: FrictionScrolled / 100, floored at 0.1 friction. */
223
235
  private get DecelerationRatio();