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

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 +31 -25
  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 +2 -0
  10. package/dist/controls/SkiaBackdrop.js +2 -0
  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 +202 -7
  69. package/dist/core/SkiaControl.js +467 -55
  70. package/dist/core/SkiaEffect.d.ts +7 -0
  71. package/dist/core/SkiaEffect.js +25 -6
  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 +29 -3
@@ -125,6 +125,13 @@ export declare class SkiaShaderEffect extends SkiaEffect implements IPostRendere
125
125
  protected ApplyShaderSource(): void;
126
126
  /** Snapshot of what the parent painted (C# CreateSnapshot): the surface the pixels are on, mapped through its origin. */
127
127
  protected CreateSnapshot(ctx: DrawingContext, destination: SKRect): CachedTexture | undefined;
128
+ private frozenEpoch;
129
+ /** Canvas.GpuEpoch: GPU textures made before a WebGL context restore are made again. */
130
+ protected GpuEpoch(): number;
131
+ /** Where the parent was when the texture was frozen (Once): the texture moves with it, as its cache does. */
132
+ private frozenAt?;
133
+ private ParentOrigin;
134
+ private FollowParent;
128
135
  /** C# GetPrimaryTexture: the parent's cache, a frozen snapshot (Once) or nothing (Never). */
129
136
  protected GetPrimaryTexture(_ctx: DrawingContext, _destination: SKRect): CachedTexture | undefined;
130
137
  /** Image → shader with the effect's sampling, translated so texel (0,0) = bounds top-left; cached per image. */
@@ -2,7 +2,7 @@ import { SkiaValueAnimator } from "./Animators";
2
2
  import { Easing } from "./Easing";
3
3
  import { SkiaImageManager } from "./SkiaImageManager";
4
4
  import { Super } from "./Super";
5
- import { Thickness } from "./Types";
5
+ import { SKRect, Thickness } from "./Types";
6
6
  /**
7
7
  * Mirrors DrawnUi SkiaEffect: something attached to a control through `VisualEffects`. `Update()` re-renders the
8
8
  * parent (C# invalidates the parent's cache and redraws), `GetEffectMargin` reports what it paints outside the box.
@@ -129,6 +129,7 @@ export class SkiaShaderEffect extends SkiaEffect {
129
129
  this.DisposeImage(this.frozen.Image);
130
130
  this.frozen = undefined;
131
131
  this.frozenOwned = false;
132
+ this.frozenAt = undefined;
132
133
  }
133
134
  DisposeImage(image) {
134
135
  const sv = this.Parent?.Superview;
@@ -158,12 +159,27 @@ export class SkiaShaderEffect extends SkiaEffect {
158
159
  return undefined;
159
160
  return { Image: image, Bounds: destination, Origin: { X: ox, Y: oy } };
160
161
  }
162
+ frozenEpoch = 0;
163
+ /** Canvas.GpuEpoch: GPU textures made before a WebGL context restore are made again. */
164
+ GpuEpoch() { return this.Parent?.Superview?.GpuEpoch ?? 0; }
165
+ /** Where the parent was when the texture was frozen (Once): the texture moves with it, as its cache does. */
166
+ frozenAt;
167
+ ParentOrigin() { const r = this.Parent?.DrawingRect; return { X: r?.Left ?? 0, Y: r?.Top ?? 0 }; }
168
+ FollowParent(texture) {
169
+ if (!texture || !this.frozenAt)
170
+ return texture;
171
+ const now = this.ParentOrigin(), dx = now.X - this.frozenAt.X, dy = now.Y - this.frozenAt.Y;
172
+ if (dx === 0 && dy === 0)
173
+ return texture;
174
+ const b = texture.Bounds;
175
+ return { Image: texture.Image, Bounds: new SKRect(b.Left + dx, b.Top + dy, b.Right + dx, b.Bottom + dy), Origin: texture.Origin && { X: texture.Origin.X + dx, Y: texture.Origin.Y + dy } };
176
+ }
161
177
  /** C# GetPrimaryTexture: the parent's cache, a frozen snapshot (Once) or nothing (Never). */
162
178
  GetPrimaryTexture(_ctx, _destination) {
163
179
  switch (this.UseBackground) {
164
180
  case "Never": return undefined;
165
181
  case "Once":
166
- if (!this.AquiredBackground || !this.frozen) {
182
+ if (!this.AquiredBackground || !this.frozen || this.frozenEpoch !== this.GpuEpoch()) { // a frozen texture of a lost GPU context is taken again
167
183
  this.ReleaseFrozenSnapshot();
168
184
  let snapshot = this.Parent?.CachedImage;
169
185
  let owned = false;
@@ -175,9 +191,11 @@ export class SkiaShaderEffect extends SkiaEffect {
175
191
  this.frozen = snapshot;
176
192
  this.frozenOwned = owned;
177
193
  this.AquiredBackground = true;
194
+ this.frozenAt = this.ParentOrigin();
195
+ this.frozenEpoch = this.GpuEpoch();
178
196
  }
179
197
  }
180
- return this.frozen;
198
+ return this.FollowParent(this.frozen);
181
199
  default:
182
200
  return this.Parent?.CachedImage;
183
201
  }
@@ -228,7 +246,7 @@ export class SkiaShaderEffect extends SkiaEffect {
228
246
  static async FetchText(url) {
229
247
  let p = sourceCache.get(url);
230
248
  if (!p) {
231
- p = fetch(url).then((r) => { if (!r.ok)
249
+ p = fetch(Super.ResolveAssetUrl(url)).then((r) => { if (!r.ok)
232
250
  throw new Error(`${r.status} ${url}`); return r.text(); });
233
251
  sourceCache.set(url, p);
234
252
  }
@@ -420,7 +438,8 @@ export class ShaderDoubleTexturesEffect extends SkiaShaderEffect {
420
438
  return undefined;
421
439
  const w = Math.round(d.Width), h = Math.round(d.Height);
422
440
  const cur = slot === "p" ? this.resizedPrimary : this.resizedSecondary;
423
- if (cur && cur.source === source && cur.w === w && cur.h === h)
441
+ const epoch = this.GpuEpoch();
442
+ if (cur && cur.source === source && cur.w === w && cur.h === h && cur.epoch === epoch)
424
443
  return cur.image;
425
444
  const main = ctx.Context.Surface;
426
445
  if (!main)
@@ -435,7 +454,7 @@ export class ShaderDoubleTexturesEffect extends SkiaShaderEffect {
435
454
  const image = surface.makeImageSnapshot();
436
455
  surface.delete();
437
456
  cur?.image.delete();
438
- const entry = { source, w, h, image };
457
+ const entry = { source, w, h, epoch, image };
439
458
  if (slot === "p")
440
459
  this.resizedPrimary = entry;
441
460
  else
@@ -39,7 +39,7 @@ export class SkiaImageManager {
39
39
  return inflight;
40
40
  }
41
41
  async Fetch(source, signal) {
42
- const response = await fetch(source, signal ? { signal } : undefined);
42
+ const response = await fetch(Super.ResolveAssetUrl(source), signal ? { signal } : undefined);
43
43
  if (!response.ok)
44
44
  throw new Error(`DrawnUi: ${response.status} loading image '${source}'`);
45
45
  const image = Super.CK.MakeImageFromEncoded(await response.arrayBuffer());
@@ -0,0 +1,51 @@
1
+ import type { SkiaControl } from "./SkiaControl";
2
+ /**
3
+ * Mirrors DrawnUi.Net `Style` (SharedNet/Internals/Core/Style.cs): a set of property defaults for one control type,
4
+ * registered once at startup through `Super.UseDrawnUi().ConfigureStyles(...)`.
5
+ *
6
+ * The C# `Setters` list of `Setter { Property, Value }` becomes a plain object here, because React controls are
7
+ * classes with plain properties instead of BindableProperty: `Setters: { FontFamily: "FontText", FontSize: 14 }`.
8
+ */
9
+ export interface Style {
10
+ /** The control class the style targets, e.g. `SkiaLabel`. */
11
+ TargetType: Function;
12
+ /** Also style classes deriving from TargetType (C# Style.ApplyToDerivedTypes). Default false. */
13
+ ApplyToDerivedTypes?: boolean;
14
+ /** Property defaults, by property name. Anything the app sets itself wins over these. */
15
+ Setters: Record<string, unknown>;
16
+ }
17
+ /** Mirrors DrawnUi.Net `IStylesCollection` / `StylesCollection`: the startup registry, static like the C# one. */
18
+ export declare class StylesCollection {
19
+ /** Every style registered through ConfigureStyles, in registration order (C# StylesCollection.Styles). */
20
+ static readonly Styles: Style[];
21
+ AddStyle(style: Style): StylesCollection;
22
+ }
23
+ /**
24
+ * Applies registered styles to controls. Port of the C# `SkiaControl` styles partial
25
+ * (SharedNet/Draw/SkiaControl.NetShared.cs) with one difference forced by the language: C# knows which properties the
26
+ * app set explicitly (BindableProperty + ExplicitPropertiesSet) and skips those; here a property counts as "not set by
27
+ * the app" while it still equals the value the control's own constructor left, captured once per class.
28
+ */
29
+ export declare class SkiaStyles {
30
+ private static readonly resolved;
31
+ private static readonly defaults;
32
+ /** True while no style is registered: lets Measure skip the whole feature at zero cost. */
33
+ static get IsEmpty(): boolean;
34
+ /** C# InvalidateStylesCache: drop the per-type resolution after the registry changed. */
35
+ static InvalidateStylesCache(): void;
36
+ /**
37
+ * Setters that apply to a control class: the exact-type style plus every ApplyToDerivedTypes style of a base class,
38
+ * furthest base first so a nearer style overrides it, exact type last (C# sorts by inheritance distance).
39
+ */
40
+ private static For;
41
+ /** The values a freshly constructed control of this class carries, for the properties a style touches. */
42
+ private static SnapshotDefaults;
43
+ /**
44
+ * C# ApplyInitialStyles: applies the registered defaults to one control. `atConstruction` is true when nothing has
45
+ * touched the control yet (the reconciler calls it between `new` and the JSX props), so every setter applies and the
46
+ * pre-style values are kept as this class's defaults; a control styled later only gets the properties that still
47
+ * hold those defaults, which is how an explicit assignment wins — the C# ExplicitPropertiesSet rule without
48
+ * property accessors.
49
+ */
50
+ static Apply(control: SkiaControl, atConstruction: boolean): void;
51
+ }
@@ -0,0 +1,92 @@
1
+ /** Mirrors DrawnUi.Net `IStylesCollection` / `StylesCollection`: the startup registry, static like the C# one. */
2
+ export class StylesCollection {
3
+ /** Every style registered through ConfigureStyles, in registration order (C# StylesCollection.Styles). */
4
+ static Styles = [];
5
+ AddStyle(style) {
6
+ StylesCollection.Styles.push(style);
7
+ SkiaStyles.InvalidateStylesCache();
8
+ return this;
9
+ }
10
+ }
11
+ /**
12
+ * Applies registered styles to controls. Port of the C# `SkiaControl` styles partial
13
+ * (SharedNet/Draw/SkiaControl.NetShared.cs) with one difference forced by the language: C# knows which properties the
14
+ * app set explicitly (BindableProperty + ExplicitPropertiesSet) and skips those; here a property counts as "not set by
15
+ * the app" while it still equals the value the control's own constructor left, captured once per class.
16
+ */
17
+ export class SkiaStyles {
18
+ static resolved = new Map();
19
+ static defaults = new Map();
20
+ /** True while no style is registered: lets Measure skip the whole feature at zero cost. */
21
+ static get IsEmpty() { return StylesCollection.Styles.length === 0; }
22
+ /** C# InvalidateStylesCache: drop the per-type resolution after the registry changed. */
23
+ static InvalidateStylesCache() { SkiaStyles.resolved.clear(); SkiaStyles.defaults.clear(); }
24
+ /**
25
+ * Setters that apply to a control class: the exact-type style plus every ApplyToDerivedTypes style of a base class,
26
+ * furthest base first so a nearer style overrides it, exact type last (C# sorts by inheritance distance).
27
+ */
28
+ static For(type) {
29
+ let hit = SkiaStyles.resolved.get(type);
30
+ if (hit)
31
+ return hit;
32
+ const inherited = [];
33
+ let exact;
34
+ for (const style of StylesCollection.Styles) {
35
+ if (style.TargetType === type) {
36
+ exact = style;
37
+ continue;
38
+ }
39
+ if (!style.ApplyToDerivedTypes)
40
+ continue;
41
+ let distance = 0;
42
+ for (let base = Object.getPrototypeOf(type); base; base = Object.getPrototypeOf(base)) {
43
+ distance++;
44
+ if (base === style.TargetType) {
45
+ inherited.push({ Distance: distance, Style: style });
46
+ break;
47
+ }
48
+ }
49
+ }
50
+ inherited.sort((a, b) => b.Distance - a.Distance);
51
+ const setters = {};
52
+ for (const i of inherited)
53
+ Object.assign(setters, i.Style.Setters);
54
+ if (exact)
55
+ Object.assign(setters, exact.Setters);
56
+ hit = { Setters: setters, Empty: Object.keys(setters).length === 0 };
57
+ SkiaStyles.resolved.set(type, hit);
58
+ return hit;
59
+ }
60
+ /** The values a freshly constructed control of this class carries, for the properties a style touches. */
61
+ static SnapshotDefaults(type, sample, keys) {
62
+ if (SkiaStyles.defaults.has(type))
63
+ return;
64
+ const known = {};
65
+ for (const key of keys)
66
+ known[key] = sample[key];
67
+ SkiaStyles.defaults.set(type, known);
68
+ }
69
+ /**
70
+ * C# ApplyInitialStyles: applies the registered defaults to one control. `atConstruction` is true when nothing has
71
+ * touched the control yet (the reconciler calls it between `new` and the JSX props), so every setter applies and the
72
+ * pre-style values are kept as this class's defaults; a control styled later only gets the properties that still
73
+ * hold those defaults, which is how an explicit assignment wins — the C# ExplicitPropertiesSet rule without
74
+ * property accessors.
75
+ */
76
+ static Apply(control, atConstruction) {
77
+ const type = control.constructor;
78
+ const resolved = SkiaStyles.For(type);
79
+ if (resolved.Empty)
80
+ return;
81
+ const keys = Object.keys(resolved.Setters);
82
+ const target = control;
83
+ if (atConstruction)
84
+ SkiaStyles.SnapshotDefaults(type, control, keys);
85
+ const defaults = atConstruction ? undefined : SkiaStyles.defaults.get(type);
86
+ for (const key of keys) {
87
+ // no snapshot for this class (nothing of it ever went through the reconciler): apply as a plain default
88
+ if (!defaults || Object.is(target[key], defaults[key]))
89
+ target[key] = resolved.Setters[key];
90
+ }
91
+ }
92
+ }
@@ -1,4 +1,5 @@
1
1
  import { Thickness } from "./Types";
2
+ import { StylesCollection } from "./Styles";
2
3
  import type { CanvasKit, Font, Typeface } from "canvaskit-wasm";
3
4
  /** Mirrors DrawnUi.Net IFontCollection: fonts.AddFont(source, alias[, weight]), plus the Blazor-head AddSymbols/AddEmojis. */
4
5
  export declare class FontCollection {
@@ -16,10 +17,16 @@ export declare class FontCollection {
16
17
  /** Registers a face under an alias; weight 100..900 lets FontWeight / FontAttributes=Bold pick the right file. */
17
18
  AddFont(source: string, alias: string, weight?: number): FontCollection;
18
19
  }
19
- /** Mirrors DrawnUi.Net DrawnUiBuilder: Super.UseDrawnUi().ConfigureFonts(...).BuildAsync(). */
20
+ /** Mirrors DrawnUi.Net DrawnUiBuilder: Super.UseDrawnUi().ConfigureFonts(...).ConfigureStyles(...).BuildAsync(). */
20
21
  export declare class DrawnUiBuilder {
21
22
  private readonly fonts;
22
23
  ConfigureFonts(configure: (fonts: FontCollection) => void): DrawnUiBuilder;
24
+ /**
25
+ * Mirrors DrawnUi.Net ConfigureStyles: property defaults per control type, applied to every control unless the app
26
+ * sets that property itself. The usual one, same as the .NET Blazor / Fiddle hosts:
27
+ * `styles.AddStyle({ TargetType: SkiaLabel, ApplyToDerivedTypes: true, Setters: { FontFamily: "FontText" } })`.
28
+ */
29
+ ConfigureStyles(configure: (styles: StylesCollection) => void): DrawnUiBuilder;
23
30
  /** Loads CanvasKit + registered fonts. Must complete before the first Canvas is created. */
24
31
  BuildAsync(): Promise<void>;
25
32
  }
@@ -39,10 +46,19 @@ export declare class Super {
39
46
  static readonly Fonts: Map<string, Map<number, Typeface>>;
40
47
  /** First registered font, or CanvasKit's built-in one. */
41
48
  static DefaultTypeface?: Typeface;
49
+ /** CanvasKit's built-in face: what an empty FontFamily resolves to, like C# SKTypeface.CreateDefault(). */
50
+ static SkiaDefaultTypeface?: Typeface;
42
51
  private static readonly fontCache;
43
52
  static UseDrawnUi(): DrawnUiBuilder;
44
53
  /** Master switch: false makes every control render uncached (DrawnUi Super.CacheEnabled). */
45
54
  static CacheEnabled: boolean;
55
+ /**
56
+ * Address every engine loader fetches a source from (images, sprites, fonts, SVG, GIF, Lottie, shaders); caches
57
+ * stay keyed by the source itself. Web-only, no C# counterpart. Default: a file listed in
58
+ * `globalThis.DrawnUiAssetStamps` (written into index.html by the `drawnUiAssetStamps()` Vite plugin) gets
59
+ * `?v=<content hash>`, so a browser never keeps an old copy of a changed file. Replace it to map sources elsewhere.
60
+ */
61
+ static ResolveAssetUrl: (source: string) => string;
46
62
  private static readonly colorCache;
47
63
  /**
48
64
  * Color string -> CanvasKit color. Hex follows the MAUI/DrawnUi convention: #RGB, #ARGB, #RRGGBB, #AARRGGBB
@@ -1,4 +1,5 @@
1
1
  import { Thickness } from "./Types";
2
+ import { StylesCollection } from "./Styles";
2
3
  // the "full" build: same API plus Skottie (SkiaLottie) and the paragraph module; +0.9 MB raw over the default build
3
4
  import CanvasKitInit from "canvaskit-wasm/bin/full/canvaskit.js";
4
5
  import wasmUrl from "canvaskit-wasm/bin/full/canvaskit.wasm?url";
@@ -22,13 +23,22 @@ export class FontCollection {
22
23
  return this;
23
24
  }
24
25
  }
25
- /** Mirrors DrawnUi.Net DrawnUiBuilder: Super.UseDrawnUi().ConfigureFonts(...).BuildAsync(). */
26
+ /** Mirrors DrawnUi.Net DrawnUiBuilder: Super.UseDrawnUi().ConfigureFonts(...).ConfigureStyles(...).BuildAsync(). */
26
27
  export class DrawnUiBuilder {
27
28
  fonts = new FontCollection();
28
29
  ConfigureFonts(configure) {
29
30
  configure(this.fonts);
30
31
  return this;
31
32
  }
33
+ /**
34
+ * Mirrors DrawnUi.Net ConfigureStyles: property defaults per control type, applied to every control unless the app
35
+ * sets that property itself. The usual one, same as the .NET Blazor / Fiddle hosts:
36
+ * `styles.AddStyle({ TargetType: SkiaLabel, ApplyToDerivedTypes: true, Setters: { FontFamily: "FontText" } })`.
37
+ */
38
+ ConfigureStyles(configure) {
39
+ configure(new StylesCollection());
40
+ return this;
41
+ }
32
42
  /** Loads CanvasKit + registered fonts. Must complete before the first Canvas is created. */
33
43
  async BuildAsync() {
34
44
  Super.CK = await CanvasKitInit({ locateFile: () => wasmUrl });
@@ -36,7 +46,7 @@ export class DrawnUiBuilder {
36
46
  for (const f of this.fonts.Fonts) {
37
47
  let face = loaded.get(f.Source);
38
48
  if (!face) {
39
- const data = await (await fetch(f.Source)).arrayBuffer();
49
+ const data = await (await fetch(Super.ResolveAssetUrl(f.Source))).arrayBuffer();
40
50
  face = Super.CK.Typeface.MakeFreeTypeFaceFromData(data) ?? undefined;
41
51
  if (!face)
42
52
  throw new Error(`DrawnUi: cannot load font '${f.Source}'`);
@@ -61,7 +71,8 @@ export class DrawnUiBuilder {
61
71
  if (!Super.DefaultFontAlias)
62
72
  Super.DefaultFontAlias = f.Alias;
63
73
  }
64
- Super.DefaultTypeface ??= Super.CK.Typeface.GetDefault() ?? undefined;
74
+ Super.SkiaDefaultTypeface ??= Super.CK.Typeface.GetDefault() ?? undefined;
75
+ Super.DefaultTypeface ??= Super.SkiaDefaultTypeface;
65
76
  }
66
77
  }
67
78
  /** Mirrors DrawnUi static Super: global engine state. */
@@ -105,10 +116,26 @@ export class Super {
105
116
  static Fonts = new Map();
106
117
  /** First registered font, or CanvasKit's built-in one. */
107
118
  static DefaultTypeface;
119
+ /** CanvasKit's built-in face: what an empty FontFamily resolves to, like C# SKTypeface.CreateDefault(). */
120
+ static SkiaDefaultTypeface;
108
121
  static fontCache = new Map();
109
122
  static UseDrawnUi() { return new DrawnUiBuilder(); }
110
123
  /** Master switch: false makes every control render uncached (DrawnUi Super.CacheEnabled). */
111
124
  static CacheEnabled = true;
125
+ /**
126
+ * Address every engine loader fetches a source from (images, sprites, fonts, SVG, GIF, Lottie, shaders); caches
127
+ * stay keyed by the source itself. Web-only, no C# counterpart. Default: a file listed in
128
+ * `globalThis.DrawnUiAssetStamps` (written into index.html by the `drawnUiAssetStamps()` Vite plugin) gets
129
+ * `?v=<content hash>`, so a browser never keeps an old copy of a changed file. Replace it to map sources elsewhere.
130
+ */
131
+ static ResolveAssetUrl = (source) => {
132
+ const stamps = globalThis.DrawnUiAssetStamps;
133
+ if (!stamps)
134
+ return source;
135
+ const q = source.indexOf("?");
136
+ const v = stamps[(q < 0 ? source : source.slice(0, q)).replace(/^\.?\//, "")];
137
+ return v ? `${source}${q < 0 ? "?" : "&"}v=${v}` : source;
138
+ };
112
139
  static colorCache = new Map();
113
140
  /**
114
141
  * Color string -> CanvasKit color. Hex follows the MAUI/DrawnUi convention: #RGB, #ARGB, #RRGGBB, #AARRGGBB
@@ -145,7 +172,11 @@ export class Super {
145
172
  }
146
173
  /** Nearest registered weight of the alias (empty alias = the first registered family); reports the weight actually used. */
147
174
  static ResolveTypeface(alias, weight) {
148
- const weights = Super.Fonts.get(alias || Super.DefaultFontAlias);
175
+ // No family: the Skia built-in face, like C# SkiaFontManager.GetFont("") -> SKTypeface.CreateDefault(). An app that
176
+ // wants its own font everywhere registers it as a style default (ConfigureStyles), exactly as the .NET hosts do.
177
+ if (!alias)
178
+ return { Typeface: Super.SkiaDefaultTypeface ?? Super.DefaultTypeface ?? null, Weight: 400 };
179
+ const weights = Super.Fonts.get(alias);
149
180
  const target = weight > 0 ? weight : 400;
150
181
  if (weights && weights.size > 0) {
151
182
  if (weights.has(target))
@@ -16,6 +16,8 @@ export declare class ViewsAdapter {
16
16
  Created: number;
17
17
  constructor(parent: SkiaControl);
18
18
  get PoolSize(): number;
19
+ /** The items views are bound to. */
20
+ get Items(): readonly unknown[];
19
21
  get InUseCount(): number;
20
22
  /** New template / items / mode: every realized view is dropped (DrawnUi ApplyItemsSource). */
21
23
  Initialize(template: (() => SkiaControl) | undefined, items: readonly unknown[], recycling: RecyclingTemplate): void;
@@ -26,8 +28,11 @@ export declare class ViewsAdapter {
26
28
  ShiftIndices(by: number, items: readonly unknown[]): void;
27
29
  /** The realized view for an index, if any (no creation). */
28
30
  GetViewForIndex(index: number): SkiaControl | undefined;
29
- /** View bound to items[index]: existing, recycled from the pool, or freshly created. */
30
- GetOrCreateViewForIndex(index: number): SkiaControl | undefined;
31
+ /**
32
+ * View bound to items[index]: existing, recycled from the pool, or freshly created. `quiet`: bound before it is
33
+ * attached, so the rebind invalidates the view alone and not the layout, which measures or draws it right away.
34
+ */
35
+ GetOrCreateViewForIndex(index: number, quiet?: boolean): SkiaControl | undefined;
31
36
  /** Index left the visible range: recycled views go back to the pool, non-recycled ones stay realized. */
32
37
  ReleaseViewAt(index: number): void;
33
38
  /** Releases every realized index outside [first, last]. */
@@ -16,6 +16,8 @@ export class ViewsAdapter {
16
16
  this.parent = parent;
17
17
  }
18
18
  get PoolSize() { return this.pool.length; }
19
+ /** The items views are bound to. */
20
+ get Items() { return this.items; }
19
21
  get InUseCount() { return this.inUse.size; }
20
22
  /** New template / items / mode: every realized view is dropped (DrawnUi ApplyItemsSource). */
21
23
  Initialize(template, items, recycling) {
@@ -52,8 +54,11 @@ export class ViewsAdapter {
52
54
  }
53
55
  /** The realized view for an index, if any (no creation). */
54
56
  GetViewForIndex(index) { return this.inUse.get(index); }
55
- /** View bound to items[index]: existing, recycled from the pool, or freshly created. */
56
- GetOrCreateViewForIndex(index) {
57
+ /**
58
+ * View bound to items[index]: existing, recycled from the pool, or freshly created. `quiet`: bound before it is
59
+ * attached, so the rebind invalidates the view alone and not the layout, which measures or draws it right away.
60
+ */
61
+ GetOrCreateViewForIndex(index, quiet = false) {
57
62
  const existing = this.inUse.get(index);
58
63
  if (existing)
59
64
  return existing;
@@ -64,9 +69,11 @@ export class ViewsAdapter {
64
69
  view = this.template();
65
70
  this.Created++;
66
71
  }
67
- view.Parent = this.parent;
72
+ if (!quiet)
73
+ view.Parent = this.parent;
68
74
  view.ContextIndex = index;
69
75
  view.BindingContext = this.items[index];
76
+ view.Parent = this.parent;
70
77
  this.inUse.set(index, view);
71
78
  return view;
72
79
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./core/Types";
2
2
  export * from "./core/Gestures";
3
+ export * from "./core/Styles";
3
4
  export * from "./core/Easing";
4
5
  export * from "./core/Animators";
5
6
  export * from "./core/ScrollAnimators";
@@ -8,9 +9,11 @@ export * from "./core/SkiaControl";
8
9
  export * from "./core/Canvas";
9
10
  export * from "./controls/SkiaLayout";
10
11
  export * from "./core/Accessibility";
12
+ export * from "./core/Hover";
11
13
  export * from "./controls/SkiaLabel";
12
14
  export * from "./controls/TextSpan";
13
15
  export * from "./controls/SkiaRichLabel";
16
+ export * from "./controls/SkiaLabelFps";
14
17
  export * from "./core/ControlStyle";
15
18
  export * from "./core/ImageEffects";
16
19
  export * from "./controls/SkiaToggle";
@@ -39,6 +42,7 @@ export * from "./controls/SkiaBackdrop";
39
42
  export * from "./controls/SkiaEditor";
40
43
  export * from "./controls/SkiaSprite";
41
44
  export * from "./controls/SkiaSpriteSet";
45
+ export * from "./controls/DrawnGame";
42
46
  export * from "./core/KeyboardManager";
43
47
  export * from "./controls/AnimatedFramesRenderer";
44
48
  export * from "./controls/SkiaLottie";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./core/Types";
2
2
  export * from "./core/Gestures";
3
+ export * from "./core/Styles";
3
4
  export * from "./core/Easing";
4
5
  export * from "./core/Animators";
5
6
  export * from "./core/ScrollAnimators";
@@ -8,9 +9,11 @@ export * from "./core/SkiaControl";
8
9
  export * from "./core/Canvas";
9
10
  export * from "./controls/SkiaLayout";
10
11
  export * from "./core/Accessibility";
12
+ export * from "./core/Hover";
11
13
  export * from "./controls/SkiaLabel";
12
14
  export * from "./controls/TextSpan";
13
15
  export * from "./controls/SkiaRichLabel";
16
+ export * from "./controls/SkiaLabelFps";
14
17
  export * from "./core/ControlStyle";
15
18
  export * from "./core/ImageEffects";
16
19
  export * from "./controls/SkiaToggle";
@@ -39,6 +42,7 @@ export * from "./controls/SkiaBackdrop";
39
42
  export * from "./controls/SkiaEditor";
40
43
  export * from "./controls/SkiaSprite";
41
44
  export * from "./controls/SkiaSpriteSet";
45
+ export * from "./controls/DrawnGame";
42
46
  export * from "./core/KeyboardManager";
43
47
  export * from "./controls/AnimatedFramesRenderer";
44
48
  export * from "./controls/SkiaLottie";
@@ -389,6 +389,12 @@ export const SkiaShell = forwardRef(function SkiaShell({ Routes, children, NavBa
389
389
  prev ? prev.TranslateToAsync(-dir * w, 0, TabsAnimationSpeed, TabsEasing) : Promise.resolve(),
390
390
  ]), TabsAnimationSpeed + 500);
391
391
  }
392
+ // the tab left stays mounted (its state kept): hidden first, then back in place for its next turn
393
+ if (prev) {
394
+ prev.IsVisible = false;
395
+ prev.TranslationX = 0;
396
+ prev.TranslationY = 0;
397
+ }
392
398
  setTabTransition(null);
393
399
  }, [hasTabs, Tabs, AnimateTabs, TabsAnimationSpeed]);
394
400
  // popstate: the browser popped OUR last entry (back) — or moved forward to a hash we can rebuild
@@ -405,7 +411,13 @@ export const SkiaShell = forwardRef(function SkiaShell({ Routes, children, NavBa
405
411
  }
406
412
  const onPop = () => {
407
413
  const depth = window.history.state?.[HISTORY_KEY] ?? 0;
408
- if (depth < history.current.length) {
414
+ const target = parseHash();
415
+ // a Back lands on one of OUR entries: its hash is the stack it was pushed with (the first N pages, N = the page
416
+ // entries below it). A typed hash or a plain <a href="#/route"> makes a new entry with no state (depth 0):
417
+ // that is a navigation to the hash, not a Back to the root
418
+ const pagesBelow = history.current.slice(0, depth).filter((k) => k === "page").length;
419
+ const back = depth < history.current.length && target.join("/") === live.current.stack.slice(0, pagesBelow).join("/");
420
+ if (back) {
409
421
  // back: unwind our entries down to depth
410
422
  void (async () => {
411
423
  while (history.current.length > depth) {
@@ -441,9 +453,8 @@ export const SkiaShell = forwardRef(function SkiaShell({ Routes, children, NavBa
441
453
  })();
442
454
  }
443
455
  else {
444
- // forward (depth grew), or the same depth with another hash = a plain <a href="#/route"> link or a typed hash:
456
+ // forward (depth grew), or another hash = a plain <a href="#/route"> link or a typed hash:
445
457
  // the hash names the pages, rebuild the stack without animation (overlays cannot be restored)
446
- const target = parseHash();
447
458
  if (depth === history.current.length && target.join("/") === live.current.stack.join("/"))
448
459
  return;
449
460
  history.current = target.map(() => "page");
@@ -466,28 +477,36 @@ export const SkiaShell = forwardRef(function SkiaShell({ Routes, children, NavBa
466
477
  const transitioning = transitions > 0 || leaving !== null;
467
478
  const rootBottom = hasTabs ? TabBarHeight + insets.Bottom : 0;
468
479
  const navTop = NavBarHeight + insets.Top;
469
- const rootNode = hasTabs ? RenderRoute(Tabs[selectedTab]?.route ?? "") : children;
480
+ // every tab keeps its root and its pages mounted (state, scroll positions), only the selected one shows (C# tab
481
+ // views, DrawnUi.Rust "tabs keep their stacks")
482
+ const tabIndexes = hasTabs ? Tabs.map((_, i) => i) : [0];
470
483
  const entering = tabTransition ? { TranslationX: tabTransition.dir * 0.75 * (typeof window !== "undefined" ? window.innerWidth : 0), Opacity: 0.001, ZIndex: 1 } : {};
471
- return (_jsx(ShellContext.Provider, { value: nav, children: _jsxs(SkiaLayer, { VerticalOptions: "Fill", children: [tabTransition && (_jsx(SkiaLayer, { VerticalOptions: "Fill", Margin: new Thickness(0, 0, 0, rootBottom), ZIndex: 0, BackgroundColor: PageBackgroundColor, ref: (c) => { if (c)
472
- tabRoots.current.set(tabTransition.from, c); }, children: RenderRoute(Tabs[tabTransition.from]?.route ?? "") }, `tab-leaving-${tabTransition.from}`)), _jsx(SkiaLayer, { VerticalOptions: "Fill", Margin: new Thickness(0, 0, 0, rootBottom), IsVisible: visiblePages.length === 0 || transitioning, BackgroundColor: hasTabs ? PageBackgroundColor : undefined, ...entering, ref: (c) => { if (c)
473
- tabRoots.current.set(selectedTab, c); }, children: rootNode }, hasTabs ? `tab-${selectedTab}` : "root"), visiblePages.map((r, i) => (_jsxs(PageHost, { route: r, speed: PagesAnimationSpeed, visible: r === topRoute || transitioning, background: PageBackgroundColor, bottom: rootBottom, register: (c) => { if (c)
484
+ return (_jsx(ShellContext.Provider, { value: nav, children: _jsxs(SkiaLayer, { VerticalOptions: "Fill", children: [tabIndexes.map((t) => {
485
+ const selected = t === selectedTab;
486
+ return (_jsx(SkiaLayer, { VerticalOptions: "Fill", Margin: new Thickness(0, 0, 0, rootBottom), BackgroundColor: hasTabs ? PageBackgroundColor : undefined, IsVisible: selected ? visiblePages.length === 0 || transitioning : tabTransition?.from === t, ZIndex: 0, ...(selected ? entering : {}), ref: (c) => { if (c)
487
+ tabRoots.current.set(t, c); }, children: hasTabs ? RenderRoute(Tabs[t]?.route ?? "") : children }, hasTabs ? `tab-${t}` : "root"));
488
+ }), tabIndexes.flatMap((t) => (t === selectedTab ? visiblePages : stacks[t] ?? []).map((r, i) => (_jsxs(PageHost, { route: r, speed: PagesAnimationSpeed, active: t === selectedTab, visible: t === selectedTab && (r === topRoute || transitioning), background: PageBackgroundColor, bottom: rootBottom, register: (c) => { if (c)
474
489
  pageCtrls.current.set(r, c);
475
490
  else
476
- pageCtrls.current.delete(r); }, children: [_jsx(SkiaLayer, { VerticalOptions: "Fill", Margin: new Thickness(0, navTop, 0, 0), children: RenderRoute(r) }), _jsxs(SkiaLayer, { HeightRequest: navTop, BackgroundColor: NavBarColor, Padding: new Thickness(0, insets.Top, 0, 0), children: [_jsx(SkiaButton, { Text: "\u2039 Back", BackgroundColor: "#00000000", TextColor: "#6EA8FE", FontSize: 16, VerticalOptions: "Center", Margin: new Thickness(8, 0), ApplyEffect: "Ripple", Tapped: () => void GoBackAsync(), AccessibilityRole: "button", AccessibilityLabel: "Back" }), _jsx(SkiaLabel, { Text: Titles?.[RouteName(r)] ?? RouteName(r), FontSize: 18, FontFamily: "FontTextBold", TextColor: Colors.White, HorizontalOptions: "Fill", HorizontalTextAlignment: "Center", VerticalOptions: "Center", MaxLines: 1, Margin: new Thickness(96, 0), AccessibilityRole: "heading" }), _jsx(SkiaButton, { Text: "Home", BackgroundColor: "#00000000", TextColor: "#6EA8FE", FontSize: 16, HorizontalOptions: "End", VerticalOptions: "Center", Margin: new Thickness(0, 0, 8, 0), ApplyEffect: "Ripple", Tapped: () => void PopToRootAsync(), AccessibilityRole: "button", AccessibilityLabel: "Home" }), _jsx(SkiaLayer, { HeightRequest: 1, VerticalOptions: "End", BackgroundColor: "#343A40" })] })] }, `${selectedTab}:${r}#${i}`))), hasTabs && (_jsxs(SkiaLayer, { HeightRequest: TabBarHeight + insets.Bottom, VerticalOptions: "End", BackgroundColor: TabBarColor, ZIndex: ShellDefaults.ZIndexModals - 1, BlockGesturesBelow: true, Padding: new Thickness(0, 0, 0, insets.Bottom), children: [_jsx(SkiaLayer, { HeightRequest: 1, BackgroundColor: "#343A40" }), _jsx(SkiaGrid, { ColumnDefinitions: Tabs.map(() => "*").join(","), HorizontalOptions: "Fill", VerticalOptions: "Fill", children: Tabs.map((t, i) => (_jsxs(SkiaLayer, { Column: i, HorizontalOptions: "Fill", VerticalOptions: "Fill", AnimationTapped: "Ripple", Tapped: () => void SelectTabAsync(i), AccessibilityRole: "button", AccessibilityLabel: t.title, children: [_jsx(SkiaLabel, { Text: t.title, FontSize: 13, FontFamily: i === selectedTab ? "FontTextBold" : undefined, TextColor: i === selectedTab ? ShellDefaults.TabSelectedColor : ShellDefaults.TabColor, HorizontalOptions: "Center", VerticalOptions: "Center", AccessibilityRole: "presentation" }), i === selectedTab && _jsx(SkiaShape, { Type: "Rectangle", HeightRequest: 3, WidthRequest: 36, CornerRadius: 2, BackgroundColor: ShellDefaults.TabSelectedColor, HorizontalOptions: "Center", VerticalOptions: "End" })] }, t.route))) })] })), modals.map((m) => m.node), popups.map((p) => p.node), toasts.map((t) => t.node)] }) }));
491
+ pageCtrls.current.delete(r); }, children: [_jsx(SkiaLayer, { VerticalOptions: "Fill", Margin: new Thickness(0, navTop, 0, 0), children: RenderRoute(r) }), _jsxs(SkiaLayer, { HeightRequest: navTop, BackgroundColor: NavBarColor, Padding: new Thickness(0, insets.Top, 0, 0), children: [_jsx(SkiaButton, { Text: "\u2039 Back", BackgroundColor: "#00000000", TextColor: "#6EA8FE", FontSize: 16, VerticalOptions: "Center", Margin: new Thickness(8, 0), ApplyEffect: "Ripple", Tapped: () => void GoBackAsync(), AccessibilityRole: "button", AccessibilityLabel: "Back" }), _jsx(SkiaLabel, { Text: Titles?.[RouteName(r)] ?? RouteName(r), FontSize: 18, FontFamily: "FontTextBold", TextColor: Colors.White, HorizontalOptions: "Fill", HorizontalTextAlignment: "Center", VerticalOptions: "Center", MaxLines: 1, Margin: new Thickness(96, 0), AccessibilityRole: "heading" }), _jsx(SkiaButton, { Text: "Home", BackgroundColor: "#00000000", TextColor: "#6EA8FE", FontSize: 16, HorizontalOptions: "End", VerticalOptions: "Center", Margin: new Thickness(0, 0, 8, 0), ApplyEffect: "Ripple", Tapped: () => void PopToRootAsync(), AccessibilityRole: "button", AccessibilityLabel: "Home" }), _jsx(SkiaLayer, { HeightRequest: 1, VerticalOptions: "End", BackgroundColor: "#343A40" })] })] }, `${t}:${r}#${i}`)))), hasTabs && (_jsxs(SkiaLayer, { HeightRequest: TabBarHeight + insets.Bottom, VerticalOptions: "End", BackgroundColor: TabBarColor, ZIndex: ShellDefaults.ZIndexModals - 1, BlockGesturesBelow: true, Padding: new Thickness(0, 0, 0, insets.Bottom), children: [_jsx(SkiaLayer, { HeightRequest: 1, BackgroundColor: "#343A40" }), _jsx(SkiaGrid, { ColumnDefinitions: Tabs.map(() => "*").join(","), HorizontalOptions: "Fill", VerticalOptions: "Fill", children: Tabs.map((t, i) => (_jsxs(SkiaLayer, { Column: i, HorizontalOptions: "Fill", VerticalOptions: "Fill", AnimationTapped: "Ripple", Tapped: () => void SelectTabAsync(i), AccessibilityRole: "button", AccessibilityLabel: t.title, children: [_jsx(SkiaLabel, { Text: t.title, FontSize: 13, FontFamily: i === selectedTab ? "FontTextBold" : undefined, TextColor: i === selectedTab ? ShellDefaults.TabSelectedColor : ShellDefaults.TabColor, HorizontalOptions: "Center", VerticalOptions: "Center", AccessibilityRole: "presentation" }), i === selectedTab && _jsx(SkiaShape, { Type: "Rectangle", HeightRequest: 3, WidthRequest: 36, CornerRadius: 2, BackgroundColor: ShellDefaults.TabSelectedColor, HorizontalOptions: "Center", VerticalOptions: "End" })] }, t.route))) })] })), modals.map((m) => m.node), popups.map((p) => p.node), toasts.map((t) => t.node)] }) }));
477
492
  });
478
493
  /** A pushed page: slides in from the right on mount (C# SkiaViewSwitcher PushView), covers what is below. */
479
- function PageHost({ route, speed, visible, background, bottom, register, children }) {
494
+ function PageHost({ route, speed, active, visible, background, bottom, register, children }) {
480
495
  const ctrl = useRef(null);
496
+ // slides in once, when pushed; a page of another tab stays mounted and is registered only while its tab is selected
481
497
  useEffect(() => {
482
498
  const c = ctrl.current;
483
- if (!c)
484
- return;
485
- register(c);
486
- if (speed > 0) {
499
+ if (c && speed > 0) {
487
500
  c.TranslationX = CanvasWidthPts(c);
488
501
  void c.TranslateToAsync(0, 0, speed);
489
502
  }
490
- return () => register(null);
491
503
  }, [route]); // eslint-disable-line react-hooks/exhaustive-deps
504
+ useEffect(() => {
505
+ const c = ctrl.current;
506
+ if (!c || !active)
507
+ return;
508
+ register(c);
509
+ return () => register(null);
510
+ }, [route, active]); // eslint-disable-line react-hooks/exhaustive-deps
492
511
  return (_jsx(SkiaLayer, { ref: (c) => { ctrl.current = c; }, VerticalOptions: "Fill", HorizontalOptions: "Fill", Margin: new Thickness(0, 0, 0, bottom), BackgroundColor: background, IsVisible: visible, BlockGesturesBelow: true, children: children }));
493
512
  }