@satinhq/kit 0.1.0-beta.1 → 0.1.0-beta.3
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/NOTICE +2 -2
- package/README.md +1 -1
- package/dist/acknowledgements/inventory.json +5 -5
- package/dist/actions/RowActions.d.ts +2 -2
- package/dist/actions/RowActions.js +2 -2
- package/dist/app/deployment.js +8 -2
- package/dist/app/installCheck.js +4 -3
- package/dist/app/launchWatchdog.js +6 -1
- package/dist/controls/civilDate.d.ts +1 -1
- package/dist/controls/civilDate.js +1 -1
- package/dist/docs/components/file-importer.md +1 -1
- package/dist/docs/components/paste-button.md +1 -1
- package/dist/docs/components/photos-picker.md +1 -1
- package/dist/docs/components/share-link.md +1 -1
- package/dist/docs/limitations.md +1 -1
- package/dist/docs/push.md +3 -3
- package/dist/docs/reference.md +1870 -0
- package/dist/glass/GlassMaterialRoot.d.ts +3 -1
- package/dist/glass/GlassMaterialRoot.js +2 -2
- package/dist/glass/MaterialEffectRoot.js +5 -1
- package/dist/glass/errors.d.ts +3 -1
- package/dist/glass/errors.js +49 -3
- package/dist/glass/fallback/canvasOwnership.d.ts +7 -0
- package/dist/glass/fallback/canvasOwnership.js +23 -0
- package/dist/glass/fallback/material.js +3 -2
- package/dist/glass/fallback/regions.d.ts +1 -0
- package/dist/glass/fallback/regions.js +9 -12
- package/dist/glass/fallback/runtime.d.ts +2 -0
- package/dist/glass/fallback/runtime.js +15 -4
- package/dist/glass/lens/controlLift.d.ts +1 -1
- package/dist/glass/lens/controlLift.js +1 -1
- package/dist/glass/lens/interactivePress.d.ts +2 -2
- package/dist/glass/lens/interactivePress.js +2 -2
- package/dist/glass/lens/menu-opening-native-fits.json +1 -1
- package/dist/glass/lens/menuContextFit.json +1 -1
- package/dist/glass/lens/menuDropFit.json +1 -1
- package/dist/glass/lens/menuField.d.ts +2 -2
- package/dist/glass/lens/menuField.js +3 -3
- package/dist/glass/lens/menuGlyphDropFit.json +1 -1
- package/dist/glass/lens/menuMorphFit.json +1 -1
- package/dist/glass/lens/menuMorphPhoneFit.json +1 -1
- package/dist/glass/lens/tabAccessoryMotion.d.ts +1 -1
- package/dist/glass/lens/tabSearchMember.d.ts +1 -1
- package/dist/glass/lens/tabSearchMember.js +1 -1
- package/dist/glass/menuPresenter.js +1 -1
- package/dist/glass/regions.d.ts +1 -1
- package/dist/glass/regions.js +1 -1
- package/dist/glass/shapeTrack.d.ts +1 -1
- package/dist/glass/submenuMotion.d.ts +1 -1
- package/dist/glass/submenuMotion.js +1 -1
- package/dist/glass/webgpu/client.d.ts +3 -0
- package/dist/glass/webgpu/client.js +57 -9
- package/dist/glass/webgpu/entry.worker.js +3 -6
- package/dist/glass/webgpu/errorCode.js +3 -2
- package/dist/glass/webgpu/frameClock.d.ts +6 -2
- package/dist/glass/webgpu/frameClock.js +9 -2
- package/dist/glass/webgpu/frameProbe.d.ts +1 -1
- package/dist/glass/webgpu/frameProbe.js +3 -3
- package/dist/glass/webgpu/layers.d.ts +5 -1
- package/dist/glass/webgpu/layers.js +61 -6
- package/dist/glass/webgpu/minimize.js +20 -2
- package/dist/glass/webgpu/production.js +13 -1
- package/dist/glass/webgpu/raster.d.ts +18 -0
- package/dist/glass/webgpu/raster.js +105 -15
- package/dist/glass/webgpu/rasterMaterial.d.ts +4 -0
- package/dist/glass/webgpu/rasterMaterial.js +17 -1
- package/dist/glass/webgpu/regionScene.d.ts +4 -1
- package/dist/glass/webgpu/regionScene.js +30 -1
- package/dist/glass/webgpu/renderer.js +23 -7
- package/dist/glass/webgpu/sourcePixels.d.ts +4 -4
- package/dist/glass/webgpu/sourcePixels.js +48 -8
- package/dist/keyboard/adapter.js +2 -2
- package/dist/keyboard/avoidance.d.ts +1 -1
- package/dist/keyboard/avoidance.js +1 -1
- package/dist/keyboard/motion.d.ts +1 -1
- package/dist/keyboard/motion.js +1 -1
- package/dist/list-form/ListForm.d.ts +2 -2
- package/dist/list-form/ListForm.js +1 -1
- package/dist/list-form/ListItem.js +1 -1
- package/dist/list-form/RowLabel.d.ts +1 -1
- package/dist/list-form/RowLabel.js +1 -1
- package/dist/list-form/Slider.js +1 -1
- package/dist/list-form/editMotion.js +1 -1
- package/dist/list-form/editing.d.ts +5 -5
- package/dist/list-form/editing.js +1 -1
- package/dist/motion/animationValue.d.ts +2 -2
- package/dist/motion/animationValue.js +9 -10
- package/dist/motion/matchedGeometry.d.ts +4 -4
- package/dist/motion/matchedGeometry.js +7 -8
- package/dist/motion/springTrack.d.ts +1 -2
- package/dist/motion/springTrack.js +10 -5
- package/dist/navigation/ScreenContainer.js +1 -1
- package/dist/navigation/chromeCurves.js +1 -1
- package/dist/navigation/types.d.ts +1 -1
- package/dist/navigation/zoomGeometry.js +1 -1
- package/dist/navigation/zoomMotion.d.ts +1 -1
- package/dist/navigation/zoomMotion.js +2 -2
- package/dist/navigation/zoomTransition.js +3 -4
- package/dist/presentation/Presentation.d.ts +2 -2
- package/dist/presentation/Presentation.js +1 -1
- package/dist/presentation/presentationSource.js +1 -1
- package/dist/public/controls.d.ts +4 -4
- package/dist/public/controls.js +7 -7
- package/dist/public/glass.d.ts +3 -4
- package/dist/public/glass.js +1 -2
- package/dist/public/navigationTransition.d.ts +1 -1
- package/dist/public/navigationTransition.js +1 -1
- package/dist/public/scroll.d.ts +1 -1
- package/dist/public/scroll.js +1 -1
- package/dist/public/toolbar.js +1 -1
- package/dist/root/SatinRoot.js +9 -5
- package/dist/safe-area/SafeAreaInset.js +3 -3
- package/dist/safe-area/modifiers.js +2 -2
- package/dist/safe-area/useSafeAreaMetrics.js +2 -2
- package/dist/scroll/ScrollSurface.d.ts +5 -5
- package/dist/scroll/ScrollSurface.js +1 -1
- package/dist/scroll/position.js +1 -1
- package/dist/scroll/screenGeometry.d.ts +1 -1
- package/dist/scroll/screenGeometry.js +1 -1
- package/dist/search/SearchDrawer.js +1 -1
- package/dist/search/searchMotion.d.ts +1 -1
- package/dist/search/searchMotion.js +1 -1
- package/dist/search/useSearchPresentation.js +1 -1
- package/dist/shapes/corners.js +1 -1
- package/dist/styles.css +55 -34
- package/dist/sw/renderServiceWorker.js +12 -3
- package/dist/text-input/TextEditor.js +7 -3
- package/package.json +4 -4
- package/dist/docs/swiftui-reference.md +0 -1895
|
@@ -96,6 +96,48 @@ export class WebGPURaster {
|
|
|
96
96
|
overlayPipeline;
|
|
97
97
|
presentPipeline;
|
|
98
98
|
format = 'rgba8unorm';
|
|
99
|
+
presentationRoot;
|
|
100
|
+
presentationOrigin;
|
|
101
|
+
presentationMembers = new Set();
|
|
102
|
+
presentationSequence;
|
|
103
|
+
collectingPresentation = false;
|
|
104
|
+
/** Member samples encode their materials first. The root then presents the whole frame once. */
|
|
105
|
+
withSharedPresentation(draw) {
|
|
106
|
+
this.collectingPresentation = true;
|
|
107
|
+
try {
|
|
108
|
+
draw();
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
this.collectingPresentation = false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Retain independent material sampling, but store overlapping fixed chrome once. */
|
|
115
|
+
joinPresentation(root, origin) {
|
|
116
|
+
if (this.presentationRoot === root) {
|
|
117
|
+
this.presentationOrigin = origin;
|
|
118
|
+
if (root.setup)
|
|
119
|
+
root.resizeCanvas();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (this.setup)
|
|
123
|
+
this.clear();
|
|
124
|
+
this.presentationRoot?.presentationMembers.delete(this);
|
|
125
|
+
this.presentationRoot = root;
|
|
126
|
+
this.presentationOrigin = origin;
|
|
127
|
+
root.presentationMembers.add(this);
|
|
128
|
+
if (root.setup)
|
|
129
|
+
root.resizeCanvas();
|
|
130
|
+
root.forgetPresent();
|
|
131
|
+
}
|
|
132
|
+
leavePresentation() {
|
|
133
|
+
const root = this.presentationRoot;
|
|
134
|
+
root?.presentationMembers.delete(this);
|
|
135
|
+
if (root?.setup)
|
|
136
|
+
root.resizeCanvas();
|
|
137
|
+
root?.forgetPresent();
|
|
138
|
+
this.presentationRoot = undefined;
|
|
139
|
+
this.presentationOrigin = undefined;
|
|
140
|
+
}
|
|
99
141
|
lost = false;
|
|
100
142
|
suspended = false;
|
|
101
143
|
covered = false;
|
|
@@ -152,6 +194,8 @@ export class WebGPURaster {
|
|
|
152
194
|
this.setup = { ...this.setup, appearance, spec: retainedAppearanceSpec(spec, appearance) };
|
|
153
195
|
}
|
|
154
196
|
dispose() {
|
|
197
|
+
this.presentationRoot?.presentationMembers.delete(this);
|
|
198
|
+
this.presentationRoot?.forgetPresent();
|
|
155
199
|
this.unsubscribe?.();
|
|
156
200
|
this.release();
|
|
157
201
|
this.passes.destroy();
|
|
@@ -571,7 +615,13 @@ export class WebGPURaster {
|
|
|
571
615
|
const encoder = this.device?.createCommandEncoder();
|
|
572
616
|
if (!encoder || !this.encodeResult(encoder, x))
|
|
573
617
|
return;
|
|
574
|
-
|
|
618
|
+
if (this.presentationRoot) {
|
|
619
|
+
const root = this.presentationRoot;
|
|
620
|
+
if (!root.collectingPresentation && root.targets.result)
|
|
621
|
+
WebGPURaster.presentLayer(encoder, root.presentationSequence ?? [root]);
|
|
622
|
+
}
|
|
623
|
+
else
|
|
624
|
+
this.custom(encoder, this.presentPipeline, drawable(this.context, 'single', encoder).createView(), this.presentUniform(), [this.targets.result], !clear, this.presentScissor(), [this.canvas.width, this.canvas.height]);
|
|
575
625
|
if (this.timing) {
|
|
576
626
|
encoder.resolveQuerySet(this.timing.queries, 0, 2, this.timing.resolve, 0);
|
|
577
627
|
encoder.copyBufferToBuffer(this.timing.resolve, 0, this.timing.read, 0, 16);
|
|
@@ -601,18 +651,34 @@ export class WebGPURaster {
|
|
|
601
651
|
* region presenter's layer already is its sample rectangle and keeps layer space. */
|
|
602
652
|
localResult = false;
|
|
603
653
|
/** Device-pixel offset of this layer's canvas within its layer. */
|
|
604
|
-
|
|
654
|
+
presentationViewport() {
|
|
605
655
|
const viewport = canvasViewports.get(this.canvas);
|
|
656
|
+
if (!viewport || !this.setup || !this.presentationMembers.size)
|
|
657
|
+
return viewport;
|
|
658
|
+
// The placeholder keeps its original band. object-fit:contain, aligned to the
|
|
659
|
+
// bottom, lets the worker omit transparent rows above all retained morph bounds.
|
|
660
|
+
const top = Math.max(viewport[1], Math.floor(Math.min(this.setup.spec.frameY - 64, ...[...this.presentationMembers].map(member => member.presentationOrigin.y))));
|
|
661
|
+
return [viewport[0], top, viewport[2], viewport[1] + viewport[3] - top];
|
|
662
|
+
}
|
|
663
|
+
canvasOffset() {
|
|
664
|
+
const viewport = this.presentationViewport();
|
|
606
665
|
const resolution = this.setup.resolution;
|
|
607
666
|
return viewport ? [viewport[0] * resolution, viewport[1] * resolution] : [0, 0];
|
|
608
667
|
}
|
|
609
668
|
canvasSize() {
|
|
610
|
-
const viewport =
|
|
669
|
+
const viewport = this.presentationViewport();
|
|
611
670
|
const { resolution, width, height } = this.setup;
|
|
612
671
|
return viewport
|
|
613
672
|
? [Math.round(viewport[2] * resolution), Math.round(viewport[3] * resolution)]
|
|
614
673
|
: [Math.round(width * resolution), Math.round(height * resolution)];
|
|
615
674
|
}
|
|
675
|
+
resizeCanvas() {
|
|
676
|
+
const [width, height] = this.canvasSize();
|
|
677
|
+
if (this.canvas.width !== width)
|
|
678
|
+
this.canvas.width = width;
|
|
679
|
+
if (this.canvas.height !== height)
|
|
680
|
+
this.canvas.height = height;
|
|
681
|
+
}
|
|
616
682
|
/** Size the shared layer canvas to a band of its layer (the element is placed there by
|
|
617
683
|
* the page): a lift overlay presents a strip, not a second full-screen canvas. */
|
|
618
684
|
setCanvasViewport(viewport) {
|
|
@@ -694,20 +760,21 @@ export class WebGPURaster {
|
|
|
694
760
|
* `presentedLayers`): the same results at the same places, or nothing at all. */
|
|
695
761
|
static presentLayer(encoder, rasters) {
|
|
696
762
|
const root = rasters[0];
|
|
697
|
-
|
|
763
|
+
root.presentationSequence = rasters;
|
|
764
|
+
const visible = [...new Set([...rasters, ...root.presentationMembers])].filter(raster => !raster.resultEmpty && !!raster.targets.result && !raster.covered && !raster.suspended);
|
|
698
765
|
const signature = JSON.stringify([
|
|
699
766
|
root.canvas.width,
|
|
700
767
|
root.canvas.height,
|
|
701
768
|
visible.map(raster => [
|
|
702
769
|
raster.resultOwner,
|
|
703
770
|
raster.resultRevision,
|
|
704
|
-
raster.
|
|
705
|
-
raster.
|
|
771
|
+
raster.groupPresentUniform(root),
|
|
772
|
+
raster.groupPresentScissor(root),
|
|
706
773
|
]),
|
|
707
774
|
]);
|
|
708
775
|
if (presentedLayers.get(root.canvas) === signature)
|
|
709
776
|
return false;
|
|
710
|
-
const draws = visible.map(raster => raster.presentDraw());
|
|
777
|
+
const draws = visible.map(raster => raster.presentDraw(root));
|
|
711
778
|
const size = [root.canvas.width, root.canvas.height];
|
|
712
779
|
encodePass(encoder, {
|
|
713
780
|
colorAttachments: [
|
|
@@ -734,8 +801,35 @@ export class WebGPURaster {
|
|
|
734
801
|
presentedLayers.set(root.canvas, signature);
|
|
735
802
|
return true;
|
|
736
803
|
}
|
|
737
|
-
|
|
738
|
-
|
|
804
|
+
presentationOffset(root) {
|
|
805
|
+
if (this.presentationRoot !== root || !this.presentationOrigin)
|
|
806
|
+
return [0, 0];
|
|
807
|
+
const resolution = this.setup.resolution;
|
|
808
|
+
const [x, y] = this.canvasOffset();
|
|
809
|
+
const [rootX, rootY] = root.canvasOffset();
|
|
810
|
+
return [
|
|
811
|
+
this.presentationOrigin.x * resolution + x - rootX,
|
|
812
|
+
this.presentationOrigin.y * resolution + y - rootY,
|
|
813
|
+
];
|
|
814
|
+
}
|
|
815
|
+
groupPresentUniform(root) {
|
|
816
|
+
const values = this.presentUniform();
|
|
817
|
+
const [x, y] = this.presentationOffset(root);
|
|
818
|
+
values[0] += x;
|
|
819
|
+
values[1] += y;
|
|
820
|
+
values[8] += x;
|
|
821
|
+
values[9] += y;
|
|
822
|
+
return values;
|
|
823
|
+
}
|
|
824
|
+
groupPresentScissor(root) {
|
|
825
|
+
const rect = this.presentScissor();
|
|
826
|
+
if (!rect)
|
|
827
|
+
return rect;
|
|
828
|
+
const [x, y] = this.presentationOffset(root);
|
|
829
|
+
return [rect[0] + x, rect[1] + y, rect[2], rect[3]];
|
|
830
|
+
}
|
|
831
|
+
presentDraw(root) {
|
|
832
|
+
const buffer = this.passes.uniform(new Float32Array(this.groupPresentUniform(root)));
|
|
739
833
|
const group = this.device.createBindGroup({
|
|
740
834
|
layout: this.presentPipeline.getBindGroupLayout(0),
|
|
741
835
|
entries: [
|
|
@@ -744,7 +838,7 @@ export class WebGPURaster {
|
|
|
744
838
|
{ binding: 2, resource: viewOf(this.targets.result.texture) },
|
|
745
839
|
],
|
|
746
840
|
});
|
|
747
|
-
return { raster: this, group, rect: this.
|
|
841
|
+
return { raster: this, group, rect: this.groupPresentScissor(root) };
|
|
748
842
|
}
|
|
749
843
|
/** Scroll-attached glass is part of the scrolling content, and the navigation bar's scroll-edge
|
|
750
844
|
* effect is painted over that content (CSS, a layer above the scroll container). Its own
|
|
@@ -946,11 +1040,7 @@ export class WebGPURaster {
|
|
|
946
1040
|
const setup = this.setup;
|
|
947
1041
|
const { spec, appearance, resolution } = setup;
|
|
948
1042
|
const targets = this.targets;
|
|
949
|
-
|
|
950
|
-
if (this.canvas.width !== canvasWidth)
|
|
951
|
-
this.canvas.width = canvasWidth;
|
|
952
|
-
if (this.canvas.height !== canvasHeight)
|
|
953
|
-
this.canvas.height = canvasHeight;
|
|
1043
|
+
this.resizeCanvas();
|
|
954
1044
|
const sampleRect = this.sampleRectangle();
|
|
955
1045
|
this.presentRect = sampleRect;
|
|
956
1046
|
// Result space = layer space shifted by an integer pixel origin, so every kernel
|
|
@@ -13,6 +13,10 @@ export declare function usesBarKernel(spec: ResultLayout['spec']): boolean;
|
|
|
13
13
|
* its accessory, toolbar and glass buttons. Menus and presentation treatments keep the chained
|
|
14
14
|
* pyramid their materials were fitted on. */
|
|
15
15
|
export declare function usesAreaMips(spec: ResultLayout['spec']): boolean;
|
|
16
|
+
/** The tab material does not paint its 128 pt backdrop overscan. Keep a generous
|
|
17
|
+
* 64 pt output margin around every capsule; the fitted bar shadow has radius 4 pt
|
|
18
|
+
* and offset 8 pt. The source and blur pyramid still retain their full sample area. */
|
|
19
|
+
export declare function tabMaterialBounds(spec: ResultLayout['spec']): number[] | undefined;
|
|
16
20
|
export declare function encodeMaterial(encoder: GPUCommandEncoder, passes: Passes, targets: Record<string, Target>, layout: ResultLayout, material: Values): void;
|
|
17
21
|
/** The tab bar's selection pill (native selection recipe) and, while the pill lifts, its lens,
|
|
18
22
|
* over a composed scratch of the backdrop and the bar material. */
|
|
@@ -25,9 +25,25 @@ export function usesBarKernel(spec) {
|
|
|
25
25
|
export function usesAreaMips(spec) {
|
|
26
26
|
return usesBarKernel(spec);
|
|
27
27
|
}
|
|
28
|
+
/** The tab material does not paint its 128 pt backdrop overscan. Keep a generous
|
|
29
|
+
* 64 pt output margin around every capsule; the fitted bar shadow has radius 4 pt
|
|
30
|
+
* and offset 8 pt. The source and blur pyramid still retain their full sample area. */
|
|
31
|
+
export function tabMaterialBounds(spec) {
|
|
32
|
+
if (!spec.tabSelection || spec.materialProfile !== 'chrome' || spec.treatment)
|
|
33
|
+
return undefined;
|
|
34
|
+
const members = spec.shape.members?.length
|
|
35
|
+
? spec.shape.members
|
|
36
|
+
: [spec.shape.first, ...(spec.shape.second ? [spec.shape.second] : [])];
|
|
37
|
+
const margin = 64 + (Math.max(0, spec.shape.blendRadius) * (members.length - 1)) / 4;
|
|
38
|
+
const left = Math.min(...members.map(s => s.centerX - s.halfWidth)) + spec.sampleInsetX - margin;
|
|
39
|
+
const right = Math.max(...members.map(s => s.centerX + s.halfWidth)) + spec.sampleInsetX + margin;
|
|
40
|
+
const top = Math.min(...members.map(s => s.centerY - s.halfHeight)) + spec.sampleInsetY - margin;
|
|
41
|
+
const bottom = Math.max(...members.map(s => s.centerY + s.halfHeight)) + spec.sampleInsetY + margin;
|
|
42
|
+
return [left, top, right - left, bottom - top];
|
|
43
|
+
}
|
|
28
44
|
export function encodeMaterial(encoder, passes, targets, layout, material) {
|
|
29
45
|
const { spec, appearance, resolution, sampleWidth, sampleHeight } = layout;
|
|
30
|
-
const fieldBounds = morphFieldSampleBounds(spec);
|
|
46
|
+
const fieldBounds = tabMaterialBounds(spec) ?? morphFieldSampleBounds(spec);
|
|
31
47
|
const fieldScissor = fieldBounds?.map(value => value * resolution);
|
|
32
48
|
const first = spec.shape.first;
|
|
33
49
|
if (spec.treatment === 'contextBackdrop' || spec.treatment === 'contextPreview') {
|
|
@@ -186,7 +186,8 @@ export declare class RegionScene {
|
|
|
186
186
|
private owner;
|
|
187
187
|
/** Worker-to-page events (a close morph that ended, so its panel can be torn down). */
|
|
188
188
|
private notify;
|
|
189
|
-
|
|
189
|
+
private readonly chrome?;
|
|
190
|
+
constructor(owner: LayerDevice, notify?: (event: GlassRegionEvent) => void, submissionAllowed?: () => boolean, chrome?: WebGPURaster);
|
|
190
191
|
request(request: GlassRendererRequest): Promise<unknown>;
|
|
191
192
|
/** New source images (uploads) since the last install: only those need a completion fence. */
|
|
192
193
|
private presentationReceipt;
|
|
@@ -200,6 +201,8 @@ export declare class RegionScene {
|
|
|
200
201
|
height: number;
|
|
201
202
|
pixels: Uint8Array<ArrayBuffer>;
|
|
202
203
|
}>;
|
|
204
|
+
/** Geometry sampled for the frame trace, in viewport points. No DOM measurements. */
|
|
205
|
+
accessoryGeometry(): string;
|
|
203
206
|
diagnostics(): {
|
|
204
207
|
navigation: {
|
|
205
208
|
key: string;
|
|
@@ -1132,7 +1132,9 @@ export class RegionScene {
|
|
|
1132
1132
|
owner;
|
|
1133
1133
|
/** Worker-to-page events (a close morph that ended, so its panel can be torn down). */
|
|
1134
1134
|
notify;
|
|
1135
|
-
|
|
1135
|
+
chrome;
|
|
1136
|
+
constructor(owner, notify = () => { }, submissionAllowed = () => true, chrome) {
|
|
1137
|
+
this.chrome = chrome;
|
|
1136
1138
|
this.submissionAllowed = () => !this.sourcePending && submissionAllowed();
|
|
1137
1139
|
this.owner = owner;
|
|
1138
1140
|
this.notify = notify;
|
|
@@ -1683,6 +1685,12 @@ export class RegionScene {
|
|
|
1683
1685
|
width: bounds.width,
|
|
1684
1686
|
height: bounds.height,
|
|
1685
1687
|
};
|
|
1688
|
+
if (this.chrome) {
|
|
1689
|
+
if (region.accessoryTrack?.held && p.descriptor.role === 'material')
|
|
1690
|
+
p.raster?.joinPresentation(this.chrome, p.descriptor.origin);
|
|
1691
|
+
else
|
|
1692
|
+
p.raster?.leavePresentation();
|
|
1693
|
+
}
|
|
1686
1694
|
const viewportTrack = region.presentationViewportTrack ?? region.accessoryViewportTrack ?? region.accessoryTrack;
|
|
1687
1695
|
const accessoryTranslation = viewportTrack && !held ? accessoryOffset(viewportTrack, this.trackClock()) : undefined;
|
|
1688
1696
|
const viewport = held
|
|
@@ -1902,6 +1910,8 @@ export class RegionScene {
|
|
|
1902
1910
|
p.raster.setViewport(viewport);
|
|
1903
1911
|
p.raster.setSubmissionGate(this.submissionAllowed);
|
|
1904
1912
|
await p.raster.init(p.canvas, this.owner, region.layer, p.descriptor);
|
|
1913
|
+
if (this.chrome && region.accessoryTrack?.held && p.descriptor.role === 'material')
|
|
1914
|
+
p.raster.joinPresentation(this.chrome, p.descriptor.origin);
|
|
1905
1915
|
p.raster.setVisible(this.visible);
|
|
1906
1916
|
fence = true;
|
|
1907
1917
|
}
|
|
@@ -2070,6 +2080,25 @@ export class RegionScene {
|
|
|
2070
2080
|
throw Error('Unknown glass presenter snapshot ' + id);
|
|
2071
2081
|
return raster.snapshot();
|
|
2072
2082
|
}
|
|
2083
|
+
/** Geometry sampled for the frame trace, in viewport points. No DOM measurements. */
|
|
2084
|
+
accessoryGeometry() {
|
|
2085
|
+
return JSON.stringify([...this.presenters.values()].flatMap(p => {
|
|
2086
|
+
const region = this.scene.regions.find(r => r.id === p.descriptor.regionIds[0]);
|
|
2087
|
+
const spec = p.raster?.spec;
|
|
2088
|
+
if (!region?.accessoryTrack || !spec || p.descriptor.role !== 'material')
|
|
2089
|
+
return [];
|
|
2090
|
+
const shape = spec.shape.first;
|
|
2091
|
+
return [
|
|
2092
|
+
{
|
|
2093
|
+
region: region.id,
|
|
2094
|
+
left: p.descriptor.origin.x + spec.frameX + shape.centerX - shape.halfWidth,
|
|
2095
|
+
right: p.descriptor.origin.x + spec.frameX + shape.centerX + shape.halfWidth,
|
|
2096
|
+
top: p.descriptor.origin.y + spec.frameY + shape.centerY - shape.halfHeight,
|
|
2097
|
+
inkLayers: spec.ink?.layers.length ?? 0,
|
|
2098
|
+
},
|
|
2099
|
+
];
|
|
2100
|
+
}));
|
|
2101
|
+
}
|
|
2073
2102
|
diagnostics() {
|
|
2074
2103
|
return {
|
|
2075
2104
|
navigation: this.navigation
|
|
@@ -65,7 +65,7 @@ export function createWebGPURenderer(emit, clock = documentFrameClock) {
|
|
|
65
65
|
const raster = new WebGPURaster();
|
|
66
66
|
raster.setSubmissionGate(submissionAllowed);
|
|
67
67
|
const layers = new Map([['chrome', raster]]);
|
|
68
|
-
const appRegions = new RegionScene(owner, event => emit({ glassEvent: event }), submissionAllowed);
|
|
68
|
+
const appRegions = new RegionScene(owner, event => emit({ glassEvent: event }), submissionAllowed, clock.kind === 'worker-raf' ? raster : undefined);
|
|
69
69
|
let tab = new TabMotion(), sharedTabLift = false, reducedMotion = false;
|
|
70
70
|
const menuLift = new TabMotion(TAB_INTERACTIVE_MOTION);
|
|
71
71
|
let tabTiming;
|
|
@@ -435,8 +435,15 @@ export function createWebGPURenderer(emit, clock = documentFrameClock) {
|
|
|
435
435
|
restart: restarted,
|
|
436
436
|
};
|
|
437
437
|
loopSample = undefined;
|
|
438
|
-
|
|
439
|
-
|
|
438
|
+
if (clock.kind === 'worker-raf')
|
|
439
|
+
raster.withSharedPresentation(() => {
|
|
440
|
+
appRegions.sample(at, sampleAt);
|
|
441
|
+
render(x, [], sampleAt);
|
|
442
|
+
});
|
|
443
|
+
else {
|
|
444
|
+
appRegions.sample(at, sampleAt);
|
|
445
|
+
render(x, [], sampleAt);
|
|
446
|
+
}
|
|
440
447
|
}
|
|
441
448
|
catch (error) {
|
|
442
449
|
frameContext = undefined;
|
|
@@ -481,7 +488,16 @@ export function createWebGPURenderer(emit, clock = documentFrameClock) {
|
|
|
481
488
|
late: 0,
|
|
482
489
|
interval: previous === undefined ? null : at - previous,
|
|
483
490
|
restart: restarted,
|
|
484
|
-
clock: 'page-raf',
|
|
491
|
+
clock: clock.kind ?? 'page-raf',
|
|
492
|
+
homeLeft: raster.spec &&
|
|
493
|
+
raster.spec.frameX +
|
|
494
|
+
raster.spec.shape.first.centerX -
|
|
495
|
+
raster.spec.shape.first.halfWidth,
|
|
496
|
+
homeRight: raster.spec &&
|
|
497
|
+
raster.spec.frameX +
|
|
498
|
+
raster.spec.shape.first.centerX +
|
|
499
|
+
raster.spec.shape.first.halfWidth,
|
|
500
|
+
accessoryGeometry: clock.kind === 'worker-raf' ? appRegions.accessoryGeometry() : undefined,
|
|
485
501
|
callbackDelayMs,
|
|
486
502
|
presented: takePresentedCanvases(),
|
|
487
503
|
tabCentre: tab.frame.centre,
|
|
@@ -509,7 +525,7 @@ export function createWebGPURenderer(emit, clock = documentFrameClock) {
|
|
|
509
525
|
}
|
|
510
526
|
};
|
|
511
527
|
owner.subscribe(device => {
|
|
512
|
-
frameProbe.useDevice(device);
|
|
528
|
+
frameProbe.useDevice(device, clock.kind !== 'worker-raf');
|
|
513
529
|
lost = !device;
|
|
514
530
|
if (!device) {
|
|
515
531
|
cancel();
|
|
@@ -934,7 +950,7 @@ export function createWebGPURenderer(emit, clock = documentFrameClock) {
|
|
|
934
950
|
}
|
|
935
951
|
break;
|
|
936
952
|
case 'capability':
|
|
937
|
-
frameProbe.useDevice(await owner.ready());
|
|
953
|
+
frameProbe.useDevice(await owner.ready(), clock.kind !== 'worker-raf');
|
|
938
954
|
value = { backend: 'webgpu', timestampQuery: frameProbe.timestampQuery };
|
|
939
955
|
break;
|
|
940
956
|
case 'init':
|
|
@@ -1291,7 +1307,7 @@ export function createWebGPURenderer(emit, clock = documentFrameClock) {
|
|
|
1291
1307
|
callbacks,
|
|
1292
1308
|
framePending: !!handle,
|
|
1293
1309
|
rate: 60,
|
|
1294
|
-
clock: 'page-raf',
|
|
1310
|
+
clock: clock.kind ?? 'page-raf',
|
|
1295
1311
|
visible,
|
|
1296
1312
|
presentationGate: {
|
|
1297
1313
|
allowed: submissionAllowed(),
|
|
@@ -19,13 +19,12 @@ export declare class SourcePixels {
|
|
|
19
19
|
private worker?;
|
|
20
20
|
private next;
|
|
21
21
|
private failure?;
|
|
22
|
+
private retrying?;
|
|
22
23
|
private pending;
|
|
23
24
|
private create;
|
|
24
25
|
constructor(create?: () => Transport);
|
|
25
|
-
/**
|
|
26
|
-
*
|
|
27
|
-
* it (`ProductionGlassRuntime.replaceFailedSourcePixels`), so a failure is reported once per
|
|
28
|
-
* attempt and never retried in a loop. Canvas readbacks do not use the worker and still work. */
|
|
26
|
+
/** Exhausted upload failures let the production owner replace this source stager.
|
|
27
|
+
* Canvas readbacks remain available because they do not use the worker. */
|
|
29
28
|
get failed(): boolean;
|
|
30
29
|
private fail;
|
|
31
30
|
/** Stage `image`'s premultiplied bytes for its upload (`takeSourcePixels`). When `capture` is a
|
|
@@ -39,4 +38,5 @@ export declare class SourcePixels {
|
|
|
39
38
|
* in WebKit (157 ms for a 1320 x 5091 capture in the Catalyst host), more than half of the
|
|
40
39
|
* stage. Measured for captures up to 1320 x 6495; the cause inside WebKit was not sampled. */
|
|
41
40
|
prepare(image: ImageBitmap, capture?: ImageBitmapSource): Promise<void>;
|
|
41
|
+
private prepareOnce;
|
|
42
42
|
}
|
|
@@ -32,6 +32,7 @@ export class SourcePixels {
|
|
|
32
32
|
worker;
|
|
33
33
|
next = 0;
|
|
34
34
|
failure;
|
|
35
|
+
retrying;
|
|
35
36
|
pending = new Map();
|
|
36
37
|
create;
|
|
37
38
|
constructor(create = () => new Worker(new URL('./sourcePixels.worker.js', import.meta.url), {
|
|
@@ -40,10 +41,8 @@ export class SourcePixels {
|
|
|
40
41
|
})) {
|
|
41
42
|
this.create = create;
|
|
42
43
|
}
|
|
43
|
-
/**
|
|
44
|
-
*
|
|
45
|
-
* it (`ProductionGlassRuntime.replaceFailedSourcePixels`), so a failure is reported once per
|
|
46
|
-
* attempt and never retried in a loop. Canvas readbacks do not use the worker and still work. */
|
|
44
|
+
/** Exhausted upload failures let the production owner replace this source stager.
|
|
45
|
+
* Canvas readbacks remain available because they do not use the worker. */
|
|
47
46
|
get failed() {
|
|
48
47
|
return !!this.failure;
|
|
49
48
|
}
|
|
@@ -51,7 +50,9 @@ export class SourcePixels {
|
|
|
51
50
|
this.failure = error;
|
|
52
51
|
this.worker?.terminate();
|
|
53
52
|
this.worker = undefined;
|
|
54
|
-
reportGlassError('worker-startup', error, 'source upload worker'
|
|
53
|
+
reportGlassError('worker-startup', error, 'source upload worker', undefined, {
|
|
54
|
+
recovering: true,
|
|
55
|
+
});
|
|
55
56
|
for (const job of this.pending.values()) {
|
|
56
57
|
clearTimeout(job.timer);
|
|
57
58
|
job.reject(error);
|
|
@@ -69,6 +70,30 @@ export class SourcePixels {
|
|
|
69
70
|
* in WebKit (157 ms for a 1320 x 5091 capture in the Catalyst host), more than half of the
|
|
70
71
|
* stage. Measured for captures up to 1320 x 6495; the cause inside WebKit was not sampled. */
|
|
71
72
|
async prepare(image, capture = image) {
|
|
73
|
+
for (let attempt = 0;; attempt++) {
|
|
74
|
+
try {
|
|
75
|
+
if (this.retrying)
|
|
76
|
+
await this.retrying;
|
|
77
|
+
return await this.prepareOnce(image, capture);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (!this.failure)
|
|
81
|
+
throw error;
|
|
82
|
+
if (attempt === 2) {
|
|
83
|
+
reportGlassError('worker-startup', error, 'source upload worker');
|
|
84
|
+
throw new Error(`Source upload worker startup failed: ${String(error)}`, { cause: error });
|
|
85
|
+
}
|
|
86
|
+
// Concurrent uploads share one restart. Each retry clones from the retained
|
|
87
|
+
// source, never from the bitmap already transferred to the failed worker.
|
|
88
|
+
this.retrying ??= new Promise(resolve => setTimeout(resolve, 250 * 2 ** attempt)).then(() => {
|
|
89
|
+
this.failure = undefined;
|
|
90
|
+
this.retrying = undefined;
|
|
91
|
+
});
|
|
92
|
+
await this.retrying;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async prepareOnce(image, capture) {
|
|
72
97
|
const readbackStarted = glassNow();
|
|
73
98
|
const pixels = canvasPixels(capture);
|
|
74
99
|
if (pixels) {
|
|
@@ -83,8 +108,17 @@ export class SourcePixels {
|
|
|
83
108
|
if (this.failure)
|
|
84
109
|
throw this.failure;
|
|
85
110
|
if (!this.worker) {
|
|
86
|
-
|
|
111
|
+
try {
|
|
112
|
+
this.worker = this.create();
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
this.fail(error instanceof Error ? error : Error(String(error)));
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
const worker = this.worker;
|
|
87
119
|
this.worker.onmessage = event => {
|
|
120
|
+
if (this.worker !== worker)
|
|
121
|
+
return;
|
|
88
122
|
const data = event.data;
|
|
89
123
|
if (data.id === 0 && data.error) {
|
|
90
124
|
this.fail(Error(data.error));
|
|
@@ -109,8 +143,14 @@ export class SourcePixels {
|
|
|
109
143
|
prepared.set(job.image, new Uint8Array(data.buffer));
|
|
110
144
|
job.resolve();
|
|
111
145
|
};
|
|
112
|
-
this.worker.onerror = event =>
|
|
113
|
-
|
|
146
|
+
this.worker.onerror = event => {
|
|
147
|
+
if (this.worker === worker)
|
|
148
|
+
this.fail(Error(event.message || 'Source upload worker failed'));
|
|
149
|
+
};
|
|
150
|
+
this.worker.onmessageerror = () => {
|
|
151
|
+
if (this.worker === worker)
|
|
152
|
+
this.fail(Error('Source upload worker reply could not be decoded'));
|
|
153
|
+
};
|
|
114
154
|
}
|
|
115
155
|
const started = glassNow(), clone = await createImageBitmap(capture);
|
|
116
156
|
if (this.failure) {
|
package/dist/keyboard/adapter.js
CHANGED
|
@@ -150,7 +150,7 @@ export function dismissKeyboard() {
|
|
|
150
150
|
}
|
|
151
151
|
/** One adapter per SatinRoot. Owns viewport listeners, never cancels editing gestures. */
|
|
152
152
|
/**
|
|
153
|
-
* Tapping a control keeps the editor's keyboard,
|
|
153
|
+
* Tapping a control keeps the editor's keyboard, so button presses preserve the first
|
|
154
154
|
* responder: a hand-rolled composer's Send must not close the keyboard (iPad, October 12, 0/5 kept).
|
|
155
155
|
* Controls that leave the editor end editing themselves: navigation and tab switches dismiss
|
|
156
156
|
* explicitly, and a presented modal takes focus through showModal().
|
|
@@ -517,7 +517,7 @@ export function installKeyboardAdapter(root) {
|
|
|
517
517
|
releaseSurface();
|
|
518
518
|
revealedFor = '';
|
|
519
519
|
revealedEditor = null;
|
|
520
|
-
// Navigation ends editing,
|
|
520
|
+
// Navigation ends editing, when a screen is pushed or popped.
|
|
521
521
|
if (editable(document.activeElement) && root.contains(document.activeElement))
|
|
522
522
|
document.activeElement.blur();
|
|
523
523
|
};
|
|
@@ -7,7 +7,7 @@ export type KeyboardClearance = Readonly<{
|
|
|
7
7
|
bottom: number;
|
|
8
8
|
}>;
|
|
9
9
|
/**
|
|
10
|
-
* Keyboard avoidance for the focused editor,
|
|
10
|
+
* Keyboard avoidance for the focused editor, through the keyboard safe area: every
|
|
11
11
|
* scroll surface around the editor gets room below its content for the keyboard and scrolls the
|
|
12
12
|
* editor (a tall editor: its caret line) above the keyboard, innermost first. An editor on a page
|
|
13
13
|
* whose surfaces cannot bring it up (a screen without a scroll surface, or a nested scroller pinned
|
|
@@ -29,7 +29,7 @@ function keepEditorInBox(delta, editor, box, offsetTop, room) {
|
|
|
29
29
|
return Math.min(high, Math.max(low, delta));
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
|
-
* Keyboard avoidance for the focused editor,
|
|
32
|
+
* Keyboard avoidance for the focused editor, through the keyboard safe area: every
|
|
33
33
|
* scroll surface around the editor gets room below its content for the keyboard and scrolls the
|
|
34
34
|
* editor (a tall editor: its caret line) above the keyboard, innermost first. An editor on a page
|
|
35
35
|
* whose surfaces cannot bring it up (a screen without a scroll surface, or a nested scroller pinned
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The software keyboard's show and hide motion. Measured per frame from the native composer's
|
|
3
|
-
* presentation layer (
|
|
3
|
+
* presentation layer (`ScrollView` + `.safeAreaInset(edge: .bottom)` chat, iOS 27.0
|
|
4
4
|
* Simulator, 4 shows and 4 hides; UIKit reports duration 0.3833 s, curve 7): a spring with mass 1,
|
|
5
5
|
* stiffness 535 and damping 45.5, released at rest, fits every frame of both directions within
|
|
6
6
|
* 0.84 pt of a 313 pt travel. UIKit ends the animation at 383 ms (0.4 pt short of rest).
|
package/dist/keyboard/motion.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { menuSpring } from '../glass/lens/menuSpring.js';
|
|
2
2
|
/**
|
|
3
3
|
* The software keyboard's show and hide motion. Measured per frame from the native composer's
|
|
4
|
-
* presentation layer (
|
|
4
|
+
* presentation layer (`ScrollView` + `.safeAreaInset(edge: .bottom)` chat, iOS 27.0
|
|
5
5
|
* Simulator, 4 shows and 4 hides; UIKit reports duration 0.3833 s, curve 7): a spring with mass 1,
|
|
6
6
|
* stiffness 535 and damping 45.5, released at rest, fits every frame of both directions within
|
|
7
7
|
* 0.84 pt of a 313 pt travel. UIKit ends the animation at 383 ms (0.4 pt short of rest).
|
|
@@ -11,13 +11,13 @@ export type ListEngineProps = Omit<ScrollSurfaceProps, 'axis'> & {
|
|
|
11
11
|
variant?: 'insetGrouped' | 'plain' | 'grouped' | 'sidebar';
|
|
12
12
|
navigationTitle?: string;
|
|
13
13
|
navigationTitleDisplay?: 'large' | 'inline';
|
|
14
|
-
/**
|
|
14
|
+
/** A search field in the navigation bar drawer above this List. */
|
|
15
15
|
searchable?: Searchable;
|
|
16
16
|
/** Default keeps measured header/footer gaps. Numbers are section gaps in points. */
|
|
17
17
|
listSectionSpacing?: 'default' | 'compact' | number;
|
|
18
18
|
/** Controls the scroll surface background, leaving row backgrounds intact. */
|
|
19
19
|
scrollContentBackground?: 'hidden' | 'visible';
|
|
20
|
-
/**
|
|
20
|
+
/** Selection state resolved by the public List. */
|
|
21
21
|
selectionBinding?: ListSelectionBinding;
|
|
22
22
|
};
|
|
23
23
|
/** A controlled List selection: one optional row, or a set of rows. */
|
|
@@ -410,7 +410,7 @@ export function StepperControl({ value, onChange, min = -Infinity, max = Infinit
|
|
|
410
410
|
return (_jsxs("span", { "data-satin-row-intrinsic": "true", "data-satin-stepper": "true", "data-satin-stepper-disabled": disabled ? 'true' : undefined, role: "group", "aria-label": `${label}, ${value}`, children: [_jsx(StepperButton, { onEditingChange: onEditingChange, label: `Decrease ${label}, ${value}`, disabled: disabled || value <= min, onStep: () => onChange(clampStepper(value, min, max, -step)), children: _jsx(SymbolImage, { name: "minus", weight: "medium" }) }), _jsx(StepperButton, { onEditingChange: onEditingChange, label: `Increase ${label}, ${value}`, disabled: disabled || value >= max, onStep: () => onChange(clampStepper(value, min, max, step)), children: _jsx(SymbolImage, { name: "plus", weight: "semibold" }) })] }));
|
|
411
411
|
}
|
|
412
412
|
export function StepperRow(props) {
|
|
413
|
-
return _jsx(Row, { label: props.label, trailing: _jsx(StepperControl, { ...props }) });
|
|
413
|
+
return (_jsx(Row, { className: "satin-stepper-row", label: props.label, trailing: _jsx(StepperControl, { ...props }) }));
|
|
414
414
|
}
|
|
415
415
|
export function SegmentedControl({ value, onChange, options, disabled = false, label = 'Options', style: cssStyle, }) {
|
|
416
416
|
const choose = useChooseOption({ options, disabled, onChange });
|
|
@@ -208,7 +208,7 @@ function playEditShift(item, before, after) {
|
|
|
208
208
|
}
|
|
209
209
|
/**
|
|
210
210
|
* The reorder handle's pointer-down: lifts this row and hands it to the drag. The move is
|
|
211
|
-
* reported through `onMove` with
|
|
211
|
+
* reported through `onMove` with source-sequence offsets once the row settles; the new order must then
|
|
212
212
|
* come back through `data`, and until it does the rows stay where they were dropped.
|
|
213
213
|
*/
|
|
214
214
|
function useReorder(element, item, editing) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
/**
|
|
3
|
-
* A row title with a secondary line under it:
|
|
3
|
+
* A row title with a secondary line under it: a two-line label (a title, then a
|
|
4
4
|
* `.subheadline` `.secondary` line). `RowLabel`, a Toggle subtitle and any other two-line row
|
|
5
5
|
* label share this one body, so their type and spacing cannot drift apart.
|
|
6
6
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
|
-
* A row title with a secondary line under it:
|
|
3
|
+
* A row title with a secondary line under it: a two-line label (a title, then a
|
|
4
4
|
* `.subheadline` `.secondary` line). `RowLabel`, a Toggle subtitle and any other two-line row
|
|
5
5
|
* label share this one body, so their type and spacing cannot drift apart.
|
|
6
6
|
*
|
package/dist/list-form/Slider.js
CHANGED
|
@@ -5,7 +5,7 @@ import { requestGlassSourceDamage } from '../glass/sourceDamage.js';
|
|
|
5
5
|
import { CONTROL_LIFT, controlLensSize } from '../glass/lens/controlLift.js';
|
|
6
6
|
import { SliderContact } from './sliderContact.js';
|
|
7
7
|
/**
|
|
8
|
-
* Native geometry (
|
|
8
|
+
* Native geometry (a slider in a form, iPhone 17 Pro Max Simulator, iOS 27.0, 3×
|
|
9
9
|
* stills, native control measurements `continuous.mov`): a 6 pt capsule track spans the row's content
|
|
10
10
|
* width, the 37 × 24 pt white thumb stays inside it (its centre travels from 18.5 pt to width −
|
|
11
11
|
* 18.5 pt), and the accent fill runs from the track start to the thumb centre with a round end.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Edit mode's row motion, measured from a
|
|
2
|
+
* Edit mode's row motion, measured from a native list with `onDelete` and `onMove`
|
|
3
3
|
* (iOS 27.0 Simulator, native reference `native list editing fixture native editing fixture`, five Edit/Done toggles, frame counter
|
|
4
4
|
* retimed: 0 skipped refreshes of 922; fits in native measurements).
|
|
5
5
|
*
|