@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swifttui/web",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "license": "MIT",
5
5
  "module": "index.ts",
6
6
  "exports": {
@@ -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 dirtyRects = frame ? this.dirtyRectsForDamage(damage, frame) : undefined;
504
- if (dirtyRects?.length === 0) {
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 (dirtyRects) {
514
- for (const rect of dirtyRects) {
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, dirtyRects);
528
- this.drawImages(context, frame.images ?? [], dirtyRects);
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
- dirtyRects?: DirtyRect[]
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
- for (const cell of row) {
539
- const [x, text, span, styleIndex] = cell;
540
- const cellRect = this.cellRect(x, y, span);
541
- if (dirtyRects && !dirtyRects.some((rect) => rectsIntersect(rect, cellRect))) {
542
- continue;
543
- }
544
- const style = frame.styles[styleIndex] ?? undefined;
545
- this.drawCell(context, x, y, text, span, style);
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
- dirtyRects?: DirtyRect[]
596
+ dirtyRegion?: DirtyRegion
568
597
  ): void {
569
598
  for (const image of images) {
570
- this.drawImage(context, image, dirtyRects);
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
- dirtyRects?: DirtyRect[]
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
- const imageRect = {
590
- x: clipX * this.cellWidth,
591
- y: clipY * this.cellHeight,
592
- width: clipWidth * this.cellWidth,
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 dirtyRectsForDamage(
715
+ private dirtyRegionForDamage(
690
716
  damage: WebHostSurfaceDamage | undefined,
691
717
  frame: WebHostSurfaceFrame
692
- ): DirtyRect[] | undefined {
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 rectsIntersect(
928
- lhs: DirtyRect,
929
- rhs: DirtyRect
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
- return lhs.x < rhs.x + rhs.width
932
- && lhs.x + lhs.width > rhs.x
933
- && lhs.y < rhs.y + rhs.height
934
- && lhs.y + lhs.height > rhs.y;
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(