drawnui-react 0.1.0-preview.1
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.
- package/LICENSE +21 -0
- package/PARITY.md +287 -0
- package/README.md +92 -0
- package/SKIPPED.md +107 -0
- package/dist/controls/AnimatedFramesRenderer.d.ts +58 -0
- package/dist/controls/AnimatedFramesRenderer.js +133 -0
- package/dist/controls/GridStructure.d.ts +79 -0
- package/dist/controls/GridStructure.js +403 -0
- package/dist/controls/RefreshIndicator.d.ts +35 -0
- package/dist/controls/RefreshIndicator.js +82 -0
- package/dist/controls/SkiaBackdrop.d.ts +25 -0
- package/dist/controls/SkiaBackdrop.js +80 -0
- package/dist/controls/SkiaButton.d.ts +58 -0
- package/dist/controls/SkiaButton.js +148 -0
- package/dist/controls/SkiaCarousel.d.ts +133 -0
- package/dist/controls/SkiaCarousel.js +603 -0
- package/dist/controls/SkiaCheckbox.d.ts +21 -0
- package/dist/controls/SkiaCheckbox.js +118 -0
- package/dist/controls/SkiaDecoratedGrid.d.ts +15 -0
- package/dist/controls/SkiaDecoratedGrid.js +46 -0
- package/dist/controls/SkiaDrawer.d.ts +46 -0
- package/dist/controls/SkiaDrawer.js +212 -0
- package/dist/controls/SkiaDynamicDrawnCell.d.ts +11 -0
- package/dist/controls/SkiaDynamicDrawnCell.js +17 -0
- package/dist/controls/SkiaEditor.d.ts +163 -0
- package/dist/controls/SkiaEditor.js +803 -0
- package/dist/controls/SkiaGif.d.ts +54 -0
- package/dist/controls/SkiaGif.js +206 -0
- package/dist/controls/SkiaHotspot.d.ts +13 -0
- package/dist/controls/SkiaHotspot.js +36 -0
- package/dist/controls/SkiaImage.d.ts +75 -0
- package/dist/controls/SkiaImage.js +222 -0
- package/dist/controls/SkiaImageTiles.d.ts +27 -0
- package/dist/controls/SkiaImageTiles.js +76 -0
- package/dist/controls/SkiaLabel.d.ts +160 -0
- package/dist/controls/SkiaLabel.js +580 -0
- package/dist/controls/SkiaLayout.d.ts +163 -0
- package/dist/controls/SkiaLayout.js +723 -0
- package/dist/controls/SkiaLottie.d.ts +75 -0
- package/dist/controls/SkiaLottie.js +290 -0
- package/dist/controls/SkiaProgress.d.ts +40 -0
- package/dist/controls/SkiaProgress.js +80 -0
- package/dist/controls/SkiaRadioButton.d.ts +32 -0
- package/dist/controls/SkiaRadioButton.js +157 -0
- package/dist/controls/SkiaRichLabel.d.ts +77 -0
- package/dist/controls/SkiaRichLabel.js +274 -0
- package/dist/controls/SkiaScroll.d.ts +231 -0
- package/dist/controls/SkiaScroll.js +958 -0
- package/dist/controls/SkiaScrollBar.d.ts +56 -0
- package/dist/controls/SkiaScrollBar.js +158 -0
- package/dist/controls/SkiaShaderCarousel.d.ts +67 -0
- package/dist/controls/SkiaShaderCarousel.js +246 -0
- package/dist/controls/SkiaShape.d.ts +63 -0
- package/dist/controls/SkiaShape.js +317 -0
- package/dist/controls/SkiaSlider.d.ts +74 -0
- package/dist/controls/SkiaSlider.js +243 -0
- package/dist/controls/SkiaSprite.d.ts +99 -0
- package/dist/controls/SkiaSprite.js +324 -0
- package/dist/controls/SkiaSpriteSet.d.ts +27 -0
- package/dist/controls/SkiaSpriteSet.js +75 -0
- package/dist/controls/SkiaSvg.d.ts +43 -0
- package/dist/controls/SkiaSvg.js +181 -0
- package/dist/controls/SkiaSwitch.d.ts +26 -0
- package/dist/controls/SkiaSwitch.js +147 -0
- package/dist/controls/SkiaToggle.d.ts +50 -0
- package/dist/controls/SkiaToggle.js +71 -0
- package/dist/controls/SnappingLayout.d.ts +78 -0
- package/dist/controls/SnappingLayout.js +217 -0
- package/dist/controls/TextSpan.d.ts +70 -0
- package/dist/controls/TextSpan.js +76 -0
- package/dist/core/Accessibility.d.ts +104 -0
- package/dist/core/Accessibility.js +150 -0
- package/dist/core/Animators.d.ts +82 -0
- package/dist/core/Animators.js +247 -0
- package/dist/core/Canvas.d.ts +89 -0
- package/dist/core/Canvas.js +328 -0
- package/dist/core/ControlStyle.d.ts +6 -0
- package/dist/core/ControlStyle.js +11 -0
- package/dist/core/Easing.d.ts +13 -0
- package/dist/core/Easing.js +15 -0
- package/dist/core/Gestures.d.ts +74 -0
- package/dist/core/Gestures.js +104 -0
- package/dist/core/ImageEffects.d.ts +29 -0
- package/dist/core/ImageEffects.js +55 -0
- package/dist/core/KeyboardManager.d.ts +25 -0
- package/dist/core/KeyboardManager.js +54 -0
- package/dist/core/ScrollAnimators.d.ts +90 -0
- package/dist/core/ScrollAnimators.js +244 -0
- package/dist/core/SkiaControl.d.ts +401 -0
- package/dist/core/SkiaControl.js +1239 -0
- package/dist/core/SkiaEffect.d.ts +216 -0
- package/dist/core/SkiaEffect.js +571 -0
- package/dist/core/SkiaImageManager.d.ts +43 -0
- package/dist/core/SkiaImageManager.js +122 -0
- package/dist/core/Super.d.ts +61 -0
- package/dist/core/Super.js +178 -0
- package/dist/core/TextInputProxy.d.ts +31 -0
- package/dist/core/TextInputProxy.js +144 -0
- package/dist/core/Types.d.ts +177 -0
- package/dist/core/Types.js +122 -0
- package/dist/core/ViewsAdapter.d.ts +41 -0
- package/dist/core/ViewsAdapter.js +113 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +48 -0
- package/dist/react/SkiaShell.d.ts +144 -0
- package/dist/react/SkiaShell.js +490 -0
- package/dist/react/index.d.ts +110 -0
- package/dist/react/index.js +158 -0
- package/dist/react/reconciler.d.ts +14 -0
- package/dist/react/reconciler.js +160 -0
- package/package.json +60 -0
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
import { SkiaControl } from "../core/SkiaControl";
|
|
2
|
+
import { ControlTappedEventArgs } from "../core/Gestures";
|
|
3
|
+
import { Super } from "../core/Super";
|
|
4
|
+
import { Colors, ScaledSize, SKRect, Thickness, } from "../core/Types";
|
|
5
|
+
import { TextSpan } from "./TextSpan";
|
|
6
|
+
/**
|
|
7
|
+
* Mirrors DrawnUi SkiaLabel: multi-line text with word wrapping, MaxLines + tail ellipsis, horizontal /
|
|
8
|
+
* vertical alignment, LineSpacing / LineHeight, weights and attributes resolved through the font registry,
|
|
9
|
+
* the opt-in per-codepoint FontFamilyFallback (symbols/emoji missing from the main face) and `Spans`
|
|
10
|
+
* (TextSpan children: per-fragment color/size/weight/italic/underline/strikeout/background/Tapped).
|
|
11
|
+
* Cached as Operations by default, like DrawnUi. Every text property invalidates like a bindable property.
|
|
12
|
+
*/
|
|
13
|
+
export class SkiaLabel extends SkiaControl {
|
|
14
|
+
text = "";
|
|
15
|
+
fontSize = 12;
|
|
16
|
+
textColor = Colors.GreenYellow;
|
|
17
|
+
fontFamily = "";
|
|
18
|
+
fontFamilyFallback = "";
|
|
19
|
+
fontWeight = 0;
|
|
20
|
+
fontAttributes = "None";
|
|
21
|
+
maxLines = -1;
|
|
22
|
+
lineBreakMode = "TailTruncation";
|
|
23
|
+
horizontalTextAlignment = "Start";
|
|
24
|
+
verticalTextAlignment = "Start";
|
|
25
|
+
lineSpacing = 1;
|
|
26
|
+
lineHeight = 1;
|
|
27
|
+
textTransform = "None";
|
|
28
|
+
padding = Thickness.Zero;
|
|
29
|
+
/** Styled fragments; when not empty they replace `Text` (same precedence as C#). */
|
|
30
|
+
Spans = [];
|
|
31
|
+
/** Set to `Aria.RoleText` to expose every label to screen readers (React extension; C# is opt-in per control). */
|
|
32
|
+
static DefaultAccessibilityRole;
|
|
33
|
+
/** Like C# OnTextInternalChanged: the spoken label is the text (or the joined spans) unless AccessibilityLabel is set. */
|
|
34
|
+
DefaultAccessibilityLabel() {
|
|
35
|
+
return this.Spans.length > 0 ? this.Spans.map((s) => s.Text).join("") : this.text || undefined;
|
|
36
|
+
}
|
|
37
|
+
constructor() {
|
|
38
|
+
super();
|
|
39
|
+
this.UseCache = "Operations";
|
|
40
|
+
}
|
|
41
|
+
// ---- invalidating accessors (DrawnUi bindable properties) ----
|
|
42
|
+
Set(key, v) { if (this[key] !== v) {
|
|
43
|
+
this[key] = v;
|
|
44
|
+
this.Update();
|
|
45
|
+
} }
|
|
46
|
+
get Text() { return this.text; }
|
|
47
|
+
set Text(v) { this.Set("text", v); }
|
|
48
|
+
get FontSize() { return this.fontSize; }
|
|
49
|
+
set FontSize(v) { this.Set("fontSize", v); }
|
|
50
|
+
get TextColor() { return this.textColor; }
|
|
51
|
+
set TextColor(v) { this.Set("textColor", v); }
|
|
52
|
+
get FontFamily() { return this.fontFamily; }
|
|
53
|
+
set FontFamily(v) { this.Set("fontFamily", v); }
|
|
54
|
+
/**
|
|
55
|
+
* Alias (or comma-separated chain of aliases) tried per codepoint when the main font has no glyph,
|
|
56
|
+
* e.g. "FontSymbols,FontSymbols2" from `AddSymbols()`. Same idea as C# FontFamilyFallback, extended to a chain.
|
|
57
|
+
*/
|
|
58
|
+
get FontFamilyFallback() { return this.fontFamilyFallback; }
|
|
59
|
+
set FontFamilyFallback(v) { this.Set("fontFamilyFallback", v); }
|
|
60
|
+
get FontWeight() { return this.fontWeight; }
|
|
61
|
+
set FontWeight(v) { this.Set("fontWeight", v); }
|
|
62
|
+
get FontAttributes() { return this.fontAttributes; }
|
|
63
|
+
set FontAttributes(v) { this.Set("fontAttributes", v); }
|
|
64
|
+
get MaxLines() { return this.maxLines; }
|
|
65
|
+
set MaxLines(v) { this.Set("maxLines", v); }
|
|
66
|
+
get LineBreakMode() { return this.lineBreakMode; }
|
|
67
|
+
set LineBreakMode(v) { this.Set("lineBreakMode", v); }
|
|
68
|
+
get HorizontalTextAlignment() { return this.horizontalTextAlignment; }
|
|
69
|
+
set HorizontalTextAlignment(v) { this.Set("horizontalTextAlignment", v); }
|
|
70
|
+
get VerticalTextAlignment() { return this.verticalTextAlignment; }
|
|
71
|
+
set VerticalTextAlignment(v) { this.Set("verticalTextAlignment", v); }
|
|
72
|
+
get LineSpacing() { return this.lineSpacing; }
|
|
73
|
+
set LineSpacing(v) { this.Set("lineSpacing", v); }
|
|
74
|
+
/** Multiplier applied to the natural line height (ascent + descent). */
|
|
75
|
+
get LineHeight() { return this.lineHeight; }
|
|
76
|
+
set LineHeight(v) { this.Set("lineHeight", v); }
|
|
77
|
+
get TextTransform() { return this.textTransform; }
|
|
78
|
+
set TextTransform(v) { this.Set("textTransform", v); }
|
|
79
|
+
get Padding() { return this.padding; }
|
|
80
|
+
set Padding(v) { this.Set("padding", v); }
|
|
81
|
+
/** Number of laid-out lines after the last measure. */
|
|
82
|
+
get LinesCount() { return this.lines.length; }
|
|
83
|
+
// ---- spans as children (reconciler AddSubView / InsertSubView / RemoveSubView) ----
|
|
84
|
+
AddSubView(control) { this.InsertSubView(this.Spans.length, control); }
|
|
85
|
+
InsertSubView(index, control) {
|
|
86
|
+
if (!(control instanceof TextSpan))
|
|
87
|
+
throw new Error("DrawnUi: SkiaLabel children must be <TextSpan>");
|
|
88
|
+
control.Parent = this;
|
|
89
|
+
this.Spans.splice(index, 0, control);
|
|
90
|
+
this.Update();
|
|
91
|
+
}
|
|
92
|
+
RemoveSubView(control) {
|
|
93
|
+
const i = this.Spans.indexOf(control);
|
|
94
|
+
if (i < 0)
|
|
95
|
+
return;
|
|
96
|
+
this.Spans.splice(i, 1);
|
|
97
|
+
control.Parent = undefined;
|
|
98
|
+
this.Update();
|
|
99
|
+
}
|
|
100
|
+
// ---- layout ----
|
|
101
|
+
lines = [];
|
|
102
|
+
mainFonts;
|
|
103
|
+
fontsCache = new Map();
|
|
104
|
+
runCache = new Map();
|
|
105
|
+
ResolveFonts(family, weight, italic, sizePx) {
|
|
106
|
+
const key = `${family}|${this.fontFamilyFallback}|${weight}|${italic}|${sizePx}`;
|
|
107
|
+
let f = this.fontsCache.get(key);
|
|
108
|
+
if (f)
|
|
109
|
+
return f;
|
|
110
|
+
const main = Super.GetFont(family, weight, italic, sizePx);
|
|
111
|
+
const m = main.getMetrics();
|
|
112
|
+
f = {
|
|
113
|
+
Key: key, Main: main, SizePx: sizePx, Ascent: -m.ascent, Descent: m.descent,
|
|
114
|
+
Fallbacks: this.fontFamilyFallback
|
|
115
|
+
? this.fontFamilyFallback.split(",").map((a) => a.trim()).filter(Boolean).map((a) => Super.GetFont(a, weight, italic, sizePx))
|
|
116
|
+
: [],
|
|
117
|
+
};
|
|
118
|
+
this.fontsCache.set(key, f);
|
|
119
|
+
return f;
|
|
120
|
+
}
|
|
121
|
+
/** The label's own style, and the base every span inherits from. */
|
|
122
|
+
ResolveMainFonts(scale) {
|
|
123
|
+
const bold = this.fontAttributes === "Bold" || this.fontAttributes === "BoldItalic";
|
|
124
|
+
const italic = this.fontAttributes === "Italic" || this.fontAttributes === "BoldItalic";
|
|
125
|
+
const weight = this.fontWeight > 0 ? this.fontWeight : bold ? 700 : 0;
|
|
126
|
+
return this.ResolveFonts(this.fontFamily, weight, italic, this.fontSize * scale);
|
|
127
|
+
}
|
|
128
|
+
ResolveSpanFonts(span, scale) {
|
|
129
|
+
const bold = span.IsBold || this.fontAttributes === "Bold" || this.fontAttributes === "BoldItalic";
|
|
130
|
+
const italic = span.IsItalic || this.fontAttributes === "Italic" || this.fontAttributes === "BoldItalic";
|
|
131
|
+
const weight = span.FontWeight > 0 ? span.FontWeight : span.IsBold ? 700 : this.fontWeight > 0 ? this.fontWeight : bold ? 700 : 0;
|
|
132
|
+
return this.ResolveFonts(span.FontFamily ?? this.fontFamily, weight, italic, (span.FontSize ?? this.fontSize) * scale);
|
|
133
|
+
}
|
|
134
|
+
static Advance(font, text) {
|
|
135
|
+
let w = 0;
|
|
136
|
+
for (const adv of font.getGlyphWidths(font.getGlyphIDs(text)))
|
|
137
|
+
w += adv;
|
|
138
|
+
return w;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Splits text into runs by glyph availability: the main font, or the first fallback that has a glyph where the
|
|
142
|
+
* main font has glyph 0. Spaces always stay on the main font (fallback faces often carry very wide spaces).
|
|
143
|
+
*/
|
|
144
|
+
Segment(text, fonts, span) {
|
|
145
|
+
if (text.length === 0)
|
|
146
|
+
return [];
|
|
147
|
+
const cacheKey = fonts.Key + " " + text;
|
|
148
|
+
let cached = this.runCache.get(cacheKey);
|
|
149
|
+
if (!cached) {
|
|
150
|
+
const main = fonts.Main;
|
|
151
|
+
const fbs = fonts.Fallbacks;
|
|
152
|
+
cached = [];
|
|
153
|
+
if (fbs.length === 0) {
|
|
154
|
+
cached.push({ Text: text, Font: main, Width: SkiaLabel.Advance(main, text), Fonts: fonts });
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
const cps = Array.from(text);
|
|
158
|
+
const mainIds = main.getGlyphIDs(text, cps.length);
|
|
159
|
+
const fbIds = fbs.map((f) => f.getGlyphIDs(text, cps.length));
|
|
160
|
+
const fontFor = (i) => {
|
|
161
|
+
if (cps[i] === " " || mainIds[i] !== 0)
|
|
162
|
+
return main;
|
|
163
|
+
for (let k = 0; k < fbs.length; k++)
|
|
164
|
+
if (fbIds[k][i] !== 0)
|
|
165
|
+
return fbs[k];
|
|
166
|
+
return main;
|
|
167
|
+
};
|
|
168
|
+
let start = 0, current = fontFor(0);
|
|
169
|
+
for (let i = 1; i < cps.length; i++) {
|
|
170
|
+
const f = fontFor(i);
|
|
171
|
+
if (f !== current) {
|
|
172
|
+
const t = cps.slice(start, i).join("");
|
|
173
|
+
cached.push({ Text: t, Font: current, Width: SkiaLabel.Advance(current, t), Fonts: fonts });
|
|
174
|
+
start = i;
|
|
175
|
+
current = f;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const t = cps.slice(start).join("");
|
|
179
|
+
cached.push({ Text: t, Font: current, Width: SkiaLabel.Advance(current, t), Fonts: fonts });
|
|
180
|
+
}
|
|
181
|
+
this.runCache.set(cacheKey, cached);
|
|
182
|
+
}
|
|
183
|
+
return span ? cached.map((r) => ({ ...r, Span: span })) : cached;
|
|
184
|
+
}
|
|
185
|
+
Width(runs) { let w = 0; for (const r of runs)
|
|
186
|
+
w += r.Width; return w; }
|
|
187
|
+
Transform(text) {
|
|
188
|
+
switch (this.textTransform) {
|
|
189
|
+
case "Uppercase": return text.toUpperCase();
|
|
190
|
+
case "Lowercase": return text.toLowerCase();
|
|
191
|
+
case "Titlecase": return text.replace(/(^|\s)(\S)/g, (_, s, c) => s + c.toUpperCase());
|
|
192
|
+
default: return text;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Paragraphs (split on "\n") of wrap tokens, built from Spans when present, else from Text.
|
|
197
|
+
* A fragment that does not start with a space glues to the previous word (no break opportunity is added).
|
|
198
|
+
*/
|
|
199
|
+
Tokenize(scale) {
|
|
200
|
+
const paragraphs = [[]];
|
|
201
|
+
const add = (text, fonts, span) => {
|
|
202
|
+
const parts = this.Transform(text).split("\n");
|
|
203
|
+
for (let p = 0; p < parts.length; p++) {
|
|
204
|
+
if (p > 0)
|
|
205
|
+
paragraphs.push([]);
|
|
206
|
+
const para = paragraphs[paragraphs.length - 1];
|
|
207
|
+
const words = parts[p].split(" ");
|
|
208
|
+
for (let w = 0; w < words.length; w++) {
|
|
209
|
+
if (words[w] === "")
|
|
210
|
+
continue;
|
|
211
|
+
para.push({ Text: words[w], Fonts: fonts, Span: span, SpaceBefore: w > 0 || (para.length > 0 && parts[p].startsWith(" ")) });
|
|
212
|
+
}
|
|
213
|
+
if (parts[p].endsWith(" ") && para.length > 0)
|
|
214
|
+
para[para.length - 1].TrailingSpace = true;
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
if (this.Spans.length > 0) {
|
|
218
|
+
for (const s of this.Spans)
|
|
219
|
+
if (s.IsVisible && s.Text)
|
|
220
|
+
add(s.Text, this.ResolveSpanFonts(s, scale), s);
|
|
221
|
+
}
|
|
222
|
+
else if (this.text) {
|
|
223
|
+
add(this.text, this.mainFonts);
|
|
224
|
+
}
|
|
225
|
+
// a fragment ending with a space puts the space before the NEXT token
|
|
226
|
+
for (const para of paragraphs) {
|
|
227
|
+
for (let i = 0; i < para.length; i++) {
|
|
228
|
+
if (para[i].TrailingSpace && i + 1 < para.length)
|
|
229
|
+
para[i + 1].SpaceBefore = true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return paragraphs;
|
|
233
|
+
}
|
|
234
|
+
NewLine() { return { Runs: [], Width: 0, Ascent: 0, Descent: 0 }; }
|
|
235
|
+
Append(line, runs) {
|
|
236
|
+
for (const r of runs) {
|
|
237
|
+
const last = line.Runs[line.Runs.length - 1];
|
|
238
|
+
if (last && last.Font === r.Font && last.Span === r.Span) {
|
|
239
|
+
last.Text += r.Text;
|
|
240
|
+
last.Width += r.Width;
|
|
241
|
+
}
|
|
242
|
+
else
|
|
243
|
+
line.Runs.push({ ...r });
|
|
244
|
+
line.Width += r.Width;
|
|
245
|
+
if (r.Fonts.Ascent > line.Ascent)
|
|
246
|
+
line.Ascent = r.Fonts.Ascent;
|
|
247
|
+
if (r.Fonts.Descent > line.Descent)
|
|
248
|
+
line.Descent = r.Fonts.Descent;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/** Word-wraps tokens into lines that fit maxWidth (Infinity = no wrap), applies MaxLines with a tail ellipsis. */
|
|
252
|
+
LayoutLines(maxWidth, scale) {
|
|
253
|
+
const wrap = this.lineBreakMode !== "NoWrap" && isFinite(maxWidth);
|
|
254
|
+
const out = [];
|
|
255
|
+
const empty = (fonts) => { const l = this.NewLine(); l.Ascent = fonts.Ascent; l.Descent = fonts.Descent; return l; };
|
|
256
|
+
for (const para of this.Tokenize(scale)) {
|
|
257
|
+
let line = this.NewLine();
|
|
258
|
+
let lastFonts = this.mainFonts;
|
|
259
|
+
for (let i = 0; i < para.length; i++) {
|
|
260
|
+
const tok = para[i];
|
|
261
|
+
lastFonts = tok.Fonts;
|
|
262
|
+
// glued fragments (no space between spans) wrap as one word
|
|
263
|
+
let word = this.Segment(tok.Text, tok.Fonts, tok.Span);
|
|
264
|
+
while (i + 1 < para.length && !para[i + 1].SpaceBefore) {
|
|
265
|
+
i++;
|
|
266
|
+
word = word.concat(this.Segment(para[i].Text, para[i].Fonts, para[i].Span));
|
|
267
|
+
}
|
|
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();
|
|
291
|
+
}
|
|
292
|
+
this.Append(line, run);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
out.push(line.Runs.length > 0 ? line : empty(lastFonts));
|
|
297
|
+
}
|
|
298
|
+
if (this.maxLines > 0 && out.length > this.maxLines) {
|
|
299
|
+
out.length = this.maxLines;
|
|
300
|
+
const truncates = this.lineBreakMode === "TailTruncation" || this.lineBreakMode === "HeadTruncation" || this.lineBreakMode === "MiddleTruncation";
|
|
301
|
+
if (truncates) {
|
|
302
|
+
const last = out[this.maxLines - 1];
|
|
303
|
+
const tailRun = last.Runs[last.Runs.length - 1];
|
|
304
|
+
const fonts = tailRun ? tailRun.Fonts : this.mainFonts;
|
|
305
|
+
const ell = this.Segment("…", fonts, tailRun?.Span);
|
|
306
|
+
const ellW = this.Width(ell);
|
|
307
|
+
while (last.Runs.length > 0 && last.Width + ellW > maxWidth) {
|
|
308
|
+
const r = last.Runs[last.Runs.length - 1];
|
|
309
|
+
const cps = Array.from(r.Text);
|
|
310
|
+
cps.pop();
|
|
311
|
+
while (cps.length > 0 && cps[cps.length - 1] === " ")
|
|
312
|
+
cps.pop();
|
|
313
|
+
last.Width -= r.Width;
|
|
314
|
+
if (cps.length === 0) {
|
|
315
|
+
last.Runs.pop();
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
r.Text = cps.join("");
|
|
319
|
+
r.Width = SkiaLabel.Advance(r.Font, r.Text);
|
|
320
|
+
last.Width += r.Width;
|
|
321
|
+
}
|
|
322
|
+
this.Append(last, ell);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
LineHeightPx(line) { return (line.Ascent + line.Descent) * this.lineHeight; }
|
|
328
|
+
BlockHeight() {
|
|
329
|
+
let h = 0;
|
|
330
|
+
for (let i = 0; i < this.lines.length; i++) {
|
|
331
|
+
const lh = this.LineHeightPx(this.lines[i]);
|
|
332
|
+
h += i < this.lines.length - 1 ? lh * this.lineSpacing : lh;
|
|
333
|
+
}
|
|
334
|
+
return h;
|
|
335
|
+
}
|
|
336
|
+
MeasureAbsolute(widthConstraint, _heightConstraint, scale) {
|
|
337
|
+
this.mainFonts = this.ResolveMainFonts(scale);
|
|
338
|
+
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) : [];
|
|
340
|
+
let width = 0;
|
|
341
|
+
for (const l of this.lines)
|
|
342
|
+
width = Math.max(width, l.Width);
|
|
343
|
+
const extra = this.EffectsExtra(scale);
|
|
344
|
+
this.LayoutVersion++;
|
|
345
|
+
return ScaledSize.FromPixels(Math.ceil(width + extra.W) + px, Math.ceil(this.BlockHeight() + extra.H) + py, scale);
|
|
346
|
+
}
|
|
347
|
+
/** C# GradientByLines: FillGradient spans each line's bounds (default) instead of the whole text block. */
|
|
348
|
+
GradientByLines = true;
|
|
349
|
+
/** C# KeepSpacesOnLineBreaks / NeedsGlyphPositions: accepted; glyph boxes are always available through GetGlyphBoxes. */
|
|
350
|
+
KeepSpacesOnLineBreaks = false;
|
|
351
|
+
NeedsGlyphPositions = false;
|
|
352
|
+
/** Bumped on every measure: consumers cache GetGlyphBoxes() against it. */
|
|
353
|
+
LayoutVersion = 0;
|
|
354
|
+
/** Height of the first laid-out line in pixels (C# MeasuredLineHeight); 0 before the first measure. */
|
|
355
|
+
get MeasuredLineHeight() { return this.lines.length ? this.LineHeightPx(this.lines[0]) : 0; }
|
|
356
|
+
/** Line origins relative to DrawingRect, same alignment math as Paint. */
|
|
357
|
+
LineGeometry() {
|
|
358
|
+
const scale = this.RenderingScale, d = this.DrawingRect, p = this.padding;
|
|
359
|
+
const left = p.Left * scale, right = d.Width - p.Right * scale, top = p.Top * scale, bottom = d.Height - p.Bottom * scale;
|
|
360
|
+
const extra = this.EffectsExtra(scale);
|
|
361
|
+
const blockH = this.BlockHeight() + extra.H;
|
|
362
|
+
let y = top;
|
|
363
|
+
if (this.verticalTextAlignment === "Center")
|
|
364
|
+
y = top + (bottom - top - blockH) / 2;
|
|
365
|
+
else if (this.verticalTextAlignment === "End")
|
|
366
|
+
y = bottom - blockH;
|
|
367
|
+
y += extra.Stroke / 2;
|
|
368
|
+
const out = [];
|
|
369
|
+
for (const line of this.lines) {
|
|
370
|
+
const lh = this.LineHeightPx(line);
|
|
371
|
+
const lineW = line.Width + extra.W;
|
|
372
|
+
let x = left;
|
|
373
|
+
if (this.horizontalTextAlignment === "Center")
|
|
374
|
+
x = left + (right - left - lineW) / 2;
|
|
375
|
+
else if (this.horizontalTextAlignment === "End")
|
|
376
|
+
x = right - lineW;
|
|
377
|
+
x += extra.Stroke / 2;
|
|
378
|
+
out.push({ x, y, w: line.Width, h: lh, text: line.Runs.map((r) => r.Text).join("") });
|
|
379
|
+
y += lh * this.lineSpacing;
|
|
380
|
+
}
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
/** Lines as CSS text for the accessibility overlay (AccessibilityTextSelectable): font alias chain, weight and px size. */
|
|
384
|
+
GetAccessibilityTextLines(scale) {
|
|
385
|
+
const family = [this.fontFamily || Super.DefaultFontAlias, ...this.fontFamilyFallback.split(",").map((f) => f.trim()).filter(Boolean)].filter(Boolean).join(", ");
|
|
386
|
+
const weight = this.fontWeight > 0 ? this.fontWeight : this.fontAttributes === "Bold" || this.fontAttributes === "BoldItalic" ? 600 : 400;
|
|
387
|
+
const size = this.fontSize;
|
|
388
|
+
return this.LineGeometry().map((g) => ({ Text: g.text, Left: g.x / scale, Top: g.y / scale, Width: g.w / scale, Height: g.h / scale, FontFamily: family, FontWeight: weight, FontSize: size }));
|
|
389
|
+
}
|
|
390
|
+
/** Laid-out lines with their text index range (a wrapped-away space or line break sits between two lines). */
|
|
391
|
+
GetLineBoxes() {
|
|
392
|
+
const geo = this.LineGeometry();
|
|
393
|
+
const out = [];
|
|
394
|
+
let cursor = 0;
|
|
395
|
+
geo.forEach((g, i) => {
|
|
396
|
+
let start = g.text.length ? this.text.indexOf(g.text, cursor) : cursor;
|
|
397
|
+
if (start < 0)
|
|
398
|
+
start = cursor;
|
|
399
|
+
const end = start + g.text.length;
|
|
400
|
+
out.push({ Line: i, Start: start, End: end, Left: g.x, Top: g.y, Width: g.w, Height: g.h });
|
|
401
|
+
cursor = end;
|
|
402
|
+
});
|
|
403
|
+
return out;
|
|
404
|
+
}
|
|
405
|
+
/** Every code point of the laid-out text with its box (editor caret / selection / hit testing). */
|
|
406
|
+
GetGlyphBoxes() {
|
|
407
|
+
const out = [];
|
|
408
|
+
const geo = this.LineGeometry();
|
|
409
|
+
let cursor = 0;
|
|
410
|
+
this.lines.forEach((line, li) => {
|
|
411
|
+
const g = geo[li];
|
|
412
|
+
let start = g.text.length ? this.text.indexOf(g.text, cursor) : cursor;
|
|
413
|
+
if (start < 0)
|
|
414
|
+
start = cursor;
|
|
415
|
+
let x = g.x, index = start;
|
|
416
|
+
for (const run of line.Runs) {
|
|
417
|
+
const cps = Array.from(run.Text);
|
|
418
|
+
const widths = run.Font.getGlyphWidths(run.Font.getGlyphIDs(run.Text, cps.length));
|
|
419
|
+
cps.forEach((cp, k) => {
|
|
420
|
+
const w = widths[k] ?? 0;
|
|
421
|
+
out.push({ Index: index, Length: cp.length, Line: li, Left: x, Top: g.y, Width: w, Height: g.h });
|
|
422
|
+
x += w;
|
|
423
|
+
index += cp.length;
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
cursor = start + g.text.length;
|
|
427
|
+
});
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
/** Outline around the glyphs, drawn under the fill (C# StrokeColor / StrokeWidth in points; Transparent = none). */
|
|
431
|
+
StrokeColor = Colors.Transparent;
|
|
432
|
+
StrokeWidth = 1;
|
|
433
|
+
StrokeGradient;
|
|
434
|
+
/** Drop shadow: a stroked copy of the text offset by DropShadowOffsetX/Y points (C# DropShadow*). */
|
|
435
|
+
DropShadowColor = Colors.Transparent;
|
|
436
|
+
DropShadowSize = 2;
|
|
437
|
+
DropShadowOffsetX = 2;
|
|
438
|
+
DropShadowOffsetY = 2;
|
|
439
|
+
HasStroke() { return this.StrokeWidth > 0 && this.StrokeColor !== Colors.Transparent; }
|
|
440
|
+
HasDropShadow() { return this.DropShadowSize > 0 && this.DropShadowColor !== Colors.Transparent; }
|
|
441
|
+
/** C# measurement: stroke adds StrokeWidth*2 on each axis, the shadow adds DropShadowSize + offset (pixels). */
|
|
442
|
+
EffectsExtra(scale) {
|
|
443
|
+
const stroke = this.HasStroke() ? this.StrokeWidth * 2 * scale : 0;
|
|
444
|
+
const sw = this.HasDropShadow() ? (this.DropShadowSize + this.DropShadowOffsetX) * scale : 0;
|
|
445
|
+
const sh = this.HasDropShadow() ? (this.DropShadowSize + this.DropShadowOffsetY) * scale : 0;
|
|
446
|
+
return { W: stroke + sw, H: stroke + sh, Stroke: stroke };
|
|
447
|
+
}
|
|
448
|
+
/** C# SkiaLabel.SetupBackgroundPaint: no BackgroundColor = no background, the gradient goes on the glyphs. */
|
|
449
|
+
FillGradientPaintsBackground() { return !!this.BackgroundColor; }
|
|
450
|
+
Paint(ctx) {
|
|
451
|
+
if (this.lines.length === 0 || !this.mainFonts)
|
|
452
|
+
return;
|
|
453
|
+
const scale = ctx.Scale;
|
|
454
|
+
const d = ctx.Destination;
|
|
455
|
+
const p = this.padding;
|
|
456
|
+
const left = d.Left + p.Left * scale, right = d.Right - p.Right * scale;
|
|
457
|
+
const top = d.Top + p.Top * scale, bottom = d.Bottom - p.Bottom * scale;
|
|
458
|
+
const extra = this.EffectsExtra(scale);
|
|
459
|
+
const blockH = this.BlockHeight() + extra.H;
|
|
460
|
+
let y = top;
|
|
461
|
+
if (this.verticalTextAlignment === "Center")
|
|
462
|
+
y = top + (bottom - top - blockH) / 2;
|
|
463
|
+
else if (this.verticalTextAlignment === "End")
|
|
464
|
+
y = bottom - blockH;
|
|
465
|
+
// C#: the glyphs sit strokeOffset in from the left/top of the inflated box, the shadow band stays below/right
|
|
466
|
+
y += extra.Stroke / 2;
|
|
467
|
+
const CKp = Super.CK;
|
|
468
|
+
let strokePaint, shadowPaint;
|
|
469
|
+
if (this.HasStroke()) {
|
|
470
|
+
strokePaint = new CKp.Paint();
|
|
471
|
+
strokePaint.setAntiAlias(true);
|
|
472
|
+
strokePaint.setStyle(CKp.PaintStyle.Stroke);
|
|
473
|
+
strokePaint.setStrokeWidth(this.StrokeWidth * 2 * scale);
|
|
474
|
+
strokePaint.setColor(Super.ParseColor(this.StrokeColor));
|
|
475
|
+
}
|
|
476
|
+
if (this.HasDropShadow()) {
|
|
477
|
+
shadowPaint = new CKp.Paint();
|
|
478
|
+
shadowPaint.setAntiAlias(true);
|
|
479
|
+
shadowPaint.setStyle(CKp.PaintStyle.Stroke);
|
|
480
|
+
shadowPaint.setStrokeWidth(this.DropShadowSize * 2 * scale);
|
|
481
|
+
shadowPaint.setColor(Super.ParseColor(this.DropShadowColor));
|
|
482
|
+
}
|
|
483
|
+
const shadowDx = Math.trunc(this.DropShadowOffsetX * scale), shadowDy = Math.trunc(this.DropShadowOffsetY * scale);
|
|
484
|
+
for (const s of this.Spans)
|
|
485
|
+
s.Rects.length = 0;
|
|
486
|
+
const CK = Super.CK;
|
|
487
|
+
const paints = new Map();
|
|
488
|
+
const paintFor = (color, stroke = 0) => {
|
|
489
|
+
const key = color + "|" + stroke;
|
|
490
|
+
let paint = paints.get(key);
|
|
491
|
+
if (!paint) {
|
|
492
|
+
paint = new CK.Paint();
|
|
493
|
+
paint.setColor(Super.ParseColor(color));
|
|
494
|
+
paint.setAntiAlias(true);
|
|
495
|
+
if (stroke > 0) {
|
|
496
|
+
paint.setStyle(CK.PaintStyle.Stroke);
|
|
497
|
+
paint.setStrokeWidth(stroke);
|
|
498
|
+
}
|
|
499
|
+
paints.set(key, paint);
|
|
500
|
+
}
|
|
501
|
+
return paint;
|
|
502
|
+
};
|
|
503
|
+
const canvas = ctx.Context.Canvas;
|
|
504
|
+
const gradient = this.FillGradient;
|
|
505
|
+
const textRect = new SKRect(left, top, right, bottom);
|
|
506
|
+
for (const line of this.lines) {
|
|
507
|
+
const lh = this.LineHeightPx(line);
|
|
508
|
+
const lineW = line.Width + extra.W;
|
|
509
|
+
let x = left;
|
|
510
|
+
if (this.horizontalTextAlignment === "Center")
|
|
511
|
+
x = left + (right - left - lineW) / 2;
|
|
512
|
+
else if (this.horizontalTextAlignment === "End")
|
|
513
|
+
x = right - lineW;
|
|
514
|
+
x += extra.Stroke / 2;
|
|
515
|
+
const baseline = y + line.Ascent;
|
|
516
|
+
// C# paintDefault gets the gradient over rectDraw, or over line.Bounds per line (GradientByLines)
|
|
517
|
+
const gradientRect = gradient ? (this.GradientByLines ? new SKRect(x, y, x + line.Width, y + lh) : textRect) : undefined;
|
|
518
|
+
if (strokePaint && this.StrokeGradient)
|
|
519
|
+
this.SetupGradient(strokePaint, this.StrokeGradient, gradientRect ?? new SKRect(x, y, x + line.Width, y + lh));
|
|
520
|
+
for (const run of line.Runs) {
|
|
521
|
+
const span = run.Span;
|
|
522
|
+
if (span) {
|
|
523
|
+
span.Rects.push(new SKRect(x - d.Left, y - d.Top, x + run.Width - d.Left, y + lh - d.Top));
|
|
524
|
+
if (span.BackgroundColor)
|
|
525
|
+
canvas.drawRect(CK.LTRBRect(x, y, x + run.Width, y + lh), paintFor(span.BackgroundColor));
|
|
526
|
+
}
|
|
527
|
+
const color = span?.TextColor ?? this.textColor;
|
|
528
|
+
if (run.Text) {
|
|
529
|
+
// C# DrawText order: drop shadow, stroke, fill
|
|
530
|
+
if (shadowPaint)
|
|
531
|
+
canvas.drawText(run.Text, x + shadowDx, baseline + shadowDy, shadowPaint, run.Font);
|
|
532
|
+
if (strokePaint)
|
|
533
|
+
canvas.drawText(run.Text, x, baseline, strokePaint, run.Font);
|
|
534
|
+
const paint = paintFor(color);
|
|
535
|
+
if (gradient && gradientRect)
|
|
536
|
+
this.SetupGradient(paint, gradient, gradientRect);
|
|
537
|
+
canvas.drawText(run.Text, x, baseline, paint, run.Font);
|
|
538
|
+
}
|
|
539
|
+
if (span?.HasDecorations) {
|
|
540
|
+
// same geometry as C# DrawSpanDecorations; CanvasKit exposes no underline/strikeout/x-height metrics,
|
|
541
|
+
// so the C# fallbacks apply: underline 1 scaled px under the baseline, strikeout at half an estimated x-height
|
|
542
|
+
if (span.Underline && span.UnderlineWidth !== 0) {
|
|
543
|
+
const w = span.UnderlineWidth > 0 ? span.UnderlineWidth * scale : -span.UnderlineWidth;
|
|
544
|
+
const yl = Math.round(baseline + scale);
|
|
545
|
+
canvas.drawLine(x, yl, x + run.Width, yl, paintFor(color, w));
|
|
546
|
+
}
|
|
547
|
+
if (span.Strikeout) {
|
|
548
|
+
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));
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
x += run.Width;
|
|
553
|
+
}
|
|
554
|
+
y += lh * this.lineSpacing;
|
|
555
|
+
}
|
|
556
|
+
for (const paint of paints.values())
|
|
557
|
+
paint.delete();
|
|
558
|
+
strokePaint?.delete();
|
|
559
|
+
shadowPaint?.delete();
|
|
560
|
+
}
|
|
561
|
+
// ---- span taps (port of C# SkiaLabel.ProcessGestures) ----
|
|
562
|
+
ProcessGestures(args, apply) {
|
|
563
|
+
if (args.Type === "Tapped" && this.Spans.length > 0) {
|
|
564
|
+
const x = apply.MappedLocation.X + apply.ChildOffset.X - this.DrawingRect.Left;
|
|
565
|
+
const y = apply.MappedLocation.Y + apply.ChildOffset.Y - this.DrawingRect.Top;
|
|
566
|
+
for (const span of this.Spans) {
|
|
567
|
+
if (span.HasTapHandler && span.HitIsInside(x, y)) {
|
|
568
|
+
this.PlayRippleAnimation(this.TouchEffectColor, x / this.RenderingScale, y / this.RenderingScale);
|
|
569
|
+
return this.OnSpanTapped(span, args, apply);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return super.ProcessGestures(args, apply);
|
|
574
|
+
}
|
|
575
|
+
/** Return null to not consume the tap. */
|
|
576
|
+
OnSpanTapped(span, args, apply) {
|
|
577
|
+
span.FireTap(new ControlTappedEventArgs(this, args, apply));
|
|
578
|
+
return this;
|
|
579
|
+
}
|
|
580
|
+
}
|