drawnui-react 0.1.0-preview.2 → 0.1.0-preview.21

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 (87) hide show
  1. package/README.md +17 -4
  2. package/SKIPPED.md +33 -27
  3. package/dist/controls/AnimatedFramesRenderer.d.ts +7 -0
  4. package/dist/controls/AnimatedFramesRenderer.js +16 -1
  5. package/dist/controls/DrawnGame.d.ts +40 -0
  6. package/dist/controls/DrawnGame.js +64 -0
  7. package/dist/controls/GridStructure.d.ts +16 -2
  8. package/dist/controls/GridStructure.js +40 -4
  9. package/dist/controls/SkiaBackdrop.d.ts +8 -2
  10. package/dist/controls/SkiaBackdrop.js +48 -8
  11. package/dist/controls/SkiaButton.d.ts +19 -2
  12. package/dist/controls/SkiaButton.js +41 -11
  13. package/dist/controls/SkiaCarousel.d.ts +9 -1
  14. package/dist/controls/SkiaCarousel.js +16 -3
  15. package/dist/controls/SkiaCheckbox.d.ts +0 -1
  16. package/dist/controls/SkiaCheckbox.js +0 -6
  17. package/dist/controls/SkiaDrawer.d.ts +2 -0
  18. package/dist/controls/SkiaDrawer.js +2 -0
  19. package/dist/controls/SkiaEditor.d.ts +12 -1
  20. package/dist/controls/SkiaEditor.js +46 -19
  21. package/dist/controls/SkiaGif.d.ts +3 -0
  22. package/dist/controls/SkiaGif.js +12 -2
  23. package/dist/controls/SkiaImage.js +15 -10
  24. package/dist/controls/SkiaLabel.d.ts +54 -3
  25. package/dist/controls/SkiaLabel.js +235 -52
  26. package/dist/controls/SkiaLabelFps.d.ts +16 -0
  27. package/dist/controls/SkiaLabelFps.js +41 -0
  28. package/dist/controls/SkiaLayout.d.ts +64 -7
  29. package/dist/controls/SkiaLayout.js +316 -19
  30. package/dist/controls/SkiaLottie.js +4 -2
  31. package/dist/controls/SkiaProgress.d.ts +2 -1
  32. package/dist/controls/SkiaProgress.js +6 -3
  33. package/dist/controls/SkiaRadioButton.js +4 -3
  34. package/dist/controls/SkiaScroll.d.ts +35 -5
  35. package/dist/controls/SkiaScroll.js +233 -23
  36. package/dist/controls/SkiaScrollBar.d.ts +27 -0
  37. package/dist/controls/SkiaScrollBar.js +51 -2
  38. package/dist/controls/SkiaShaderCarousel.d.ts +5 -0
  39. package/dist/controls/SkiaShaderCarousel.js +5 -0
  40. package/dist/controls/SkiaShape.d.ts +13 -2
  41. package/dist/controls/SkiaShape.js +50 -10
  42. package/dist/controls/SkiaSlider.d.ts +20 -2
  43. package/dist/controls/SkiaSlider.js +59 -5
  44. package/dist/controls/SkiaSprite.d.ts +1 -0
  45. package/dist/controls/SkiaSprite.js +4 -0
  46. package/dist/controls/SkiaSvg.js +1 -1
  47. package/dist/controls/SkiaSwitch.d.ts +0 -1
  48. package/dist/controls/SkiaSwitch.js +0 -6
  49. package/dist/controls/SkiaToggle.d.ts +25 -5
  50. package/dist/controls/SkiaToggle.js +58 -5
  51. package/dist/controls/SnappingLayout.d.ts +12 -1
  52. package/dist/controls/SnappingLayout.js +19 -1
  53. package/dist/core/Accessibility.d.ts +80 -0
  54. package/dist/core/Accessibility.js +365 -8
  55. package/dist/core/Animators.d.ts +37 -1
  56. package/dist/core/Animators.js +102 -9
  57. package/dist/core/Canvas.d.ts +108 -7
  58. package/dist/core/Canvas.js +363 -35
  59. package/dist/core/Easing.d.ts +12 -1
  60. package/dist/core/Easing.js +25 -1
  61. package/dist/core/Gestures.d.ts +48 -2
  62. package/dist/core/Gestures.js +95 -3
  63. package/dist/core/Hover.d.ts +47 -0
  64. package/dist/core/Hover.js +134 -0
  65. package/dist/core/KeyboardManager.d.ts +13 -0
  66. package/dist/core/KeyboardManager.js +18 -0
  67. package/dist/core/ScrollAnimators.js +9 -0
  68. package/dist/core/SkiaControl.d.ts +216 -7
  69. package/dist/core/SkiaControl.js +516 -59
  70. package/dist/core/SkiaEffect.d.ts +17 -0
  71. package/dist/core/SkiaEffect.js +43 -8
  72. package/dist/core/SkiaImageManager.js +1 -1
  73. package/dist/core/Styles.d.ts +51 -0
  74. package/dist/core/Styles.js +92 -0
  75. package/dist/core/Super.d.ts +17 -1
  76. package/dist/core/Super.js +35 -4
  77. package/dist/core/ViewsAdapter.d.ts +7 -2
  78. package/dist/core/ViewsAdapter.js +10 -3
  79. package/dist/index.d.ts +4 -0
  80. package/dist/index.js +4 -0
  81. package/dist/react/SkiaShell.js +33 -14
  82. package/dist/react/index.d.ts +15 -4
  83. package/dist/react/index.js +102 -15
  84. package/dist/react/reconciler.js +34 -12
  85. package/dist/vite/index.d.ts +9 -0
  86. package/dist/vite/index.js +36 -3
  87. package/package.json +33 -3
@@ -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#). */
@@ -43,12 +45,17 @@ export class SkiaLabel extends SkiaControl {
43
45
  this[key] = v;
44
46
  this.Update();
45
47
  } }
48
+ /** A property that changes only the look (a color): no layout change, see UpdateDraw. */
49
+ SetLook(key, v) { if (this[key] !== v) {
50
+ this[key] = v;
51
+ this.UpdateDraw();
52
+ } }
46
53
  get Text() { return this.text; }
47
54
  set Text(v) { this.Set("text", v); }
48
55
  get FontSize() { return this.fontSize; }
49
56
  set FontSize(v) { this.Set("fontSize", v); }
50
57
  get TextColor() { return this.textColor; }
51
- set TextColor(v) { this.Set("textColor", v); }
58
+ set TextColor(v) { this.SetLook("textColor", v); }
52
59
  get FontFamily() { return this.fontFamily; }
53
60
  set FontFamily(v) { this.Set("fontFamily", v); }
54
61
  /**
@@ -74,6 +81,18 @@ export class SkiaLabel extends SkiaControl {
74
81
  /** Multiplier applied to the natural line height (ascent + descent). */
75
82
  get LineHeight() { return this.lineHeight; }
76
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); }
77
96
  get TextTransform() { return this.textTransform; }
78
97
  set TextTransform(v) { this.Set("textTransform", v); }
79
98
  get Padding() { return this.padding; }
@@ -85,8 +104,14 @@ export class SkiaLabel extends SkiaControl {
85
104
  InsertSubView(index, control) {
86
105
  if (!(control instanceof TextSpan))
87
106
  throw new Error("DrawnUi: SkiaLabel children must be <TextSpan>");
107
+ const at = control.Parent === this ? this.Spans.indexOf(control) : -1; // already a span: a move, never a 2nd copy
108
+ if (at >= 0) {
109
+ this.Spans.splice(at, 1);
110
+ if (at < index)
111
+ index--;
112
+ }
88
113
  control.Parent = this;
89
- this.Spans.splice(index, 0, control);
114
+ this.Spans.splice(Math.min(Math.max(0, index), this.Spans.length), 0, control);
90
115
  this.Update();
91
116
  }
92
117
  RemoveSubView(control) {
@@ -102,6 +127,8 @@ export class SkiaLabel extends SkiaControl {
102
127
  mainFonts;
103
128
  fontsCache = new Map();
104
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;
105
132
  ResolveFonts(family, weight, italic, sizePx) {
106
133
  const key = `${family}|${this.fontFamilyFallback}|${weight}|${italic}|${sizePx}`;
107
134
  let f = this.fontsCache.get(key);
@@ -131,12 +158,24 @@ export class SkiaLabel extends SkiaControl {
131
158
  const weight = span.FontWeight > 0 ? span.FontWeight : span.IsBold ? 700 : this.fontWeight > 0 ? this.fontWeight : bold ? 700 : 0;
132
159
  return this.ResolveFonts(span.FontFamily ?? this.fontFamily, weight, italic, (span.FontSize ?? this.fontSize) * scale);
133
160
  }
134
- static Advance(font, text) {
161
+ Advance(font, text) {
135
162
  let w = 0;
136
163
  for (const adv of font.getGlyphWidths(font.getGlyphIDs(text)))
137
- w += adv;
164
+ w += adv + this.glyphSpacing;
138
165
  return w;
139
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
+ }
140
179
  /**
141
180
  * Splits text into runs by glyph availability: the main font, or the first fallback that has a glyph where the
142
181
  * main font has glyph 0. Spaces always stay on the main font (fallback faces often carry very wide spaces).
@@ -150,8 +189,13 @@ export class SkiaLabel extends SkiaControl {
150
189
  const main = fonts.Main;
151
190
  const fbs = fonts.Fallbacks;
152
191
  cached = [];
192
+ text = SkiaLabel.DropIgnorables(text, main, fbs);
193
+ if (text.length === 0) {
194
+ this.runCache.set(cacheKey, cached);
195
+ return cached;
196
+ }
153
197
  if (fbs.length === 0) {
154
- 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 });
155
199
  }
156
200
  else {
157
201
  const cps = Array.from(text);
@@ -170,13 +214,13 @@ export class SkiaLabel extends SkiaControl {
170
214
  const f = fontFor(i);
171
215
  if (f !== current) {
172
216
  const t = cps.slice(start, i).join("");
173
- 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 });
174
218
  start = i;
175
219
  current = f;
176
220
  }
177
221
  }
178
222
  const t = cps.slice(start).join("");
179
- 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 });
180
224
  }
181
225
  this.runCache.set(cacheKey, cached);
182
226
  }
@@ -184,6 +228,76 @@ export class SkiaLabel extends SkiaControl {
184
228
  }
185
229
  Width(runs) { let w = 0; for (const r of runs)
186
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
+ }
187
301
  Transform(text) {
188
302
  switch (this.textTransform) {
189
303
  case "Uppercase": return text.toUpperCase();
@@ -199,10 +313,16 @@ export class SkiaLabel extends SkiaControl {
199
313
  Tokenize(scale) {
200
314
  const paragraphs = [[]];
201
315
  const add = (text, fonts, span) => {
202
- 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);
203
319
  for (let p = 0; p < parts.length; p++) {
204
- if (p > 0)
205
- paragraphs.push([]);
320
+ if (p > 0) {
321
+ const next = [];
322
+ if (seps[p - 1] === "\u2028")
323
+ next.LineBreak = true;
324
+ paragraphs.push(next);
325
+ }
206
326
  const para = paragraphs[paragraphs.length - 1];
207
327
  const words = parts[p].split(" ");
208
328
  for (let w = 0; w < words.length; w++) {
@@ -248,13 +368,21 @@ export class SkiaLabel extends SkiaControl {
248
368
  line.Descent = r.Fonts.Descent;
249
369
  }
250
370
  }
251
- /** Word-wraps tokens into lines that fit maxWidth (Infinity = no wrap), applies MaxLines with a tail ellipsis. */
252
- 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) {
253
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;
254
380
  const out = [];
255
381
  const empty = (fonts) => { const l = this.NewLine(); l.Ascent = fonts.Ascent; l.Descent = fonts.Descent; return l; };
256
382
  for (const para of this.Tokenize(scale)) {
257
383
  let line = this.NewLine();
384
+ if (out.length > 0 && !para.LineBreak)
385
+ line.Para = true;
258
386
  let lastFonts = this.mainFonts;
259
387
  for (let i = 0; i < para.length; i++) {
260
388
  const tok = para[i];
@@ -265,46 +393,71 @@ export class SkiaLabel extends SkiaControl {
265
393
  i++;
266
394
  word = word.concat(this.Segment(para[i].Text, para[i].Fonts, para[i].Span));
267
395
  }
268
- const space = tok.SpaceBefore && line.Runs.length > 0 ? this.Segment(" ", tok.Fonts, tok.Span) : [];
269
- const spaceW = this.Width(space), wordW = this.Width(word);
270
- if (!wrap || line.Width + spaceW + wordW <= maxWidth || (line.Runs.length === 0 && wordW <= maxWidth)) {
271
- this.Append(line, space);
272
- this.Append(line, word);
273
- continue;
274
- }
275
- if (line.Runs.length > 0) {
276
- out.push(line);
277
- line = this.NewLine();
278
- }
279
- if (wordW <= maxWidth) {
280
- this.Append(line, word);
281
- continue;
282
- }
283
- // word longer than the line: break by code points
284
- for (const r of word) {
285
- for (const ch of Array.from(r.Text)) {
286
- const run = this.Segment(ch, r.Fonts, r.Span);
287
- const chW = this.Width(run);
288
- if (line.Runs.length > 0 && line.Width + chW > maxWidth) {
289
- out.push(line);
290
- 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);
291
430
  }
292
- this.Append(line, run);
293
431
  }
432
+ break;
294
433
  }
295
434
  }
296
435
  out.push(line.Runs.length > 0 ? line : empty(lastFonts));
297
436
  }
298
- if (this.maxLines > 0 && out.length > this.maxLines) {
299
- 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;
300
453
  const truncates = this.lineBreakMode === "TailTruncation" || this.lineBreakMode === "HeadTruncation" || this.lineBreakMode === "MiddleTruncation";
301
454
  if (truncates) {
302
- const last = out[this.maxLines - 1];
455
+ const last = out[kept - 1];
303
456
  const tailRun = last.Runs[last.Runs.length - 1];
304
457
  const fonts = tailRun ? tailRun.Fonts : this.mainFonts;
305
458
  const ell = this.Segment("…", fonts, tailRun?.Span);
306
459
  const ellW = this.Width(ell);
307
- while (last.Runs.length > 0 && last.Width + ellW > maxWidth) {
460
+ while (last.Runs.length > 0 && last.Width + ellW - sp > maxWidth) {
308
461
  const r = last.Runs[last.Runs.length - 1];
309
462
  const cps = Array.from(r.Text);
310
463
  cps.pop();
@@ -316,27 +469,39 @@ export class SkiaLabel extends SkiaControl {
316
469
  continue;
317
470
  }
318
471
  r.Text = cps.join("");
319
- r.Width = SkiaLabel.Advance(r.Font, r.Text);
472
+ r.Width = this.Advance(r.Font, r.Text);
320
473
  last.Width += r.Width;
321
474
  }
322
475
  this.Append(last, ell);
323
476
  }
324
477
  }
478
+ if (sp !== 0)
479
+ for (const l of out)
480
+ if (l.Runs.length > 0)
481
+ l.Width -= sp;
325
482
  return out;
326
483
  }
327
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; }
328
487
  BlockHeight() {
329
488
  let h = 0;
330
489
  for (let i = 0; i < this.lines.length; i++) {
331
490
  const lh = this.LineHeightPx(this.lines[i]);
332
- 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]);
333
492
  }
334
493
  return h;
335
494
  }
336
- MeasureAbsolute(widthConstraint, _heightConstraint, scale) {
495
+ MeasureAbsolute(widthConstraint, heightConstraint, scale) {
337
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
+ }
338
502
  const px = this.padding.HorizontalThickness * scale, py = this.padding.VerticalThickness * scale;
339
- 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) : [];
340
505
  let width = 0;
341
506
  for (const l of this.lines)
342
507
  width = Math.max(width, l.Width);
@@ -367,6 +532,7 @@ export class SkiaLabel extends SkiaControl {
367
532
  y += extra.Stroke / 2;
368
533
  const out = [];
369
534
  for (const line of this.lines) {
535
+ y += this.ParagraphSpace(line);
370
536
  const lh = this.LineHeightPx(line);
371
537
  const lineW = line.Width + extra.W;
372
538
  let x = left;
@@ -419,7 +585,7 @@ export class SkiaLabel extends SkiaControl {
419
585
  cps.forEach((cp, k) => {
420
586
  const w = widths[k] ?? 0;
421
587
  out.push({ Index: index, Length: cp.length, Line: li, Left: x, Top: g.y, Width: w, Height: g.h });
422
- x += w;
588
+ x += w + this.glyphSpacing;
423
589
  index += cp.length;
424
590
  });
425
591
  }
@@ -501,9 +667,25 @@ export class SkiaLabel extends SkiaControl {
501
667
  return paint;
502
668
  };
503
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
+ };
504
685
  const gradient = this.FillGradient;
505
686
  const textRect = new SKRect(left, top, right, bottom);
506
687
  for (const line of this.lines) {
688
+ y += this.ParagraphSpace(line);
507
689
  const lh = this.LineHeightPx(line);
508
690
  const lineW = line.Width + extra.W;
509
691
  let x = left;
@@ -519,22 +701,23 @@ export class SkiaLabel extends SkiaControl {
519
701
  this.SetupGradient(strokePaint, this.StrokeGradient, gradientRect ?? new SKRect(x, y, x + line.Width, y + lh));
520
702
  for (const run of line.Runs) {
521
703
  const span = run.Span;
704
+ const drawnW = run.Width - sp; // the gap after the run's last glyph is not part of it
522
705
  if (span) {
523
- 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));
524
707
  if (span.BackgroundColor)
525
- 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));
526
709
  }
527
710
  const color = span?.TextColor ?? this.textColor;
528
711
  if (run.Text) {
529
712
  // C# DrawText order: drop shadow, stroke, fill
530
713
  if (shadowPaint)
531
- canvas.drawText(run.Text, x + shadowDx, baseline + shadowDy, shadowPaint, run.Font);
714
+ drawRun(run, x + shadowDx, baseline + shadowDy, shadowPaint);
532
715
  if (strokePaint)
533
- canvas.drawText(run.Text, x, baseline, strokePaint, run.Font);
716
+ drawRun(run, x, baseline, strokePaint);
534
717
  const paint = paintFor(color);
535
718
  if (gradient && gradientRect)
536
719
  this.SetupGradient(paint, gradient, gradientRect);
537
- canvas.drawText(run.Text, x, baseline, paint, run.Font);
720
+ drawRun(run, x, baseline, paint);
538
721
  }
539
722
  if (span?.HasDecorations) {
540
723
  // same geometry as C# DrawSpanDecorations; CanvasKit exposes no underline/strikeout/x-height metrics,
@@ -542,11 +725,11 @@ export class SkiaLabel extends SkiaControl {
542
725
  if (span.Underline && span.UnderlineWidth !== 0) {
543
726
  const w = span.UnderlineWidth > 0 ? span.UnderlineWidth * scale : -span.UnderlineWidth;
544
727
  const yl = Math.round(baseline + scale);
545
- canvas.drawLine(x, yl, x + run.Width, yl, paintFor(color, w));
728
+ canvas.drawLine(x, yl, x + drawnW, yl, paintFor(color, w));
546
729
  }
547
730
  if (span.Strikeout) {
548
731
  const yl = Math.round(baseline - (run.Fonts.SizePx * 0.52) / 2);
549
- 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));
550
733
  }
551
734
  }
552
735
  x += run.Width;
@@ -0,0 +1,16 @@
1
+ import type { DrawingContext } from "../core/SkiaControl";
2
+ import type { SKRect } from "../core/Types";
3
+ import { SkiaLabel } from "./SkiaLabel";
4
+ /**
5
+ * Mirrors DrawnUi SkiaLabelFps: a label showing the canvas FPS, drawn above its siblings, input-transparent, never
6
+ * re-laying out its parent. Divergence: C# sets Text on every draw, and each new value asks for another frame, so
7
+ * the counter keeps an idle canvas redrawing. Here the value changes only on frames that running animators already
8
+ * request (scroll inertia, transitions, Lottie, game loops): the counter never asks for a frame itself, and an idle
9
+ * canvas keeps the last value measured. Not ported: ForceRefresh, Format, MonoForDigits.
10
+ */
11
+ export declare class SkiaLabelFps extends SkiaLabel {
12
+ constructor();
13
+ private arrangedIn?;
14
+ Arrange(destination: SKRect, widthRequest: number, heightRequest: number, scale: number): void;
15
+ Render(ctx: DrawingContext): void;
16
+ }
@@ -0,0 +1,41 @@
1
+ import { Aria } from "../core/Accessibility";
2
+ import { SkiaLabel } from "./SkiaLabel";
3
+ /**
4
+ * Mirrors DrawnUi SkiaLabelFps: a label showing the canvas FPS, drawn above its siblings, input-transparent, never
5
+ * re-laying out its parent. Divergence: C# sets Text on every draw, and each new value asks for another frame, so
6
+ * the counter keeps an idle canvas redrawing. Here the value changes only on frames that running animators already
7
+ * request (scroll inertia, transitions, Lottie, game loops): the counter never asks for a frame itself, and an idle
8
+ * canvas keeps the last value measured. Not ported: ForceRefresh, Format, MonoForDigits.
9
+ */
10
+ export class SkiaLabelFps extends SkiaLabel {
11
+ constructor() {
12
+ super();
13
+ this.IsParentIndependent = true;
14
+ this.AccessibilityRole = Aria.RolePresentation;
15
+ this.Tag = "FPS";
16
+ this.Text = "FPS --";
17
+ this.MaxLines = 1;
18
+ this.BackgroundColor = "#000000";
19
+ this.TextColor = "#32CD32"; // LimeGreen
20
+ this.InputTransparent = true;
21
+ this.ZIndex = Number.MAX_SAFE_INTEGER;
22
+ }
23
+ arrangedIn;
24
+ Arrange(destination, widthRequest, heightRequest, scale) {
25
+ this.arrangedIn = destination;
26
+ super.Arrange(destination, widthRequest, heightRequest, scale);
27
+ }
28
+ Render(ctx) {
29
+ const view = this.Superview;
30
+ // the frame this asks for is requested anyway by the running animators
31
+ if (view && view.AnimatingControls.size > 0)
32
+ this.Text = `FPS ${view.FPS.toFixed(1).padStart(4, "0")}`;
33
+ // parent-independent: the parent does not re-lay out for it, so it re-measures itself here (as SkiaScroll does for its bars)
34
+ const d = this.arrangedIn;
35
+ if (this.NeedMeasure && d) {
36
+ this.Measure(d.Width, d.Height, ctx.Scale);
37
+ this.Arrange(d, this.WidthRequest, this.HeightRequest, ctx.Scale);
38
+ }
39
+ super.Render(ctx);
40
+ }
41
+ }