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,1239 @@
|
|
|
1
|
+
import { Super } from "./Super";
|
|
2
|
+
import { Colors, SKRect, ScaledSize, Thickness } from "./Types";
|
|
3
|
+
import { RippleAnimator, SkiaValueAnimator } from "./Animators";
|
|
4
|
+
import { Easing } from "./Easing";
|
|
5
|
+
import { Aria } from "./Accessibility";
|
|
6
|
+
import { ControlTappedEventArgs, GestureEventProcessingInfo, SKPoint, SkiaGesturesInfo, SkiaGesturesParameters, TouchActionEventArgs } from "./Gestures";
|
|
7
|
+
import { IsPostRendererEffect } from "./SkiaEffect";
|
|
8
|
+
/** Mirrors DrawnUi CachedObject: what a cached control replays instead of repainting. */
|
|
9
|
+
export class CachedObject {
|
|
10
|
+
Type;
|
|
11
|
+
Bounds;
|
|
12
|
+
Scale;
|
|
13
|
+
Picture;
|
|
14
|
+
Image;
|
|
15
|
+
constructor(Type,
|
|
16
|
+
/** Rect the content was recorded for, canvas pixels at recording time. */
|
|
17
|
+
Bounds, Scale, Picture, Image) {
|
|
18
|
+
this.Type = Type;
|
|
19
|
+
this.Bounds = Bounds;
|
|
20
|
+
this.Scale = Scale;
|
|
21
|
+
this.Picture = Picture;
|
|
22
|
+
this.Image = Image;
|
|
23
|
+
}
|
|
24
|
+
/** Replays the cache with its top-left moved to (left, top). */
|
|
25
|
+
Draw(canvas, left, top) {
|
|
26
|
+
const CK = Super.CK;
|
|
27
|
+
if (this.Image) {
|
|
28
|
+
// Nearest sampling: a cached bitmap is blitted 1:1, never resampled.
|
|
29
|
+
canvas.drawImageOptions(this.Image, left, top, CK.FilterMode.Nearest, CK.MipmapMode.None, null);
|
|
30
|
+
}
|
|
31
|
+
else if (this.Picture) {
|
|
32
|
+
const saved = canvas.save();
|
|
33
|
+
canvas.translate(left - this.Bounds.Left, top - this.Bounds.Top);
|
|
34
|
+
canvas.drawPicture(this.Picture);
|
|
35
|
+
canvas.restoreToCount(saved);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
Dispose() {
|
|
39
|
+
this.Picture?.delete();
|
|
40
|
+
this.Image?.delete();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Base of every drawn control. Same contract as DrawnUi SkiaControl:
|
|
45
|
+
* Measure(constraints px, scale) -> MeasuredSize (includes Margin),
|
|
46
|
+
* Arrange(destination px) -> DrawingRect,
|
|
47
|
+
* Render(ctx) -> background + Paint(ctx),
|
|
48
|
+
* ProcessGestures(args, apply) -> consumer or null.
|
|
49
|
+
*/
|
|
50
|
+
export class SkiaControl {
|
|
51
|
+
// ---- layout properties (points) ----
|
|
52
|
+
HorizontalOptions = "Start";
|
|
53
|
+
VerticalOptions = "Start";
|
|
54
|
+
/** -1 = auto. */
|
|
55
|
+
WidthRequest = -1;
|
|
56
|
+
HeightRequest = -1;
|
|
57
|
+
/** Caps the available size (points); a Fill control fills only up to it and stays start-aligned, like DrawnUi. -1 = none. */
|
|
58
|
+
MaximumWidthRequest = -1;
|
|
59
|
+
MaximumHeightRequest = -1;
|
|
60
|
+
/** Floors the measured size (points). -1 = none. */
|
|
61
|
+
MinimumWidthRequest = -1;
|
|
62
|
+
MinimumHeightRequest = -1;
|
|
63
|
+
/** Grid placement (C# attached properties SkiaLayout.Column/Row/ColumnSpan/RowSpan). */
|
|
64
|
+
Column = 0;
|
|
65
|
+
Row = 0;
|
|
66
|
+
ColumnSpan = 1;
|
|
67
|
+
RowSpan = 1;
|
|
68
|
+
Margin = Thickness.Zero;
|
|
69
|
+
/**
|
|
70
|
+
* DrawnUi LockRatio: 0 = off; positive = square of the LARGER of the two sizes (requests or constraints),
|
|
71
|
+
* negative = square of the SMALLER one. Infinite sides are ignored, so WidthRequest=150 + LockRatio=1 = 150x150.
|
|
72
|
+
*/
|
|
73
|
+
LockRatio = 0;
|
|
74
|
+
BackgroundColor;
|
|
75
|
+
/** Gradient painted as the background (over BackgroundColor). */
|
|
76
|
+
FillGradient;
|
|
77
|
+
IsVisible = true;
|
|
78
|
+
Tag;
|
|
79
|
+
// ---- render transforms (MAUI VisualElement names; points, applied at render around DrawingRect) ----
|
|
80
|
+
TranslationX = 0;
|
|
81
|
+
TranslationY = 0;
|
|
82
|
+
/** Degrees, around (AnchorX, AnchorY). */
|
|
83
|
+
Rotation = 0;
|
|
84
|
+
ScaleX = 1;
|
|
85
|
+
ScaleY = 1;
|
|
86
|
+
/** Degrees. */
|
|
87
|
+
SkewX = 0;
|
|
88
|
+
SkewY = 0;
|
|
89
|
+
/** Pivot for Rotation/Scale/Skew as a fraction of the box (MAUI default 0.5). */
|
|
90
|
+
AnchorX = 0.5;
|
|
91
|
+
AnchorY = 0.5;
|
|
92
|
+
/** 0..1, applied as a layer alpha over the whole subtree (MAUI VisualElement.Opacity). */
|
|
93
|
+
Opacity = 1;
|
|
94
|
+
/** Clip everything this control draws (content, children, effects per ClipEffects) to its DrawingRect. */
|
|
95
|
+
IsClippedToBounds = false;
|
|
96
|
+
/** Paint-time offset in points (C# Left/Top: moves the cached output without a matrix; here a plain translate). */
|
|
97
|
+
Left = 0;
|
|
98
|
+
Top = 0;
|
|
99
|
+
zIndex = 0;
|
|
100
|
+
/** Drawing order among siblings: higher draws later (on top) and receives gestures first (C# ZIndex). */
|
|
101
|
+
get ZIndex() { return this.zIndex; }
|
|
102
|
+
set ZIndex(v) { if (this.zIndex !== v) {
|
|
103
|
+
this.zIndex = v;
|
|
104
|
+
this.Parent?.InvalidateViewsOrder();
|
|
105
|
+
this.RepaintComposition();
|
|
106
|
+
} }
|
|
107
|
+
/** A layout re-sorts its children by ZIndex on the next draw (C# _orderedChildren reset). */
|
|
108
|
+
InvalidateViewsOrder() { }
|
|
109
|
+
/** Fraction of the available box a Fill control takes (C# DefineAvailableSize); alignment stays inside the full box. */
|
|
110
|
+
HorizontalFillRatio = 1;
|
|
111
|
+
VerticalFillRatio = 1;
|
|
112
|
+
/** Sets ScaleX and ScaleY together (MAUI Scale). */
|
|
113
|
+
get Scale() { return this.ScaleX; }
|
|
114
|
+
set Scale(v) { this.ScaleX = v; this.ScaleY = v; }
|
|
115
|
+
get HasTransform() {
|
|
116
|
+
return this.TranslationX !== 0 || this.TranslationY !== 0 || this.Left !== 0 || this.Top !== 0 || this.Rotation !== 0 || this.ScaleX !== 1 || this.ScaleY !== 1 || this.SkewX !== 0 || this.SkewY !== 0;
|
|
117
|
+
}
|
|
118
|
+
/** Matrix applied at the last render (canvas space), undefined when none; gestures map through its inverse. */
|
|
119
|
+
RenderTransformMatrix;
|
|
120
|
+
/** Generic numeric parameters (DrawnUi Value1/Value2): SkiaShape Arc uses start angle / sweep angle in degrees. */
|
|
121
|
+
Value1 = 0;
|
|
122
|
+
Value2 = 0;
|
|
123
|
+
/** Extra points beyond the viewport a virtualized layout keeps realized (DrawnUi VirtualisationInflated). */
|
|
124
|
+
VirtualisationInflated = 0;
|
|
125
|
+
// ---- data binding (MAUI BindableObject subset) ----
|
|
126
|
+
bindingContext = undefined;
|
|
127
|
+
/** Bound item; recycled cells get a new one on every rebind. */
|
|
128
|
+
get BindingContext() { return this.bindingContext; }
|
|
129
|
+
set BindingContext(value) {
|
|
130
|
+
if (this.bindingContext === value)
|
|
131
|
+
return;
|
|
132
|
+
this.bindingContext = value;
|
|
133
|
+
this.OnBindingContextChanged();
|
|
134
|
+
}
|
|
135
|
+
/** Index of the bound item inside its ItemsSource, -1 when not templated. */
|
|
136
|
+
ContextIndex = -1;
|
|
137
|
+
/** Override to react to a new BindingContext (DrawnUi ContextPropertyChanged / cell SetContent). */
|
|
138
|
+
OnBindingContextChanged() { }
|
|
139
|
+
// ---- caching (same names as DrawnUi) ----
|
|
140
|
+
/** What to cache for this subtree; see SkiaCacheType. Layouts default to None, SkiaLabel/SkiaSvg to Operations. */
|
|
141
|
+
UseCache = "None";
|
|
142
|
+
/** The cache type actually applied (None while caching is disabled globally). */
|
|
143
|
+
get UsingCacheType() {
|
|
144
|
+
if (!Super.CacheEnabled)
|
|
145
|
+
return "None";
|
|
146
|
+
switch (this.UseCache) {
|
|
147
|
+
case "None": return "None";
|
|
148
|
+
case "Operations":
|
|
149
|
+
case "OperationsFull": return "Operations";
|
|
150
|
+
case "ImageDoubleBuffered": return "ImageDoubleBuffered";
|
|
151
|
+
case "ImageComposite":
|
|
152
|
+
case "ImageCompositeGPU": return "ImageComposite";
|
|
153
|
+
default: return "Image"; // Image, GPU -> single offscreen image
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** C# IsCacheComposite: the offscreen surface is kept and only the changed children are re-recorded. */
|
|
157
|
+
get IsCacheComposite() { return this.UsingCacheType === "ImageComposite"; }
|
|
158
|
+
/** Children reported dirty since the last composite record (C# DirtyChildrenInternal, filled by RepaintComposition). */
|
|
159
|
+
DirtyChildrenInternal = new Set();
|
|
160
|
+
/** True while a composite re-record paints only the dirty children (C# IsRenderingWithComposition). */
|
|
161
|
+
IsRenderingWithComposition = false;
|
|
162
|
+
/** Composite surface kept across records; its size follows the expanded cache rect. */
|
|
163
|
+
compositeSurface;
|
|
164
|
+
/** Own content or structure changed: the next composite record is a full one. */
|
|
165
|
+
compositeFull = true;
|
|
166
|
+
/** Canvas-pixel bounds this control covered when its parent last recorded it (composite erase region). */
|
|
167
|
+
lastCompositeBounds;
|
|
168
|
+
/** What the last composite record did (diagnostics): "full" or "partial", the number of children re-recorded and which ones. */
|
|
169
|
+
LastCompositeRecord = { Mode: "full", Children: 0, Dirty: [] };
|
|
170
|
+
/** The children a composite record can re-record individually; layouts return their views. */
|
|
171
|
+
GetCompositeChildren() { return []; }
|
|
172
|
+
/** C# TrackChildAsDirty (DirtyChildrenTracker): the child changed without a remeasure of this control. */
|
|
173
|
+
TrackChildAsDirty(child) { if (this.IsCacheComposite)
|
|
174
|
+
this.DirtyChildrenInternal.add(child); }
|
|
175
|
+
/** Drawn bounds in canvas pixels including transforms and effects margins (C# GetTransformedDirtyBounds). */
|
|
176
|
+
GetTransformedDirtyBounds() {
|
|
177
|
+
const r = this.ExpandedCacheRect(this.RenderingScale), m = this.RenderTransformMatrix;
|
|
178
|
+
if (!m)
|
|
179
|
+
return r;
|
|
180
|
+
const pts = Super.CK.Matrix.mapPoints(m, [r.Left, r.Top, r.Right, r.Top, r.Right, r.Bottom, r.Left, r.Bottom]);
|
|
181
|
+
const xs = [pts[0], pts[2], pts[4], pts[6]], ys = [pts[1], pts[3], pts[5], pts[7]];
|
|
182
|
+
return new SKRect(Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys));
|
|
183
|
+
}
|
|
184
|
+
/** Current cache, if any. */
|
|
185
|
+
RenderObject;
|
|
186
|
+
cacheDirty = true;
|
|
187
|
+
// ---- gesture properties ----
|
|
188
|
+
/** Control itself ignores input (things below it still get it). */
|
|
189
|
+
InputTransparent = false;
|
|
190
|
+
/** Consume every gesture that lands on this control so nothing below (z-order) receives it. */
|
|
191
|
+
BlockGesturesBelow = false;
|
|
192
|
+
LockChildrenGestures = "Disabled";
|
|
193
|
+
// ---- touch feedback (same names as DrawnUi) ----
|
|
194
|
+
TouchEffectColor = Colors.White;
|
|
195
|
+
/** Plays a ripple (from the tap point) when Tapped fires. */
|
|
196
|
+
AnimationTapped = "None";
|
|
197
|
+
/** Ripple duration ms, 0 = animator default (500). */
|
|
198
|
+
AnimationTappedSpeed = 0;
|
|
199
|
+
/** Overlay effects drawn above this control's content every frame (ripple etc). */
|
|
200
|
+
PostAnimators = [];
|
|
201
|
+
// ---- visual effects (C# VisualEffects: attached SkiaEffect objects) ----
|
|
202
|
+
visualEffects = [];
|
|
203
|
+
/** Post-renderer effects among VisualEffects (C# EffectPostRenderers). */
|
|
204
|
+
EffectPostRenderers = [];
|
|
205
|
+
/** Skip every attached effect (C# DisableEffects). */
|
|
206
|
+
DisableEffects = false;
|
|
207
|
+
/** Effects attached to this control; assign a new array to change them (they are attached/detached here). */
|
|
208
|
+
get VisualEffects() { return this.visualEffects; }
|
|
209
|
+
set VisualEffects(v) {
|
|
210
|
+
const next = v ? [...v] : [];
|
|
211
|
+
for (const e of this.visualEffects)
|
|
212
|
+
if (!next.includes(e))
|
|
213
|
+
e.Dettach();
|
|
214
|
+
for (const e of next)
|
|
215
|
+
if (e.Parent !== this)
|
|
216
|
+
e.Attach(this);
|
|
217
|
+
this.visualEffects = next;
|
|
218
|
+
this.OnVisualEffectsChanged();
|
|
219
|
+
}
|
|
220
|
+
OnVisualEffectsChanged() {
|
|
221
|
+
this.EffectPostRenderers = this.visualEffects.filter(IsPostRendererEffect);
|
|
222
|
+
this.InvalidateEffectsMargin();
|
|
223
|
+
this.InvalidateCache();
|
|
224
|
+
this.RepaintComposition();
|
|
225
|
+
}
|
|
226
|
+
/** Post renderers that can render this frame (compiled shaders); an unready effect leaves the control drawn plainly. */
|
|
227
|
+
ActivePostRenderers() {
|
|
228
|
+
if (this.DisableEffects || this.EffectPostRenderers.length === 0)
|
|
229
|
+
return [];
|
|
230
|
+
return this.EffectPostRenderers.filter((e) => e.NeedApply);
|
|
231
|
+
}
|
|
232
|
+
/** C# CachedImage: the cached texture and the canvas rect it was rasterized over (Image caches only). */
|
|
233
|
+
get CachedImage() {
|
|
234
|
+
const c = this.RenderObject;
|
|
235
|
+
return c?.Image ? { Image: c.Image, Bounds: c.Bounds } : undefined;
|
|
236
|
+
}
|
|
237
|
+
/** Clip overlay effects to the control's shape (CreateClip). */
|
|
238
|
+
ClipEffects = true;
|
|
239
|
+
// ---- gesture events (single handler each; C# events map to one callback prop) ----
|
|
240
|
+
Tapped;
|
|
241
|
+
ChildTapped;
|
|
242
|
+
/** Raw gesture hook: set e.Consumed = true to stop propagation (not for Up). */
|
|
243
|
+
ConsumeGestures;
|
|
244
|
+
// ---- tree ----
|
|
245
|
+
Parent;
|
|
246
|
+
/** Containers override; a leaf control cannot host children. */
|
|
247
|
+
AddSubView(_control) { throw new Error(`DrawnUi: ${this.constructor.name} cannot host children`); }
|
|
248
|
+
InsertSubView(_index, control) { this.AddSubView(control); }
|
|
249
|
+
RemoveSubView(_control) { }
|
|
250
|
+
/** Set by Canvas on its Content. Children resolve through Parent. */
|
|
251
|
+
_superview;
|
|
252
|
+
get Superview() {
|
|
253
|
+
return this.Parent ? this.Parent.Superview : this._superview;
|
|
254
|
+
}
|
|
255
|
+
// ---- state (pixels) ----
|
|
256
|
+
MeasuredSize = ScaledSize.Default;
|
|
257
|
+
DrawingRect = SKRect.Empty;
|
|
258
|
+
RenderingScale = 1;
|
|
259
|
+
NeedMeasure = true;
|
|
260
|
+
lastWidthConstraint = NaN;
|
|
261
|
+
lastHeightConstraint = NaN;
|
|
262
|
+
/**
|
|
263
|
+
* Public non-virtual entry like DrawnUi: applies Margin/requests, then MeasureAbsolute for content.
|
|
264
|
+
* A control that was not invalidated and gets the same constraints + scale returns its previous size —
|
|
265
|
+
* this is what keeps a cached tree from re-measuring text every frame.
|
|
266
|
+
*/
|
|
267
|
+
Measure(widthConstraint, heightConstraint, scale) {
|
|
268
|
+
if (!this.NeedMeasure && this.RenderingScale === scale
|
|
269
|
+
&& Object.is(this.lastWidthConstraint, widthConstraint) && Object.is(this.lastHeightConstraint, heightConstraint)) {
|
|
270
|
+
return this.MeasuredSize;
|
|
271
|
+
}
|
|
272
|
+
this.lastWidthConstraint = widthConstraint;
|
|
273
|
+
this.lastHeightConstraint = heightConstraint;
|
|
274
|
+
this.RenderingScale = scale;
|
|
275
|
+
const mx = this.Margin.HorizontalThickness * scale;
|
|
276
|
+
const my = this.Margin.VerticalThickness * scale;
|
|
277
|
+
// DrawnUi CalculateSizeRequest: with LockRatio a single set request drives BOTH sides
|
|
278
|
+
// (WidthRequest=42 + LockRatio=1 => 42x42 whatever the cell is), requests then win over locked constraints.
|
|
279
|
+
let reqW = this.WidthRequest, reqH = this.HeightRequest;
|
|
280
|
+
if (this.LockRatio !== 0 && (reqW >= 0 || reqH >= 0)) {
|
|
281
|
+
const both = reqW >= 0 && reqH >= 0;
|
|
282
|
+
reqW = reqH = both ? (this.LockRatio > 0 ? Math.max(reqW, reqH) : Math.min(reqW, reqH)) : Math.max(reqW, reqH);
|
|
283
|
+
}
|
|
284
|
+
let w = widthConstraint - mx;
|
|
285
|
+
let h = heightConstraint - my;
|
|
286
|
+
if (reqW >= 0)
|
|
287
|
+
w = reqW * scale;
|
|
288
|
+
else if (this.MaximumWidthRequest >= 0)
|
|
289
|
+
w = Math.min(w, this.MaximumWidthRequest * scale);
|
|
290
|
+
if (reqH >= 0)
|
|
291
|
+
h = reqH * scale;
|
|
292
|
+
else if (this.MaximumHeightRequest >= 0)
|
|
293
|
+
h = Math.min(h, this.MaximumHeightRequest * scale);
|
|
294
|
+
// DrawnUi CreateMeasureRequest: no request set -> the constraints themselves are locked (SmartMax/SmartMin * |ratio|)
|
|
295
|
+
let locked = false;
|
|
296
|
+
if (this.LockRatio !== 0 && reqW < 0 && reqH < 0) {
|
|
297
|
+
const lock = (this.LockRatio > 0 ? SkiaControl.SmartMax(w, h) : SkiaControl.SmartMin(w, h)) * Math.abs(this.LockRatio);
|
|
298
|
+
if (lock > 0 && isFinite(lock)) {
|
|
299
|
+
w = h = lock;
|
|
300
|
+
locked = true;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const content = this.MeasureAbsolute(w, h, scale);
|
|
304
|
+
let rw = locked || reqW >= 0 ? w : this.HorizontalOptions === "Fill" && isFinite(w) ? w : content.Pixels.Width;
|
|
305
|
+
let rh = locked || reqH >= 0 ? h : this.VerticalOptions === "Fill" && isFinite(h) ? h : content.Pixels.Height;
|
|
306
|
+
if (this.MinimumWidthRequest >= 0)
|
|
307
|
+
rw = Math.max(rw, this.MinimumWidthRequest * scale);
|
|
308
|
+
if (this.MinimumHeightRequest >= 0)
|
|
309
|
+
rh = Math.max(rh, this.MinimumHeightRequest * scale);
|
|
310
|
+
if (this.MaximumWidthRequest >= 0 && reqW < 0)
|
|
311
|
+
rw = Math.min(rw, this.MaximumWidthRequest * scale);
|
|
312
|
+
if (this.MaximumHeightRequest >= 0 && reqH < 0)
|
|
313
|
+
rh = Math.min(rh, this.MaximumHeightRequest * scale);
|
|
314
|
+
this.MeasuredSize = ScaledSize.FromPixels(rw + mx, rh + my, scale);
|
|
315
|
+
this.NeedMeasure = false;
|
|
316
|
+
return this.MeasuredSize;
|
|
317
|
+
}
|
|
318
|
+
/** Override to measure own content, constraints already exclude Margin. */
|
|
319
|
+
MeasureAbsolute(_widthConstraint, _heightConstraint, scale) {
|
|
320
|
+
return ScaledSize.FromPixels(0, 0, scale);
|
|
321
|
+
}
|
|
322
|
+
/** Places MeasuredSize inside destination per Margin + Horizontal/VerticalOptions -> DrawingRect. */
|
|
323
|
+
Arrange(destination, _widthRequest, _heightRequest, scale) {
|
|
324
|
+
const m = this.Margin;
|
|
325
|
+
const availL = destination.Left + m.Left * scale;
|
|
326
|
+
const availT = destination.Top + m.Top * scale;
|
|
327
|
+
let availW = destination.Width - m.HorizontalThickness * scale;
|
|
328
|
+
let availH = destination.Height - m.VerticalThickness * scale;
|
|
329
|
+
// DrawnUi DefineAvailableSize: a Maximum*Request caps the available box; a Fill control then fills only that.
|
|
330
|
+
if (this.WidthRequest < 0 && this.MaximumWidthRequest >= 0)
|
|
331
|
+
availW = Math.min(availW, this.MaximumWidthRequest * scale);
|
|
332
|
+
if (this.HeightRequest < 0 && this.MaximumHeightRequest >= 0)
|
|
333
|
+
availH = Math.min(availH, this.MaximumHeightRequest * scale);
|
|
334
|
+
if (this.HorizontalFillRatio !== 1)
|
|
335
|
+
availW = Math.ceil(availW * this.HorizontalFillRatio);
|
|
336
|
+
if (this.VerticalFillRatio !== 1)
|
|
337
|
+
availH = Math.ceil(availH * this.VerticalFillRatio);
|
|
338
|
+
const w = this.HorizontalOptions === "Fill" ? availW : Math.min(availW, this.MeasuredSize.Pixels.Width - m.HorizontalThickness * scale);
|
|
339
|
+
const h = this.VerticalOptions === "Fill" ? availH : Math.min(availH, this.MeasuredSize.Pixels.Height - m.VerticalThickness * scale);
|
|
340
|
+
// Alignment happens inside the ORIGINAL box (Center/End of a capped control still center/end in the parent).
|
|
341
|
+
const fullW = destination.Width - m.HorizontalThickness * scale, fullH = destination.Height - m.VerticalThickness * scale;
|
|
342
|
+
const x = availL + SkiaControl.Align(this.HorizontalOptions, fullW, w);
|
|
343
|
+
const y = availT + SkiaControl.Align(this.VerticalOptions, fullH, h);
|
|
344
|
+
this.DrawingRect = SKRect.Create(x, y, w, h);
|
|
345
|
+
this.OnLayoutChanged();
|
|
346
|
+
// DrawnUi OnLayoutReady: an accessible control registers itself on its first layout
|
|
347
|
+
if (!this.registeredWithAccessibility && this.IsAccessibilityElement)
|
|
348
|
+
this.NotifyAccessibility();
|
|
349
|
+
}
|
|
350
|
+
// ---- accessibility (DrawnUi ISkiaAccessibilityNode) ----
|
|
351
|
+
static nextAccessibilityId = 1;
|
|
352
|
+
/** Stable identity for the DOM overlay. */
|
|
353
|
+
AccessibilityId = SkiaControl.nextAccessibilityId++;
|
|
354
|
+
registeredWithAccessibility = false;
|
|
355
|
+
accessibilityRole;
|
|
356
|
+
accessibilityLabel;
|
|
357
|
+
accessibilityHint;
|
|
358
|
+
accessibilityCanInteract;
|
|
359
|
+
accessibilityIsPressed;
|
|
360
|
+
accessibilityLive;
|
|
361
|
+
/**
|
|
362
|
+
* ARIA role; setting it exposes the control to assistive technology (use `Aria` constants). Unset = the class
|
|
363
|
+
* default (`SkiaLabel.DefaultAccessibilityRole` etc.), which is itself unset unless the app opts in.
|
|
364
|
+
*/
|
|
365
|
+
get AccessibilityRole() { return this.accessibilityRole ?? this.constructor.DefaultAccessibilityRole; }
|
|
366
|
+
set AccessibilityRole(v) { if (this.accessibilityRole !== v) {
|
|
367
|
+
this.accessibilityRole = v;
|
|
368
|
+
this.AccessibilityChanged();
|
|
369
|
+
} }
|
|
370
|
+
/** Per-class default role (React extension): e.g. `SkiaLabel.DefaultAccessibilityRole = Aria.RoleText` reads every label. */
|
|
371
|
+
static DefaultAccessibilityRole;
|
|
372
|
+
/** Spoken label; unset = the control's own text (SkiaLabel.Text, SkiaButton.Text). */
|
|
373
|
+
get AccessibilityLabel() { return this.accessibilityLabel ?? this.DefaultAccessibilityLabel(); }
|
|
374
|
+
set AccessibilityLabel(v) { if (this.accessibilityLabel !== v) {
|
|
375
|
+
this.accessibilityLabel = v;
|
|
376
|
+
this.AccessibilityChanged();
|
|
377
|
+
} }
|
|
378
|
+
DefaultAccessibilityLabel() { return undefined; }
|
|
379
|
+
get AccessibilityHint() { return this.accessibilityHint; }
|
|
380
|
+
set AccessibilityHint(v) { if (this.accessibilityHint !== v) {
|
|
381
|
+
this.accessibilityHint = v;
|
|
382
|
+
this.AccessibilityChanged();
|
|
383
|
+
} }
|
|
384
|
+
/** Tab stop + activation; unset = true when the control handles Tapped. */
|
|
385
|
+
get AccessibilityCanInteract() { return this.accessibilityCanInteract ?? this.DefaultAccessibilityCanInteract(); }
|
|
386
|
+
set AccessibilityCanInteract(v) { if (this.accessibilityCanInteract !== v) {
|
|
387
|
+
this.accessibilityCanInteract = v;
|
|
388
|
+
this.AccessibilityChanged();
|
|
389
|
+
} }
|
|
390
|
+
DefaultAccessibilityCanInteract() { return !!this.Tapped; }
|
|
391
|
+
/** aria-pressed for toggles; undefined = not a toggle. */
|
|
392
|
+
get AccessibilityIsPressed() { return this.accessibilityIsPressed; }
|
|
393
|
+
set AccessibilityIsPressed(v) { if (this.accessibilityIsPressed !== v) {
|
|
394
|
+
this.accessibilityIsPressed = v;
|
|
395
|
+
this.AccessibilityChanged();
|
|
396
|
+
} }
|
|
397
|
+
/** aria-live: `Aria.LivePolite` / `Aria.LiveAssertive`; changes are announced immediately. */
|
|
398
|
+
get AccessibilityLive() { return this.accessibilityLive; }
|
|
399
|
+
set AccessibilityLive(v) { if (this.accessibilityLive !== v) {
|
|
400
|
+
this.accessibilityLive = v;
|
|
401
|
+
this.AccessibilityChanged();
|
|
402
|
+
} }
|
|
403
|
+
get IsAccessibilityElement() { const r = this.AccessibilityRole; return r != null && r !== Aria.RolePresentation; }
|
|
404
|
+
accessibilityTextSelectable = false;
|
|
405
|
+
/**
|
|
406
|
+
* Opt-in (React extension, off by default): the control's text is rendered as real, invisible DOM text in the
|
|
407
|
+
* accessibility overlay with pointer events, so the browser selects / copies it like HTML. Pointer input over the
|
|
408
|
+
* text then goes to the selection, not to the drawn control — never enable it on gesture-driven controls.
|
|
409
|
+
*/
|
|
410
|
+
get AccessibilityTextSelectable() { return this.accessibilityTextSelectable; }
|
|
411
|
+
set AccessibilityTextSelectable(v) { if (this.accessibilityTextSelectable !== v) {
|
|
412
|
+
this.accessibilityTextSelectable = v;
|
|
413
|
+
this.AccessibilityChanged();
|
|
414
|
+
} }
|
|
415
|
+
/** Text lines for AccessibilityTextSelectable in CSS px relative to the node (SkiaLabel implements it). */
|
|
416
|
+
GetAccessibilityTextLines(_scale) { return []; }
|
|
417
|
+
/** Hit rect in canvas pixels used to position the overlay element: the drawn (transformed) bounds, like C# HitBoxWithTransforms. */
|
|
418
|
+
GetAccessibilityPixelRect() {
|
|
419
|
+
const r = this.DrawingRect, m = this.RenderTransformMatrix;
|
|
420
|
+
if (!m)
|
|
421
|
+
return r;
|
|
422
|
+
const pts = Super.CK.Matrix.mapPoints(m, [r.Left, r.Top, r.Right, r.Top, r.Right, r.Bottom, r.Left, r.Bottom]);
|
|
423
|
+
const xs = [pts[0], pts[2], pts[4], pts[6]], ys = [pts[1], pts[3], pts[5], pts[7]];
|
|
424
|
+
return new SKRect(Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys));
|
|
425
|
+
}
|
|
426
|
+
/** Registers on first call, marks the snapshot dirty afterwards. */
|
|
427
|
+
NotifyAccessibility() {
|
|
428
|
+
if (!this.IsAccessibilityElement)
|
|
429
|
+
return;
|
|
430
|
+
const mgr = this.Superview?.AccessibilityManager;
|
|
431
|
+
if (!mgr)
|
|
432
|
+
return;
|
|
433
|
+
if (!this.registeredWithAccessibility) {
|
|
434
|
+
mgr.Register(this);
|
|
435
|
+
this.registeredWithAccessibility = true;
|
|
436
|
+
}
|
|
437
|
+
else
|
|
438
|
+
mgr.NotifyUpdated(this);
|
|
439
|
+
}
|
|
440
|
+
UnregisterAccessibility() {
|
|
441
|
+
this.Superview?.AccessibilityManager.Unregister(this);
|
|
442
|
+
this.registeredWithAccessibility = false;
|
|
443
|
+
}
|
|
444
|
+
AccessibilityChanged() {
|
|
445
|
+
if (!this.IsAccessibilityElement && this.registeredWithAccessibility)
|
|
446
|
+
this.UnregisterAccessibility();
|
|
447
|
+
else
|
|
448
|
+
this.NotifyAccessibility();
|
|
449
|
+
}
|
|
450
|
+
OnAccessibilityUnregistered() { this.registeredWithAccessibility = false; }
|
|
451
|
+
/** Platform layer (overlay click / Enter): injects a synthetic Tapped at the control's center into the gesture pipeline. */
|
|
452
|
+
OnAccessibilityActivated() {
|
|
453
|
+
const r = this.GetAccessibilityPixelRect();
|
|
454
|
+
const center = new SKPoint((r.Left + r.Right) / 2, (r.Top + r.Bottom) / 2);
|
|
455
|
+
const args = new TouchActionEventArgs();
|
|
456
|
+
args.Type = "Released";
|
|
457
|
+
args.Location = center;
|
|
458
|
+
args.StartingLocation = center;
|
|
459
|
+
args.Scale = this.RenderingScale;
|
|
460
|
+
this.OnSkiaGestureEvent(SkiaGesturesParameters.Create("Tapped", args), new GestureEventProcessingInfo(center));
|
|
461
|
+
this.Repaint();
|
|
462
|
+
}
|
|
463
|
+
/** Overlay focus arrives on / leaves this node (inputs may activate their sink here). */
|
|
464
|
+
OnAccessibilityFocused(_focused) { }
|
|
465
|
+
NotifyAccessibilityFocused(focused) { this.Superview?.AccessibilityManager.NotifyFocused(focused ? this : undefined); }
|
|
466
|
+
/** DrawnUi SmartMax: larger of two sizes, an infinite one loses. */
|
|
467
|
+
static SmartMax(a, b) {
|
|
468
|
+
if (!isFinite(a) || (isFinite(b) && b > a))
|
|
469
|
+
return b;
|
|
470
|
+
return a;
|
|
471
|
+
}
|
|
472
|
+
/** DrawnUi SmartMin: smaller of two sizes, an infinite one loses. */
|
|
473
|
+
static SmartMin(a, b) {
|
|
474
|
+
if (!isFinite(a) || (isFinite(b) && b < a))
|
|
475
|
+
return b;
|
|
476
|
+
return a;
|
|
477
|
+
}
|
|
478
|
+
static Align(o, avail, size) {
|
|
479
|
+
if (o === "Center")
|
|
480
|
+
return (avail - size) / 2;
|
|
481
|
+
if (o === "End")
|
|
482
|
+
return avail - size;
|
|
483
|
+
return 0;
|
|
484
|
+
}
|
|
485
|
+
/** Called after DrawingRect changed; layouts arrange children here. */
|
|
486
|
+
OnLayoutChanged() { }
|
|
487
|
+
/** Part of DrawingRect that can actually be seen: intersected with every ancestor (a scroll's box clips its content). */
|
|
488
|
+
GetVisibleViewport() {
|
|
489
|
+
const r = this.DrawingRect;
|
|
490
|
+
if (!this.Parent)
|
|
491
|
+
return r;
|
|
492
|
+
const p = this.Parent.GetVisibleViewport();
|
|
493
|
+
return new SKRect(Math.max(r.Left, p.Left), Math.max(r.Top, p.Top), Math.min(r.Right, p.Right), Math.min(r.Bottom, p.Bottom));
|
|
494
|
+
}
|
|
495
|
+
/** Draws the control: from its cache when it has one, else background + Paint(); overlays always live. */
|
|
496
|
+
/**
|
|
497
|
+
* Pixels the control paints OUTSIDE its DrawingRect (shadows, effects): caches record that much more and the
|
|
498
|
+
* blit is offset accordingly (DrawnUi ComputeEffectsMargin / GetRenderingExpandPixels). Base: nothing.
|
|
499
|
+
*/
|
|
500
|
+
ComputeEffectsMargin(_scale) { return Thickness.Zero; }
|
|
501
|
+
effectsMarginCache;
|
|
502
|
+
/** Cached ComputeEffectsMargin (reset by InvalidateMeasure) — C# AggregatedEffectsMarginPixels. */
|
|
503
|
+
EffectsMargin(scale) {
|
|
504
|
+
if (!this.effectsMarginCache || this.effectsMarginCache.scale !== scale) {
|
|
505
|
+
let m = this.ComputeEffectsMargin(scale);
|
|
506
|
+
if (!this.DisableEffects)
|
|
507
|
+
for (const e of this.visualEffects) { // C# ComputeEffectsMargin: per-side max over the attached effects
|
|
508
|
+
const em = e.GetEffectMargin(scale);
|
|
509
|
+
if (em.Left > m.Left || em.Top > m.Top || em.Right > m.Right || em.Bottom > m.Bottom)
|
|
510
|
+
m = new Thickness(Math.max(m.Left, em.Left), Math.max(m.Top, em.Top), Math.max(m.Right, em.Right), Math.max(m.Bottom, em.Bottom));
|
|
511
|
+
}
|
|
512
|
+
this.effectsMarginCache = { scale, margin: m };
|
|
513
|
+
}
|
|
514
|
+
return this.effectsMarginCache.margin;
|
|
515
|
+
}
|
|
516
|
+
/** DrawingRect grown by the effects margin, in pixels — the rect a cache is recorded for. */
|
|
517
|
+
ExpandedCacheRect(scale) {
|
|
518
|
+
const r = this.DrawingRect, m = this.EffectsMargin(scale);
|
|
519
|
+
if (m.Left === 0 && m.Top === 0 && m.Right === 0 && m.Bottom === 0)
|
|
520
|
+
return r;
|
|
521
|
+
return new SKRect(r.Left - Math.ceil(m.Left), r.Top - Math.ceil(m.Top), r.Right + Math.ceil(m.Right), r.Bottom + Math.ceil(m.Bottom));
|
|
522
|
+
}
|
|
523
|
+
Render(ctx) {
|
|
524
|
+
if (!this.IsVisible || this.Opacity <= 0 || this.IsDisposed)
|
|
525
|
+
return;
|
|
526
|
+
const canvas = ctx.Context.Canvas;
|
|
527
|
+
const applyOpacity = this.Opacity < 1;
|
|
528
|
+
// C# Left/Top: a cached control is blitted at an offset, no matrix and no save/restore around the subtree
|
|
529
|
+
const offsetOnly = (this.Left !== 0 || this.Top !== 0) && this.UsingCacheType !== "None" && this.TranslationX === 0 && this.TranslationY === 0
|
|
530
|
+
&& this.Rotation === 0 && this.ScaleX === 1 && this.ScaleY === 1 && this.SkewX === 0 && this.SkewY === 0;
|
|
531
|
+
const needTransform = this.HasTransform && !offsetOnly;
|
|
532
|
+
let saved = false;
|
|
533
|
+
// same as DrawnUi: opacity = a layer with alpha, transforms = canvas matrix around the whole subtree (cache included)
|
|
534
|
+
if (applyOpacity) {
|
|
535
|
+
const paint = new Super.CK.Paint();
|
|
536
|
+
paint.setAlphaf(Math.max(0, Math.min(1, this.Opacity)));
|
|
537
|
+
canvas.saveLayer(paint);
|
|
538
|
+
paint.delete();
|
|
539
|
+
saved = true;
|
|
540
|
+
}
|
|
541
|
+
else if (needTransform) {
|
|
542
|
+
canvas.save();
|
|
543
|
+
saved = true;
|
|
544
|
+
}
|
|
545
|
+
let dx = 0, dy = 0;
|
|
546
|
+
if (needTransform) {
|
|
547
|
+
this.RenderTransformMatrix = this.CreateRenderTransformMatrix(this.DrawingRect, ctx.Scale);
|
|
548
|
+
canvas.concat(this.RenderTransformMatrix);
|
|
549
|
+
}
|
|
550
|
+
else if (offsetOnly) {
|
|
551
|
+
dx = this.Left * ctx.Scale;
|
|
552
|
+
dy = this.Top * ctx.Scale;
|
|
553
|
+
this.RenderTransformMatrix = Super.CK.Matrix.translated(dx, dy); // gestures / accessibility still map through it
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
this.RenderTransformMatrix = undefined;
|
|
557
|
+
}
|
|
558
|
+
if (this.IsClippedToBounds) {
|
|
559
|
+
if (!saved) {
|
|
560
|
+
canvas.save();
|
|
561
|
+
saved = true;
|
|
562
|
+
}
|
|
563
|
+
const c = this.ClipEffects ? this.DrawingRect : this.ExpandedCacheRect(ctx.Scale);
|
|
564
|
+
canvas.clipRect(Super.CK.LTRBRect(c.Left + dx, c.Top + dy, c.Right + dx, c.Bottom + dy), Super.CK.ClipOp.Intersect, true);
|
|
565
|
+
}
|
|
566
|
+
this.RenderContent(ctx, dx, dy);
|
|
567
|
+
if (saved)
|
|
568
|
+
canvas.restore();
|
|
569
|
+
}
|
|
570
|
+
/** Port of DrawnUi ApplyTransforms: T(-pivot) · rotation · scale/skew · T(pivot) · translation, in canvas pixels. */
|
|
571
|
+
CreateRenderTransformMatrix(destination, scale) {
|
|
572
|
+
const M = Super.CK.Matrix;
|
|
573
|
+
const moveX = (this.TranslationX + this.Left) * scale, moveY = (this.TranslationY + this.Top) * scale;
|
|
574
|
+
if (this.Rotation === 0 && this.ScaleX === 1 && this.ScaleY === 1 && this.SkewX === 0 && this.SkewY === 0)
|
|
575
|
+
return M.translated(moveX, moveY);
|
|
576
|
+
const px = destination.Left + destination.Width * this.AnchorX;
|
|
577
|
+
const py = destination.Top + destination.Height * this.AnchorY;
|
|
578
|
+
const kx = this.SkewX !== 0 ? Math.tan((Math.PI * this.SkewX) / 180) : 0;
|
|
579
|
+
const ky = this.SkewY !== 0 ? Math.tan((Math.PI * this.SkewY) / 180) : 0;
|
|
580
|
+
const scaleSkew = [this.ScaleX, kx, 0, ky, this.ScaleY, 0, 0, 0, 1];
|
|
581
|
+
const rotation = this.Rotation !== 0 ? M.rotated((Math.PI * this.Rotation) / 180) : M.identity();
|
|
582
|
+
// multiply(a, b, c) = a·b·c, the right-most applies first
|
|
583
|
+
return M.multiply(M.translated(px + moveX, py + moveY), scaleSkew, rotation, M.translated(-px, -py));
|
|
584
|
+
}
|
|
585
|
+
/** Maps a point given in the parent's space into this control's untransformed space (DrawnUi TransformPointToLocalSpace). */
|
|
586
|
+
TransformPointToLocalSpace(point) {
|
|
587
|
+
const m = this.RenderTransformMatrix;
|
|
588
|
+
if (!m)
|
|
589
|
+
return point;
|
|
590
|
+
const inv = Super.CK.Matrix.invert(m);
|
|
591
|
+
if (!inv)
|
|
592
|
+
return point;
|
|
593
|
+
const p = Super.CK.Matrix.mapPoints(inv, [point.X, point.Y]);
|
|
594
|
+
return new SKPoint(p[0], p[1]);
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Image caches are rasterized over whole device pixels: the expanded rect snapped outward to integers, so the blit
|
|
598
|
+
* lands 1:1 (no sub-pixel resample) and effects sampling the cache at `fragCoord - iOffset` hit texel centers.
|
|
599
|
+
*/
|
|
600
|
+
AlignedCacheRect(scale) {
|
|
601
|
+
const r = this.ExpandedCacheRect(scale);
|
|
602
|
+
return new SKRect(Math.floor(r.Left), Math.floor(r.Top), Math.ceil(r.Right), Math.ceil(r.Bottom));
|
|
603
|
+
}
|
|
604
|
+
/** Cache blit or live paint, then post animators — the part a transform/opacity layer wraps. */
|
|
605
|
+
RenderContent(ctx, dx = 0, dy = 0) {
|
|
606
|
+
const dest = dx !== 0 || dy !== 0 ? new SKRect(this.DrawingRect.Left + dx, this.DrawingRect.Top + dy, this.DrawingRect.Right + dx, this.DrawingRect.Bottom + dy) : this.DrawingRect;
|
|
607
|
+
const own = { ...ctx, Destination: dest };
|
|
608
|
+
const cacheType = this.UsingCacheType;
|
|
609
|
+
const post = this.ActivePostRenderers();
|
|
610
|
+
if (cacheType === "None") {
|
|
611
|
+
this.DestroyRenderingObject();
|
|
612
|
+
this.PaintContent(own);
|
|
613
|
+
// C# DrawDirectInternal: post renderers run after the direct paint, snapshotting what was painted
|
|
614
|
+
for (const e of post)
|
|
615
|
+
e.Render(own);
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
const r = cacheType === "Operations" ? this.ExpandedCacheRect(ctx.Scale) : this.AlignedCacheRect(ctx.Scale);
|
|
619
|
+
const stale = this.cacheDirty || !this.RenderObject || this.RenderObject.Type !== cacheType
|
|
620
|
+
|| this.RenderObject.Scale !== ctx.Scale
|
|
621
|
+
|| Math.round(this.RenderObject.Bounds.Width) !== Math.round(r.Width) || Math.round(this.RenderObject.Bounds.Height) !== Math.round(r.Height);
|
|
622
|
+
if (stale)
|
|
623
|
+
this.CreateRenderingObject({ ...ctx, Destination: this.DrawingRect }, cacheType);
|
|
624
|
+
if (this.RenderObject) {
|
|
625
|
+
// C# DrawRenderObject: with post renderers an Image cache is not blitted, the effects sample it (CachedImage)
|
|
626
|
+
// and paint the result; a picture cache has no texture, so it is replayed first and snapshotted by the effect
|
|
627
|
+
if (post.length === 0 || !this.RenderObject.Image)
|
|
628
|
+
this.RenderObject.Draw(ctx.Context.Canvas, r.Left + dx, r.Top + dy);
|
|
629
|
+
for (const e of post)
|
|
630
|
+
e.Render(own);
|
|
631
|
+
}
|
|
632
|
+
else if (this.RenderObjectPrevious)
|
|
633
|
+
this.RenderObjectPrevious.Draw(ctx.Context.Canvas, r.Left + dx, r.Top + dy); // double buffer: last good frame
|
|
634
|
+
else
|
|
635
|
+
this.DrawPlaceholder(own);
|
|
636
|
+
}
|
|
637
|
+
this.ExecutePostAnimators(own);
|
|
638
|
+
}
|
|
639
|
+
/** Background + Paint(): the part of the control that a cache captures. */
|
|
640
|
+
PaintContent(ctx) {
|
|
641
|
+
if (this.BackgroundColor || (this.FillGradient && this.FillGradientPaintsBackground()) || this.PaintsBackgroundWithoutColor())
|
|
642
|
+
this.PaintBackground(ctx);
|
|
643
|
+
this.Paint(ctx);
|
|
644
|
+
}
|
|
645
|
+
/** Subclasses whose PaintBackground has something to draw without a BackgroundColor (shape shadows). */
|
|
646
|
+
PaintsBackgroundWithoutColor() { return false; }
|
|
647
|
+
/**
|
|
648
|
+
* ImageDoubleBuffered: the last cache is kept while a new one is produced and shown when producing fails
|
|
649
|
+
* (no surface yet); no background thread in the browser, so recording itself stays synchronous like DrawnUi.Blazor.
|
|
650
|
+
*/
|
|
651
|
+
RenderObjectPrevious;
|
|
652
|
+
/** Drawn when a cache is expected but none exists yet (DrawnUi DrawPlaceholder, ImageDoubleBuffered only). Default: nothing. */
|
|
653
|
+
DrawPlaceholder(_ctx) { }
|
|
654
|
+
/** Records/renders the content into a new CachedObject for the current DrawingRect. */
|
|
655
|
+
CreateRenderingObject(ctx, cacheType) {
|
|
656
|
+
const CK = Super.CK;
|
|
657
|
+
// includes what shadows/effects paint outside the box; image caches on whole pixels
|
|
658
|
+
const r = cacheType === "Operations" ? this.ExpandedCacheRect(ctx.Scale) : this.AlignedCacheRect(ctx.Scale);
|
|
659
|
+
const w = Math.max(1, Math.round(r.Width)), h = Math.max(1, Math.round(r.Height));
|
|
660
|
+
if (cacheType === "ImageDoubleBuffered") {
|
|
661
|
+
// keep the previous frame as the fallback until the new cache exists
|
|
662
|
+
if (this.RenderObject) {
|
|
663
|
+
this.DisposePrevious();
|
|
664
|
+
this.RenderObjectPrevious = this.RenderObject;
|
|
665
|
+
this.RenderObject = undefined;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
else if (cacheType !== "ImageComposite") { // a composite keeps its previous object to patch it
|
|
669
|
+
this.DestroyRenderingObject();
|
|
670
|
+
}
|
|
671
|
+
if (cacheType === "Operations") {
|
|
672
|
+
const recorder = new CK.PictureRecorder();
|
|
673
|
+
const canvas = recorder.beginRecording(CK.LTRBRect(r.Left, r.Top, r.Right, r.Bottom));
|
|
674
|
+
this.PaintContent({ ...ctx, Context: { ...ctx.Context, Canvas: canvas, Recording: true } });
|
|
675
|
+
const picture = recorder.finishRecordingAsPicture();
|
|
676
|
+
recorder.delete();
|
|
677
|
+
this.RenderObject = new CachedObject("Operations", r, ctx.Scale, picture);
|
|
678
|
+
}
|
|
679
|
+
else if (cacheType === "ImageComposite") {
|
|
680
|
+
this.CreateCompositeRenderingObject(ctx, r, w, h);
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
const main = ctx.Context.Surface;
|
|
684
|
+
if (!main) {
|
|
685
|
+
this.PaintContent(ctx);
|
|
686
|
+
return;
|
|
687
|
+
} // no surface to derive from (e.g. recording): draw live
|
|
688
|
+
const offscreen = main.makeSurface({ ...main.imageInfo(), width: w, height: h });
|
|
689
|
+
if (!offscreen) {
|
|
690
|
+
if (!this.RenderObjectPrevious)
|
|
691
|
+
this.PaintContent(ctx);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const canvas = offscreen.getCanvas();
|
|
695
|
+
canvas.clear(CK.TRANSPARENT);
|
|
696
|
+
canvas.translate(-r.Left, -r.Top);
|
|
697
|
+
this.PaintContent({ ...ctx, Context: { Canvas: canvas, Surface: offscreen, Origin: { X: r.Left, Y: r.Top } } });
|
|
698
|
+
const image = offscreen.makeImageSnapshot();
|
|
699
|
+
offscreen.delete();
|
|
700
|
+
this.RenderObject = new CachedObject(cacheType, r, ctx.Scale, undefined, image);
|
|
701
|
+
}
|
|
702
|
+
this.cacheDirty = false;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* C# ImageComposite (SetupRenderingWithComposition + CreateRenderingObject reuse): the offscreen surface survives
|
|
706
|
+
* between records; when only some children changed (RepaintComposition from a child, no remeasure of this control)
|
|
707
|
+
* their old and new bounds — plus every sibling they overlap — are erased and just those children are painted
|
|
708
|
+
* again. Anything else (own content, structure, size, scale) records fully.
|
|
709
|
+
*/
|
|
710
|
+
CreateCompositeRenderingObject(ctx, r, w, h) {
|
|
711
|
+
const CK = Super.CK;
|
|
712
|
+
const main = ctx.Context.Surface;
|
|
713
|
+
if (!main) {
|
|
714
|
+
this.PaintContent(ctx);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
let surface = this.compositeSurface;
|
|
718
|
+
const prev = this.RenderObject;
|
|
719
|
+
const sameGeometry = !!surface && !!prev && prev.Type === "ImageComposite" && prev.Scale === ctx.Scale
|
|
720
|
+
&& Math.round(prev.Bounds.Width) === w && Math.round(prev.Bounds.Height) === h;
|
|
721
|
+
if (!sameGeometry) {
|
|
722
|
+
surface?.delete();
|
|
723
|
+
surface = main.makeSurface({ ...main.imageInfo(), width: w, height: h }) ?? undefined;
|
|
724
|
+
this.compositeSurface = surface;
|
|
725
|
+
}
|
|
726
|
+
if (!surface) {
|
|
727
|
+
this.PaintContent(ctx);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const canvas = surface.getCanvas();
|
|
731
|
+
const children = this.GetCompositeChildren();
|
|
732
|
+
const partial = sameGeometry && !this.compositeFull && this.DirtyChildrenInternal.size > 0 && children.length > 0;
|
|
733
|
+
const offset = prev && sameGeometry ? { X: r.Left - prev.Bounds.Left, Y: r.Top - prev.Bounds.Top } : { X: 0, Y: 0 };
|
|
734
|
+
const saved = canvas.save();
|
|
735
|
+
canvas.translate(-r.Left, -r.Top);
|
|
736
|
+
if (partial) {
|
|
737
|
+
// dirty = reported children + siblings intersecting their old or new bounds (C# makes intersecting children dirty too)
|
|
738
|
+
const dirty = new Set();
|
|
739
|
+
const rects = [];
|
|
740
|
+
const boundsOf = (c) => {
|
|
741
|
+
const out = [c.GetTransformedDirtyBounds()];
|
|
742
|
+
if (c.lastCompositeBounds)
|
|
743
|
+
out.push(new SKRect(c.lastCompositeBounds.Left + offset.X, c.lastCompositeBounds.Top + offset.Y, c.lastCompositeBounds.Right + offset.X, c.lastCompositeBounds.Bottom + offset.Y));
|
|
744
|
+
return out;
|
|
745
|
+
};
|
|
746
|
+
for (const c of this.DirtyChildrenInternal)
|
|
747
|
+
if (children.includes(c)) {
|
|
748
|
+
dirty.add(c);
|
|
749
|
+
rects.push(...boundsOf(c));
|
|
750
|
+
}
|
|
751
|
+
let grew = true;
|
|
752
|
+
while (grew) {
|
|
753
|
+
grew = false;
|
|
754
|
+
for (const c of children) {
|
|
755
|
+
if (dirty.has(c) || !c.IsVisible)
|
|
756
|
+
continue;
|
|
757
|
+
const own = boundsOf(c);
|
|
758
|
+
if (rects.some((d) => own.some((o) => o.Left < d.Right && d.Left < o.Right && o.Top < d.Bottom && d.Top < o.Bottom))) {
|
|
759
|
+
dirty.add(c);
|
|
760
|
+
rects.push(...own);
|
|
761
|
+
grew = true;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
const clip = new CK.PathBuilder();
|
|
766
|
+
const erase = new CK.Paint();
|
|
767
|
+
erase.setBlendMode(CK.BlendMode.Clear);
|
|
768
|
+
for (const d of rects) {
|
|
769
|
+
const l = Math.floor(d.Left), t = Math.floor(d.Top), rr = Math.ceil(d.Right), b = Math.ceil(d.Bottom);
|
|
770
|
+
clip.addRect(CK.LTRBRect(l, t, rr, b));
|
|
771
|
+
canvas.drawRect(CK.LTRBRect(l, t, rr, b), erase);
|
|
772
|
+
}
|
|
773
|
+
erase.delete();
|
|
774
|
+
const path = clip.detach();
|
|
775
|
+
clip.delete();
|
|
776
|
+
canvas.clipPath(path, CK.ClipOp.Intersect, false);
|
|
777
|
+
path.delete();
|
|
778
|
+
this.IsRenderingWithComposition = true;
|
|
779
|
+
this.DirtyChildrenInternal.clear();
|
|
780
|
+
for (const c of dirty)
|
|
781
|
+
this.DirtyChildrenInternal.add(c);
|
|
782
|
+
this.PaintContent({ ...ctx, Context: { Canvas: canvas, Surface: surface, Origin: { X: r.Left, Y: r.Top } } });
|
|
783
|
+
this.IsRenderingWithComposition = false;
|
|
784
|
+
this.LastCompositeRecord = { Mode: "partial", Children: dirty.size, Dirty: [...dirty] };
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
787
|
+
canvas.clear(CK.TRANSPARENT);
|
|
788
|
+
this.PaintContent({ ...ctx, Context: { Canvas: canvas, Surface: surface, Origin: { X: r.Left, Y: r.Top } } });
|
|
789
|
+
this.LastCompositeRecord = { Mode: "full", Children: children.length, Dirty: children };
|
|
790
|
+
}
|
|
791
|
+
canvas.restoreToCount(saved);
|
|
792
|
+
for (const c of children)
|
|
793
|
+
c.lastCompositeBounds = c.IsVisible ? c.GetTransformedDirtyBounds() : undefined;
|
|
794
|
+
this.DirtyChildrenInternal.clear();
|
|
795
|
+
this.compositeFull = false;
|
|
796
|
+
const image = surface.makeImageSnapshot();
|
|
797
|
+
const old = this.RenderObject;
|
|
798
|
+
this.RenderObject = new CachedObject("ImageComposite", r, ctx.Scale, undefined, image);
|
|
799
|
+
if (old) {
|
|
800
|
+
const sv = this.Superview;
|
|
801
|
+
if (sv)
|
|
802
|
+
sv.DisposeObject(old);
|
|
803
|
+
else
|
|
804
|
+
old.Dispose();
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
DisposePrevious() {
|
|
808
|
+
const old = this.RenderObjectPrevious;
|
|
809
|
+
if (!old)
|
|
810
|
+
return;
|
|
811
|
+
this.RenderObjectPrevious = undefined;
|
|
812
|
+
const sv = this.Superview;
|
|
813
|
+
if (sv)
|
|
814
|
+
sv.DisposeObject(old);
|
|
815
|
+
else
|
|
816
|
+
old.Dispose();
|
|
817
|
+
}
|
|
818
|
+
/** Drops the cache (disposed after the frame, never mid-draw). */
|
|
819
|
+
DestroyRenderingObject() {
|
|
820
|
+
this.DisposePrevious();
|
|
821
|
+
const old = this.RenderObject;
|
|
822
|
+
if (!old)
|
|
823
|
+
return;
|
|
824
|
+
this.RenderObject = undefined;
|
|
825
|
+
const sv = this.Superview;
|
|
826
|
+
if (sv)
|
|
827
|
+
sv.DisposeObject(old);
|
|
828
|
+
else
|
|
829
|
+
old.Dispose();
|
|
830
|
+
}
|
|
831
|
+
/** Marks the cache stale; re-recorded on the next frame (a composite records fully: its own content changed). */
|
|
832
|
+
InvalidateCache() { this.cacheDirty = true; this.compositeFull = true; }
|
|
833
|
+
/** Effects margin recomputed on the next use (C# InvalidateEffectsMargin). */
|
|
834
|
+
InvalidateEffectsMargin() { this.effectsMarginCache = undefined; }
|
|
835
|
+
/** Draws PostAnimators above content; an effect returning true asks for another frame. */
|
|
836
|
+
ExecutePostAnimators(ctx) {
|
|
837
|
+
if (this.PostAnimators.length === 0)
|
|
838
|
+
return;
|
|
839
|
+
for (const effect of [...this.PostAnimators])
|
|
840
|
+
if (effect.Render(ctx, this))
|
|
841
|
+
this.Repaint();
|
|
842
|
+
}
|
|
843
|
+
/** Clip path of this control in canvas pixels (rect; shapes override). Caller deletes it. */
|
|
844
|
+
CreateClip() {
|
|
845
|
+
const r = this.DrawingRect;
|
|
846
|
+
const b = new Super.CK.PathBuilder();
|
|
847
|
+
b.addRect(Super.CK.LTRBRect(r.Left, r.Top, r.Right, r.Bottom));
|
|
848
|
+
const path = b.detach();
|
|
849
|
+
b.delete();
|
|
850
|
+
return path;
|
|
851
|
+
}
|
|
852
|
+
/** Fills DrawingRect with BackgroundColor / FillGradient; shapes override for rounded corners etc. */
|
|
853
|
+
PaintBackground(ctx) {
|
|
854
|
+
const paint = this.CreateBackgroundPaint(ctx.Destination);
|
|
855
|
+
const r = ctx.Destination;
|
|
856
|
+
ctx.Context.Canvas.drawRect(Super.CK.LTRBRect(r.Left, r.Top, r.Right, r.Bottom), paint);
|
|
857
|
+
paint.delete();
|
|
858
|
+
}
|
|
859
|
+
/** Paint for the background: solid BackgroundColor, or FillGradient shader over the given rect. Caller deletes. */
|
|
860
|
+
CreateBackgroundPaint(rect) {
|
|
861
|
+
const CK = Super.CK;
|
|
862
|
+
const paint = new CK.Paint();
|
|
863
|
+
paint.setAntiAlias(true);
|
|
864
|
+
if (this.BackgroundColor)
|
|
865
|
+
paint.setColor(Super.ParseColor(this.BackgroundColor));
|
|
866
|
+
if (this.FillGradient && this.FillGradientPaintsBackground())
|
|
867
|
+
this.SetupGradient(paint, this.FillGradient, rect);
|
|
868
|
+
return paint;
|
|
869
|
+
}
|
|
870
|
+
/** Whether FillGradient fills the background (C# base: yes, even without BackgroundColor; SkiaLabel: only the glyphs unless BackgroundColor is set). */
|
|
871
|
+
FillGradientPaintsBackground() { return true; }
|
|
872
|
+
/** Shaders built for a gradient object, keyed by the rect they were built for (C# cached SetupGradient overload). */
|
|
873
|
+
gradientShaders;
|
|
874
|
+
/** C# SetupGradient: white base color, the gradient's BlendMode and a (cached) shader on the paint. */
|
|
875
|
+
SetupGradient(paint, gradient, rect) {
|
|
876
|
+
const CK = Super.CK;
|
|
877
|
+
const key = `${rect.Left},${rect.Top},${rect.Width},${rect.Height}|${this.Value1},${this.Value2}`;
|
|
878
|
+
this.gradientShaders ??= new Map();
|
|
879
|
+
let byRect = this.gradientShaders.get(gradient);
|
|
880
|
+
if (!byRect) {
|
|
881
|
+
// a new gradient object (React re-render literal) replaces the old ones: drop their shaders
|
|
882
|
+
for (const m of this.gradientShaders.values())
|
|
883
|
+
for (const s of m.values())
|
|
884
|
+
s.delete();
|
|
885
|
+
this.gradientShaders.clear();
|
|
886
|
+
byRect = new Map();
|
|
887
|
+
this.gradientShaders.set(gradient, byRect);
|
|
888
|
+
}
|
|
889
|
+
let shader = byRect.get(key);
|
|
890
|
+
if (!shader) {
|
|
891
|
+
const made = this.CreateGradient(rect, gradient);
|
|
892
|
+
if (!made)
|
|
893
|
+
return false;
|
|
894
|
+
if (byRect.size >= 32) {
|
|
895
|
+
for (const s of byRect.values())
|
|
896
|
+
s.delete();
|
|
897
|
+
byRect.clear();
|
|
898
|
+
} // GradientByLines on long text
|
|
899
|
+
byRect.set(key, made);
|
|
900
|
+
shader = made;
|
|
901
|
+
}
|
|
902
|
+
paint.setColor(CK.WHITE);
|
|
903
|
+
const blend = gradient.BlendMode ? CK.BlendMode[gradient.BlendMode] : undefined;
|
|
904
|
+
if (blend)
|
|
905
|
+
paint.setBlendMode(blend);
|
|
906
|
+
paint.setShader(shader);
|
|
907
|
+
return true;
|
|
908
|
+
}
|
|
909
|
+
/** Port of C# CreateGradient: Linear / Circular / Oval / Sweep shader over the rect (pixels); null for None. */
|
|
910
|
+
CreateGradient(rect, g) {
|
|
911
|
+
const type = g.Type ?? "Linear";
|
|
912
|
+
if (type === "None" || !g.Colors || g.Colors.length === 0)
|
|
913
|
+
return null;
|
|
914
|
+
const CK = Super.CK;
|
|
915
|
+
const light = g.Light ?? 1, opacity = g.Opacity ?? 1;
|
|
916
|
+
const colors = g.Colors.map((c) => {
|
|
917
|
+
const p = Super.ParseColor(c);
|
|
918
|
+
let rgb = [p[0], p[1], p[2]];
|
|
919
|
+
if (light !== 1)
|
|
920
|
+
rgb = SkiaControl.AdjustLightness(rgb, light);
|
|
921
|
+
return Float32Array.of(rgb[0], rgb[1], rgb[2], p[3] * opacity);
|
|
922
|
+
});
|
|
923
|
+
const positions = g.ColorPositions && g.ColorPositions.length === colors.length ? g.ColorPositions : null;
|
|
924
|
+
const tile = CK.TileMode[g.TileMode ?? "Clamp"] ?? CK.TileMode.Clamp;
|
|
925
|
+
switch (type) {
|
|
926
|
+
case "Sweep":
|
|
927
|
+
return CK.Shader.MakeSweepGradient(rect.Left + rect.Width / 2, rect.Top + rect.Height / 2, colors, positions, tile, null, 0, this.Value1, this.Value1 + (this.Value2 || 360));
|
|
928
|
+
case "Circular":
|
|
929
|
+
case "Conical":
|
|
930
|
+
case "Oval": {
|
|
931
|
+
const cx = rect.Left + (g.StartXRatio ?? 0) * rect.Width, cy = rect.Top + (g.StartYRatio ?? 0) * rect.Height;
|
|
932
|
+
if (type !== "Oval")
|
|
933
|
+
return CK.Shader.MakeRadialGradient([cx, cy], Math.min(rect.Width / 2, rect.Height / 2), colors, positions, tile);
|
|
934
|
+
const scaleX = rect.Width >= rect.Height ? 1 : rect.Width / rect.Height;
|
|
935
|
+
const scaleY = rect.Height >= rect.Width ? 1 : rect.Height / rect.Width;
|
|
936
|
+
return CK.Shader.MakeRadialGradient([cx, cy], Math.max(rect.Width / 2, rect.Height / 2), colors, positions, tile, CK.Matrix.scaled(scaleX, scaleY, cx, cy));
|
|
937
|
+
}
|
|
938
|
+
default: {
|
|
939
|
+
let sx = g.StartXRatio ?? 0, sy = g.StartYRatio ?? 0, ex = g.EndXRatio ?? 0, ey = g.EndYRatio ?? 1;
|
|
940
|
+
if (g.Angle != null)
|
|
941
|
+
[sx, sy, ex, ey] = SkiaControl.LinearGradientAngleToPoints(g.Angle);
|
|
942
|
+
return CK.Shader.MakeLinearGradient([rect.Left + rect.Width * sx, rect.Top + rect.Height * sy], [rect.Left + rect.Width * ex, rect.Top + rect.Height * ey], colors, positions, tile);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
/** C# SkiaGradient.LinearGradientAngleToPoints (CSS angle -> start/end ratios). */
|
|
947
|
+
static LinearGradientAngleToPoints(direction) {
|
|
948
|
+
direction -= 90;
|
|
949
|
+
if (direction < 0)
|
|
950
|
+
direction = 360 + direction;
|
|
951
|
+
if (direction > 360)
|
|
952
|
+
direction = 360;
|
|
953
|
+
const eps = Math.pow(2, -52);
|
|
954
|
+
const angle = direction % 360;
|
|
955
|
+
const rad = (d) => (d * Math.PI) / 180;
|
|
956
|
+
let sx = Math.cos(rad(180 - angle)), sy = Math.sin(rad(180 - angle)), ex = Math.cos(rad(360 - angle)), ey = Math.sin(rad(360 - angle));
|
|
957
|
+
if (sx <= 0 || Math.abs(sx) <= eps)
|
|
958
|
+
sx = 0;
|
|
959
|
+
if (sy <= 0 || Math.abs(sy) <= eps)
|
|
960
|
+
sy = 0;
|
|
961
|
+
if (ex <= 0 || Math.abs(ex) <= eps)
|
|
962
|
+
ex = 0;
|
|
963
|
+
if (ey <= 0 || Math.abs(ey) <= eps)
|
|
964
|
+
ey = 0;
|
|
965
|
+
return [sx, sy, ex, ey];
|
|
966
|
+
}
|
|
967
|
+
/** C# MakeDarker / MakeLighter: HSL lightness scaled down (light < 1) or pushed toward white (light > 1). */
|
|
968
|
+
static AdjustLightness(rgb, light) {
|
|
969
|
+
const [r, g, b] = rgb;
|
|
970
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
971
|
+
let h = 0, s = 0;
|
|
972
|
+
let l = (max + min) / 2;
|
|
973
|
+
if (max !== min) {
|
|
974
|
+
const d = max - min;
|
|
975
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
976
|
+
if (max === r)
|
|
977
|
+
h = (g - b) / d + (g < b ? 6 : 0);
|
|
978
|
+
else if (max === g)
|
|
979
|
+
h = (b - r) / d + 2;
|
|
980
|
+
else
|
|
981
|
+
h = (r - g) / d + 4;
|
|
982
|
+
h /= 6;
|
|
983
|
+
}
|
|
984
|
+
l = light < 1 ? l * light : l + (1 - l) * (light - 1);
|
|
985
|
+
l = Math.max(0, Math.min(1, l));
|
|
986
|
+
if (s === 0)
|
|
987
|
+
return [l, l, l];
|
|
988
|
+
const hue = (p, q, t) => { if (t < 0)
|
|
989
|
+
t += 1; if (t > 1)
|
|
990
|
+
t -= 1; if (t < 1 / 6)
|
|
991
|
+
return p + (q - p) * 6 * t; if (t < 1 / 2)
|
|
992
|
+
return q; if (t < 2 / 3)
|
|
993
|
+
return p + (q - p) * (2 / 3 - t) * 6; return p; };
|
|
994
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q;
|
|
995
|
+
return [hue(p, q, h + 1 / 3), hue(p, q, h), hue(p, q, h - 1 / 3)];
|
|
996
|
+
}
|
|
997
|
+
/** Override to draw own content into ctx.Destination. */
|
|
998
|
+
Paint(_ctx) { }
|
|
999
|
+
// ---- invalidation (same names as DrawnUi) ----
|
|
1000
|
+
// ---- disposal (C# Dispose / OnDisposing) ----
|
|
1001
|
+
IsDisposed = false;
|
|
1002
|
+
/**
|
|
1003
|
+
* Frees what the control owns: caches, gradient shaders, running animations and overlay effects, the accessibility
|
|
1004
|
+
* node, then the children. Called by the React renderer when the element leaves the tree (detachDeletedInstance).
|
|
1005
|
+
*/
|
|
1006
|
+
Dispose() {
|
|
1007
|
+
if (this.IsDisposed)
|
|
1008
|
+
return;
|
|
1009
|
+
this.IsDisposed = true;
|
|
1010
|
+
this.OnDisposing();
|
|
1011
|
+
for (const c of this.ownAnimations.values())
|
|
1012
|
+
c.abort();
|
|
1013
|
+
this.ownAnimations.clear();
|
|
1014
|
+
for (const e of [...this.PostAnimators])
|
|
1015
|
+
e.Stop?.();
|
|
1016
|
+
this.PostAnimators.length = 0;
|
|
1017
|
+
this.DestroyRenderingObject();
|
|
1018
|
+
this.compositeSurface?.delete();
|
|
1019
|
+
this.compositeSurface = undefined;
|
|
1020
|
+
for (const e of this.visualEffects)
|
|
1021
|
+
e.Dispose(); // C# disposes attached effects with the control
|
|
1022
|
+
this.visualEffects = [];
|
|
1023
|
+
this.EffectPostRenderers = [];
|
|
1024
|
+
if (this.gradientShaders) {
|
|
1025
|
+
for (const m of this.gradientShaders.values())
|
|
1026
|
+
for (const s of m.values())
|
|
1027
|
+
s.delete();
|
|
1028
|
+
this.gradientShaders.clear();
|
|
1029
|
+
}
|
|
1030
|
+
this.UnregisterAccessibility();
|
|
1031
|
+
this.DisposeChildren();
|
|
1032
|
+
this.Parent = undefined;
|
|
1033
|
+
}
|
|
1034
|
+
/** Subclasses release their own native objects here (before the base frees caches and children). */
|
|
1035
|
+
OnDisposing() { }
|
|
1036
|
+
/** Layouts dispose their children here. */
|
|
1037
|
+
DisposeChildren() { }
|
|
1038
|
+
/** Content changed: cache invalidated + remeasure + redraw (bubbles to every ancestor, whose caches go stale too). */
|
|
1039
|
+
Update() {
|
|
1040
|
+
this.InvalidateMeasure();
|
|
1041
|
+
}
|
|
1042
|
+
/** Redraw without remeasure and WITHOUT dropping caches (position/overlay changes). */
|
|
1043
|
+
Repaint() {
|
|
1044
|
+
this.Superview?.Update();
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Transform / Opacity changed: this control's own cache is still valid (content unchanged) but every ancestor
|
|
1048
|
+
* cache holds its composited output, so those are staled before redrawing (DrawnUi RedrawCanvas + parent invalidation).
|
|
1049
|
+
*/
|
|
1050
|
+
RepaintComposition() {
|
|
1051
|
+
let child = this, p = this.Parent;
|
|
1052
|
+
while (p) {
|
|
1053
|
+
p.cacheDirty = true;
|
|
1054
|
+
p.TrackChildAsDirty(child);
|
|
1055
|
+
child = p;
|
|
1056
|
+
p = p.Parent;
|
|
1057
|
+
}
|
|
1058
|
+
this.Repaint();
|
|
1059
|
+
}
|
|
1060
|
+
/** Size or content may have changed: this control and all ancestors remeasure and re-record. */
|
|
1061
|
+
InvalidateMeasure() {
|
|
1062
|
+
this.NeedMeasure = true;
|
|
1063
|
+
this.cacheDirty = true;
|
|
1064
|
+
this.compositeFull = true; // structure may change: a composite cannot patch it
|
|
1065
|
+
this.effectsMarginCache = undefined;
|
|
1066
|
+
if (this.Parent)
|
|
1067
|
+
this.Parent.InvalidateMeasure();
|
|
1068
|
+
else
|
|
1069
|
+
this.Superview?.Update();
|
|
1070
|
+
}
|
|
1071
|
+
// ---- gestures (same names as DrawnUi) ----
|
|
1072
|
+
/** Hit rect in pixels (no transforms in this port, so it is DrawingRect). */
|
|
1073
|
+
get HitBoxAuto() { return this.DrawingRect; }
|
|
1074
|
+
HitIsInside(x, y) {
|
|
1075
|
+
const r = this.HitBoxAuto;
|
|
1076
|
+
return x >= r.Left && x < r.Right && y >= r.Top && y < r.Bottom;
|
|
1077
|
+
}
|
|
1078
|
+
IsGestureForChild(child, point) {
|
|
1079
|
+
const local = child.TransformPointToLocalSpace(point);
|
|
1080
|
+
return child.HitIsInside(local.X, local.Y);
|
|
1081
|
+
}
|
|
1082
|
+
/** Children that may receive gestures, top-most LAST (layouts return their Views). */
|
|
1083
|
+
GetGestureListeners() { return SkiaControl.NoListeners; }
|
|
1084
|
+
static NoListeners = [];
|
|
1085
|
+
/** ISkiaGestureListener entry, same as DrawnUi: routes to ProcessGestures. */
|
|
1086
|
+
OnSkiaGestureEvent(args, apply) {
|
|
1087
|
+
return this.ProcessGestures(args, apply);
|
|
1088
|
+
}
|
|
1089
|
+
CheckChildrenGesturesLocked(type) {
|
|
1090
|
+
switch (this.LockChildrenGestures) {
|
|
1091
|
+
case "Enabled": return true;
|
|
1092
|
+
case "PassNone": return true;
|
|
1093
|
+
case "PassTap": return type !== "Tapped";
|
|
1094
|
+
case "PassTapAndLongPress": return type !== "Tapped" && type !== "LongPressing";
|
|
1095
|
+
default: return false;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Port of DrawnUi SkiaControl.ProcessGestures (non-render-tree branch):
|
|
1100
|
+
* ConsumeGestures hook -> children top-most first -> own Tapped. Returns the consumer or null.
|
|
1101
|
+
*/
|
|
1102
|
+
ProcessGestures(args, apply) {
|
|
1103
|
+
if (!this.Superview)
|
|
1104
|
+
return null;
|
|
1105
|
+
const consumedDefault = this.BlockGesturesBelow ? this : null;
|
|
1106
|
+
if (this.ConsumeGestures) {
|
|
1107
|
+
const sent = new SkiaGesturesInfo(args, apply);
|
|
1108
|
+
this.ConsumeGestures(this, sent);
|
|
1109
|
+
if (args.Type !== "Up" && sent.Consumed)
|
|
1110
|
+
return this;
|
|
1111
|
+
}
|
|
1112
|
+
// C# EffectsGestureProcessors: attached effects that process gestures (ISkiaGestureProcessor) see them first
|
|
1113
|
+
if (!this.DisableEffects)
|
|
1114
|
+
for (const e of this.visualEffects) {
|
|
1115
|
+
const p = e.ProcessGestures;
|
|
1116
|
+
if (typeof p === "function" && p.call(e, args, apply) && args.Type !== "Up")
|
|
1117
|
+
return this;
|
|
1118
|
+
}
|
|
1119
|
+
if (this.CheckChildrenGesturesLocked(args.Type))
|
|
1120
|
+
return consumedDefault;
|
|
1121
|
+
let consumed = null;
|
|
1122
|
+
const wasConsumed = apply.AlreadyConsumed;
|
|
1123
|
+
const point = new SKPoint(apply.MappedLocation.X + apply.ChildOffset.X, apply.MappedLocation.Y + apply.ChildOffset.Y);
|
|
1124
|
+
const listeners = this.GetGestureListeners();
|
|
1125
|
+
for (let i = listeners.length - 1; i >= 0; i--) {
|
|
1126
|
+
const listener = listeners[i];
|
|
1127
|
+
if (!listener.IsVisible || listener.InputTransparent)
|
|
1128
|
+
continue;
|
|
1129
|
+
if (!this.IsGestureForChild(listener, point))
|
|
1130
|
+
continue;
|
|
1131
|
+
let breakForChild = null;
|
|
1132
|
+
if (args.Type === "Tapped") {
|
|
1133
|
+
if (this.ChildTapped) {
|
|
1134
|
+
breakForChild = listener;
|
|
1135
|
+
this.ChildTapped(this, new ControlTappedEventArgs(listener, args, apply));
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
// the child sees the location in its own untransformed space (DrawnUi maps through the inverse RenderTransformMatrix)
|
|
1139
|
+
const local = listener.TransformPointToLocalSpace(point);
|
|
1140
|
+
consumed = listener.OnSkiaGestureEvent(args, new GestureEventProcessingInfo(local, SKPoint.Empty, apply.ChildOffsetDirect, wasConsumed));
|
|
1141
|
+
if (consumed)
|
|
1142
|
+
break;
|
|
1143
|
+
if (breakForChild) {
|
|
1144
|
+
consumed = breakForChild;
|
|
1145
|
+
break;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
if (wasConsumed)
|
|
1149
|
+
consumed = wasConsumed;
|
|
1150
|
+
if (args.Type === "Tapped" && !consumed && this.SendTapped(args, apply))
|
|
1151
|
+
return this;
|
|
1152
|
+
return consumed ?? consumedDefault;
|
|
1153
|
+
}
|
|
1154
|
+
/** Fires Tapped (after AnimationTapped feedback); true when a handler existed (= consumed), like DrawnUi SendTapped. */
|
|
1155
|
+
SendTapped(args, apply) {
|
|
1156
|
+
if (!this.Tapped)
|
|
1157
|
+
return false;
|
|
1158
|
+
if (this.AnimationTapped === "Ripple") {
|
|
1159
|
+
const pts = this.GetOffsetInsideControlInPoints(apply.MappedLocation, apply.ChildOffset);
|
|
1160
|
+
this.PlayRippleAnimation(this.TouchEffectColor, pts.X, pts.Y, true, this.AnimationTappedSpeed);
|
|
1161
|
+
}
|
|
1162
|
+
this.Tapped(this, new ControlTappedEventArgs(this, args, apply));
|
|
1163
|
+
return true;
|
|
1164
|
+
}
|
|
1165
|
+
/** Canvas-pixel touch location -> points relative to this control's top-left. */
|
|
1166
|
+
GetOffsetInsideControlInPoints(location, childOffset) {
|
|
1167
|
+
return new SKPoint((location.X + childOffset.X - this.DrawingRect.Left) / this.RenderingScale, (location.Y + childOffset.Y - this.DrawingRect.Top) / this.RenderingScale);
|
|
1168
|
+
}
|
|
1169
|
+
// ---- animations (DrawnUi AnimateAsync family, Promise-based; AbortSignal instead of CancellationTokenSource) ----
|
|
1170
|
+
ownAnimations = new Map();
|
|
1171
|
+
/** Runs callback(0..1) over ms with easing on the canvas frame loop; rejects with AbortError when cancelled. */
|
|
1172
|
+
AnimateAsync(callback, onCancel, ms = 250, easing = Easing.Linear, cancel) {
|
|
1173
|
+
return new Promise((resolve, reject) => {
|
|
1174
|
+
const animator = new SkiaValueAnimator(this);
|
|
1175
|
+
animator.mMinValue = 0;
|
|
1176
|
+
animator.mMaxValue = 1;
|
|
1177
|
+
animator.Speed = ms;
|
|
1178
|
+
animator.Easing = easing;
|
|
1179
|
+
let cancelled = false;
|
|
1180
|
+
const abort = () => { cancelled = true; animator.Stop(); };
|
|
1181
|
+
if (cancel?.aborted) {
|
|
1182
|
+
onCancel?.();
|
|
1183
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
cancel?.addEventListener("abort", abort, { once: true });
|
|
1187
|
+
animator.OnUpdated = (v) => { callback(v); this.RepaintComposition(); };
|
|
1188
|
+
animator.OnStop = () => {
|
|
1189
|
+
cancel?.removeEventListener("abort", abort);
|
|
1190
|
+
if (cancelled) {
|
|
1191
|
+
onCancel?.();
|
|
1192
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
1193
|
+
}
|
|
1194
|
+
else
|
|
1195
|
+
resolve();
|
|
1196
|
+
};
|
|
1197
|
+
animator.Start();
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
/** callback(start..end) over ms. */
|
|
1201
|
+
AnimateRangeAsync(callback, start, end, ms = 250, easing = Easing.Linear, cancel) {
|
|
1202
|
+
return this.AnimateAsync((v) => callback(start + (end - start) * v), undefined, ms, easing, cancel);
|
|
1203
|
+
}
|
|
1204
|
+
/** One running animation per kind, like the per-property CancellationTokenSources in C#. */
|
|
1205
|
+
RunOwnAnimation(kind, run, cancel) {
|
|
1206
|
+
this.ownAnimations.get(kind)?.abort();
|
|
1207
|
+
const controller = new AbortController();
|
|
1208
|
+
this.ownAnimations.set(kind, controller);
|
|
1209
|
+
cancel?.addEventListener("abort", () => controller.abort(), { once: true });
|
|
1210
|
+
return run(controller.signal).finally(() => { if (this.ownAnimations.get(kind) === controller)
|
|
1211
|
+
this.ownAnimations.delete(kind); });
|
|
1212
|
+
}
|
|
1213
|
+
FadeToAsync(end, ms = 250, easing = Easing.Linear, cancel) {
|
|
1214
|
+
const start = this.Opacity;
|
|
1215
|
+
return this.RunOwnAnimation("fade", (s) => this.AnimateAsync((v) => { this.Opacity = start + (end - start) * v; }, undefined, ms, easing, s).then(() => { this.Opacity = end; }), cancel);
|
|
1216
|
+
}
|
|
1217
|
+
ScaleToAsync(x, y, ms = 250, easing = Easing.Linear, cancel) {
|
|
1218
|
+
const sx = this.ScaleX, sy = this.ScaleY;
|
|
1219
|
+
return this.RunOwnAnimation("scale", (s) => this.AnimateAsync((v) => { this.ScaleX = sx + (x - sx) * v; this.ScaleY = sy + (y - sy) * v; }, undefined, ms, easing, s).then(() => { this.ScaleX = x; this.ScaleY = y; }), cancel);
|
|
1220
|
+
}
|
|
1221
|
+
TranslateToAsync(x, y, ms = 250, easing = Easing.Linear, cancel) {
|
|
1222
|
+
const tx = this.TranslationX, ty = this.TranslationY;
|
|
1223
|
+
return this.RunOwnAnimation("translate", (s) => this.AnimateAsync((v) => { this.TranslationX = tx + (x - tx) * v; this.TranslationY = ty + (y - ty) * v; }, undefined, ms, easing, s).then(() => { this.TranslationX = x; this.TranslationY = y; }), cancel);
|
|
1224
|
+
}
|
|
1225
|
+
RotateToAsync(end, ms = 250, easing = Easing.Linear, cancel) {
|
|
1226
|
+
const start = this.Rotation;
|
|
1227
|
+
return this.RunOwnAnimation("rotate", (s) => this.AnimateAsync((v) => { this.Rotation = start + (end - start) * v; }, undefined, ms, easing, s).then(() => { this.Rotation = end; }), cancel);
|
|
1228
|
+
}
|
|
1229
|
+
/** Starts a ripple at (x, y) points inside this control; speedMs 0 = RippleAnimator default. */
|
|
1230
|
+
PlayRippleAnimation(color, x, y, _removePrevious = true, speedMs = 0) {
|
|
1231
|
+
const animation = new RippleAnimator(this);
|
|
1232
|
+
animation.Color = color;
|
|
1233
|
+
animation.X = x;
|
|
1234
|
+
animation.Y = y;
|
|
1235
|
+
if (speedMs > 0)
|
|
1236
|
+
animation.Speed = speedMs;
|
|
1237
|
+
animation.Start();
|
|
1238
|
+
}
|
|
1239
|
+
}
|