@swifttui/web 0.0.9 → 0.0.10
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/package.json +1 -1
- package/src/WebHostSceneRuntime.test.ts +66 -0
- package/src/WebHostSceneRuntime.ts +116 -35
package/package.json
CHANGED
|
@@ -216,6 +216,72 @@ test("runtime redraws only damaged cells when a compatible frame includes damage
|
|
|
216
216
|
}
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
+
test("runtime redraws spanning cells that overlap a dirty range", async () => {
|
|
220
|
+
const dom = installFakeDOM();
|
|
221
|
+
try {
|
|
222
|
+
const bridge = new BrowserWASIBridge({
|
|
223
|
+
sceneId: "main",
|
|
224
|
+
columns: 6,
|
|
225
|
+
rows: 1,
|
|
226
|
+
});
|
|
227
|
+
const mount = new FakeElement("div");
|
|
228
|
+
const runtime = new WebHostSceneRuntime({
|
|
229
|
+
mount: mount as unknown as HTMLElement,
|
|
230
|
+
descriptor: { id: "main", title: "Main", isDefault: true },
|
|
231
|
+
style: {
|
|
232
|
+
fontSize: 20,
|
|
233
|
+
fontFamily: "Test Mono",
|
|
234
|
+
},
|
|
235
|
+
bridge,
|
|
236
|
+
onInput: () => {},
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await runtime.mount();
|
|
240
|
+
|
|
241
|
+
const canvas = dom.canvases[0]!;
|
|
242
|
+
const context = canvas.context;
|
|
243
|
+
bridge.stdout.write(encoder.encode(surfaceRecord({
|
|
244
|
+
version: 1,
|
|
245
|
+
width: 6,
|
|
246
|
+
height: 1,
|
|
247
|
+
styles: [null],
|
|
248
|
+
rows: [
|
|
249
|
+
[[0, "Wide", 4, 0], [4, "Z", 1, 0]],
|
|
250
|
+
],
|
|
251
|
+
images: [],
|
|
252
|
+
})));
|
|
253
|
+
|
|
254
|
+
context.operations = [];
|
|
255
|
+
bridge.stdout.write(encoder.encode(surfaceRecord({
|
|
256
|
+
version: 1,
|
|
257
|
+
width: 6,
|
|
258
|
+
height: 1,
|
|
259
|
+
styles: [null],
|
|
260
|
+
rows: [
|
|
261
|
+
[[0, "wide", 4, 0], [4, "Z", 1, 0]],
|
|
262
|
+
],
|
|
263
|
+
images: [],
|
|
264
|
+
damage: {
|
|
265
|
+
textRows: [[0, [[2, 3]]]],
|
|
266
|
+
requiresFullTextRepaint: false,
|
|
267
|
+
requiresFullGraphicsReplay: false,
|
|
268
|
+
},
|
|
269
|
+
})));
|
|
270
|
+
|
|
271
|
+
expect(context.operations).toContainEqual({
|
|
272
|
+
type: "clearRect",
|
|
273
|
+
x: 20,
|
|
274
|
+
y: 0,
|
|
275
|
+
width: 10,
|
|
276
|
+
height: 27,
|
|
277
|
+
});
|
|
278
|
+
expect(fillTextOperations(context, "wide")).toHaveLength(1);
|
|
279
|
+
expect(fillTextOperations(context, "Z")).toEqual([]);
|
|
280
|
+
} finally {
|
|
281
|
+
dom.restore();
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
219
285
|
test("runtime clears stale overlay text when dirty rects remove an overlay", async () => {
|
|
220
286
|
const dom = installFakeDOM();
|
|
221
287
|
try {
|
|
@@ -57,6 +57,18 @@ interface DirtyRect {
|
|
|
57
57
|
height: number;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
interface DirtyCellRange {
|
|
61
|
+
start: number;
|
|
62
|
+
end: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type DirtyRowRanges = "full" | DirtyCellRange[];
|
|
66
|
+
|
|
67
|
+
interface DirtyRegion {
|
|
68
|
+
rects: DirtyRect[];
|
|
69
|
+
rows: Map<number, DirtyRowRanges>;
|
|
70
|
+
}
|
|
71
|
+
|
|
60
72
|
export class WebHostSceneRuntime {
|
|
61
73
|
readonly descriptor: WebHostSceneDescriptor;
|
|
62
74
|
readonly element: HTMLElement;
|
|
@@ -500,8 +512,8 @@ export class WebHostSceneRuntime {
|
|
|
500
512
|
}
|
|
501
513
|
|
|
502
514
|
const frame = this.currentFrame;
|
|
503
|
-
const
|
|
504
|
-
if (
|
|
515
|
+
const dirtyRegion = frame ? this.dirtyRegionForDamage(damage, frame) : undefined;
|
|
516
|
+
if (dirtyRegion?.rects.length === 0) {
|
|
505
517
|
return;
|
|
506
518
|
}
|
|
507
519
|
|
|
@@ -510,8 +522,8 @@ export class WebHostSceneRuntime {
|
|
|
510
522
|
context.textBaseline = "alphabetic";
|
|
511
523
|
|
|
512
524
|
context.fillStyle = webTUITerminalBackgroundColor(this.currentStyle);
|
|
513
|
-
if (
|
|
514
|
-
for (const rect of
|
|
525
|
+
if (dirtyRegion) {
|
|
526
|
+
for (const rect of dirtyRegion.rects) {
|
|
515
527
|
context.clearRect(rect.x, rect.y, rect.width, rect.height);
|
|
516
528
|
context.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
517
529
|
}
|
|
@@ -524,26 +536,43 @@ export class WebHostSceneRuntime {
|
|
|
524
536
|
return;
|
|
525
537
|
}
|
|
526
538
|
|
|
527
|
-
this.drawRows(context, frame,
|
|
528
|
-
this.drawImages(context, frame.images ?? [],
|
|
539
|
+
this.drawRows(context, frame, dirtyRegion);
|
|
540
|
+
this.drawImages(context, frame.images ?? [], dirtyRegion);
|
|
529
541
|
}
|
|
530
542
|
|
|
531
543
|
private drawRows(
|
|
532
544
|
context: CanvasRenderingContext2D,
|
|
533
545
|
frame: WebHostSurfaceFrame,
|
|
534
|
-
|
|
546
|
+
dirtyRegion?: DirtyRegion
|
|
535
547
|
): void {
|
|
548
|
+
if (dirtyRegion) {
|
|
549
|
+
for (const [y, ranges] of dirtyRegion.rows) {
|
|
550
|
+
const row = frame.rows[y] ?? [];
|
|
551
|
+
this.drawRow(context, frame, row, y, ranges);
|
|
552
|
+
}
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
536
556
|
for (let y = 0; y < frame.rows.length; y += 1) {
|
|
537
557
|
const row = frame.rows[y] ?? [];
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
558
|
+
this.drawRow(context, frame, row, y);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private drawRow(
|
|
563
|
+
context: CanvasRenderingContext2D,
|
|
564
|
+
frame: WebHostSurfaceFrame,
|
|
565
|
+
row: WebHostSurfaceFrame["rows"][number],
|
|
566
|
+
y: number,
|
|
567
|
+
ranges?: DirtyRowRanges
|
|
568
|
+
): void {
|
|
569
|
+
for (const cell of row) {
|
|
570
|
+
const [x, text, span, styleIndex] = cell;
|
|
571
|
+
if (ranges !== undefined && !cellIntersectsRanges(x, span, ranges)) {
|
|
572
|
+
continue;
|
|
546
573
|
}
|
|
574
|
+
const style = frame.styles[styleIndex] ?? undefined;
|
|
575
|
+
this.drawCell(context, x, y, text, span, style);
|
|
547
576
|
}
|
|
548
577
|
}
|
|
549
578
|
|
|
@@ -564,17 +593,17 @@ export class WebHostSceneRuntime {
|
|
|
564
593
|
private drawImages(
|
|
565
594
|
context: CanvasRenderingContext2D,
|
|
566
595
|
images: WebHostSurfaceImage[],
|
|
567
|
-
|
|
596
|
+
dirtyRegion?: DirtyRegion
|
|
568
597
|
): void {
|
|
569
598
|
for (const image of images) {
|
|
570
|
-
this.drawImage(context, image,
|
|
599
|
+
this.drawImage(context, image, dirtyRegion);
|
|
571
600
|
}
|
|
572
601
|
}
|
|
573
602
|
|
|
574
603
|
private drawImage(
|
|
575
604
|
context: CanvasRenderingContext2D,
|
|
576
605
|
image: WebHostSurfaceImage,
|
|
577
|
-
|
|
606
|
+
dirtyRegion?: DirtyRegion
|
|
578
607
|
): void {
|
|
579
608
|
const decodedImage = this.cachedImage(image);
|
|
580
609
|
if (!decodedImage) {
|
|
@@ -586,13 +615,10 @@ export class WebHostSceneRuntime {
|
|
|
586
615
|
if (boundsWidth <= 0 || boundsHeight <= 0 || clipWidth <= 0 || clipHeight <= 0) {
|
|
587
616
|
return;
|
|
588
617
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
height: clipHeight * this.cellHeight,
|
|
594
|
-
};
|
|
595
|
-
if (dirtyRects && !dirtyRects.some((rect) => rectsIntersect(rect, imageRect))) {
|
|
618
|
+
if (
|
|
619
|
+
dirtyRegion
|
|
620
|
+
&& !dirtyRegionIntersectsCellRect(dirtyRegion, clipX, clipY, clipWidth, clipHeight)
|
|
621
|
+
) {
|
|
596
622
|
return;
|
|
597
623
|
}
|
|
598
624
|
|
|
@@ -686,23 +712,28 @@ export class WebHostSceneRuntime {
|
|
|
686
712
|
context.globalAlpha = 1;
|
|
687
713
|
}
|
|
688
714
|
|
|
689
|
-
private
|
|
715
|
+
private dirtyRegionForDamage(
|
|
690
716
|
damage: WebHostSurfaceDamage | undefined,
|
|
691
717
|
frame: WebHostSurfaceFrame
|
|
692
|
-
):
|
|
718
|
+
): DirtyRegion | undefined {
|
|
693
719
|
if (!damage || damage.requiresFullTextRepaint || damage.requiresFullGraphicsReplay) {
|
|
694
720
|
return undefined;
|
|
695
721
|
}
|
|
696
722
|
|
|
697
723
|
const rects: DirtyRect[] = [];
|
|
724
|
+
const rows = new Map<number, DirtyRowRanges>();
|
|
698
725
|
for (const [row, ranges] of damage.textRows) {
|
|
699
726
|
if (row < 0 || row >= frame.height) {
|
|
700
727
|
continue;
|
|
701
728
|
}
|
|
702
729
|
if (ranges.length === 0) {
|
|
703
730
|
rects.push(this.cellRect(0, row, frame.width));
|
|
731
|
+
rows.set(row, "full");
|
|
704
732
|
continue;
|
|
705
733
|
}
|
|
734
|
+
const rowRanges: DirtyCellRange[] = rows.get(row) === "full"
|
|
735
|
+
? []
|
|
736
|
+
: [...(rows.get(row) as DirtyCellRange[] | undefined ?? [])];
|
|
706
737
|
for (const [start, end] of ranges) {
|
|
707
738
|
const lowerBound = Math.max(0, Math.min(frame.width, Math.floor(start)));
|
|
708
739
|
const upperBound = Math.max(lowerBound, Math.min(frame.width, Math.ceil(end)));
|
|
@@ -710,9 +741,13 @@ export class WebHostSceneRuntime {
|
|
|
710
741
|
continue;
|
|
711
742
|
}
|
|
712
743
|
rects.push(this.cellRect(lowerBound, row, upperBound - lowerBound));
|
|
744
|
+
rowRanges.push({ start: lowerBound, end: upperBound });
|
|
745
|
+
}
|
|
746
|
+
if (rows.get(row) !== "full" && rowRanges.length > 0) {
|
|
747
|
+
rows.set(row, normalizeCellRanges(rowRanges));
|
|
713
748
|
}
|
|
714
749
|
}
|
|
715
|
-
return rects;
|
|
750
|
+
return { rects, rows };
|
|
716
751
|
}
|
|
717
752
|
|
|
718
753
|
private cellRect(
|
|
@@ -924,14 +959,60 @@ function normalizedWheelDelta(
|
|
|
924
959
|
return 0;
|
|
925
960
|
}
|
|
926
961
|
|
|
927
|
-
function
|
|
928
|
-
|
|
929
|
-
|
|
962
|
+
function normalizeCellRanges(
|
|
963
|
+
ranges: DirtyCellRange[]
|
|
964
|
+
): DirtyCellRange[] {
|
|
965
|
+
const sorted = ranges
|
|
966
|
+
.filter((range) => range.end > range.start)
|
|
967
|
+
.sort((lhs, rhs) => lhs.start - rhs.start || lhs.end - rhs.end);
|
|
968
|
+
const normalized: DirtyCellRange[] = [];
|
|
969
|
+
for (const range of sorted) {
|
|
970
|
+
const previous = normalized[normalized.length - 1];
|
|
971
|
+
if (previous && range.start <= previous.end) {
|
|
972
|
+
previous.end = Math.max(previous.end, range.end);
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
normalized.push({ ...range });
|
|
976
|
+
}
|
|
977
|
+
return normalized;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function cellIntersectsRanges(
|
|
981
|
+
x: number,
|
|
982
|
+
span: number,
|
|
983
|
+
ranges: DirtyRowRanges
|
|
930
984
|
): boolean {
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
985
|
+
if (ranges === "full") {
|
|
986
|
+
return true;
|
|
987
|
+
}
|
|
988
|
+
const start = Math.floor(x);
|
|
989
|
+
const end = start + Math.max(1, Math.ceil(span));
|
|
990
|
+
return ranges.some((range) => start < range.end && end > range.start);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function dirtyRegionIntersectsCellRect(
|
|
994
|
+
region: DirtyRegion,
|
|
995
|
+
x: number,
|
|
996
|
+
y: number,
|
|
997
|
+
width: number,
|
|
998
|
+
height: number
|
|
999
|
+
): boolean {
|
|
1000
|
+
const startRow = Math.max(0, Math.floor(y));
|
|
1001
|
+
const endRow = Math.max(startRow, Math.ceil(y + height));
|
|
1002
|
+
const rectRange = {
|
|
1003
|
+
start: Math.floor(x),
|
|
1004
|
+
end: Math.floor(x) + Math.max(1, Math.ceil(width)),
|
|
1005
|
+
};
|
|
1006
|
+
for (let row = startRow; row < endRow; row += 1) {
|
|
1007
|
+
const ranges = region.rows.get(row);
|
|
1008
|
+
if (!ranges) {
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
if (cellIntersectsRanges(rectRange.start, rectRange.end - rectRange.start, ranges)) {
|
|
1012
|
+
return true;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return false;
|
|
935
1016
|
}
|
|
936
1017
|
|
|
937
1018
|
function resolvedForeground(
|