ntk 3.5.1 → 3.5.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.
@@ -552,6 +552,34 @@ class RenderingContext2d {
552
552
  * Core fill: rasterize device-space polys into the scratch a8 mask,
553
553
  * scale by globalAlpha, intersect with the clip, composite the source.
554
554
  */
555
+ /**
556
+ * Device-space bounding box of flattened polys, with a pixel of slack for
557
+ * the antialiased edge, clamped to the surface. Null when nothing lands
558
+ * on it.
559
+ */
560
+ _polysBBox(polys) {
561
+ let minX = Infinity;
562
+ let minY = Infinity;
563
+ let maxX = -Infinity;
564
+ let maxY = -Infinity;
565
+ for (const poly of polys) {
566
+ const pts = poly.pts;
567
+ for (let i = 0; i < pts.length; i += 2) {
568
+ if (pts[i] < minX) minX = pts[i];
569
+ if (pts[i] > maxX) maxX = pts[i];
570
+ if (pts[i + 1] < minY) minY = pts[i + 1];
571
+ if (pts[i + 1] > maxY) maxY = pts[i + 1];
572
+ }
573
+ }
574
+ if (maxX === -Infinity) return null;
575
+ const x = Math.max(0, Math.floor(minX) - 1);
576
+ const y = Math.max(0, Math.floor(minY) - 1);
577
+ const w = Math.min(this.width, Math.ceil(maxX) + 1) - x;
578
+ const h = Math.min(this.height, Math.ceil(maxY) + 1) - y;
579
+ if (w <= 0 || h <= 0) return null;
580
+ return { x, y, w, h };
581
+ }
582
+
555
583
  _fillPolys(polys, rule, { src = null, op = null, alpha = null } = {}) {
556
584
  if (!polys.length) return;
557
585
  src = src ?? this._backgroundPicture;
@@ -559,26 +587,31 @@ class RenderingContext2d {
559
587
  alpha = alpha ?? this.globalAlpha;
560
588
  if (alpha <= 0) return;
561
589
 
590
+ // Everything below is bounded to the shape's bounding box rather than
591
+ // the whole surface. On the wire it makes no difference — a Composite
592
+ // request is the same size either way — but it is the difference
593
+ // between the server touching a 34x34 box and a 400x400 one per fill.
594
+ // Stale mask content outside the box is never composited, so clearing
595
+ // only the box is safe.
596
+ const b = this._polysBBox(polys);
597
+ if (!b) return;
598
+ const R = this.Render;
599
+
562
600
  this._ensureFillMask();
563
- this.Render.FillRectangles(this.Render.PictOp.Src, this.fillMask.id, [0, 0, 0, 0], [0, 0, this.width, this.height]);
601
+ R.FillRectangles(R.PictOp.Src, this.fillMask.id, [0, 0, 0, 0], [b.x, b.y, b.w, b.h]);
564
602
  this._rasterizePolys(this.fillMask, polys, rule);
565
603
 
566
604
  if (alpha < 1) {
567
605
  // In with a constant color scales the a8 coverage by that alpha
568
- this.Render.FillRectangles(this.Render.PictOp.In, this.fillMask.id, [0, 0, 0, alpha], [0, 0, this.width, this.height]);
606
+ R.FillRectangles(R.PictOp.In, this.fillMask.id, [0, 0, 0, alpha], [b.x, b.y, b.w, b.h]);
569
607
  }
570
608
  if (this.clipMask) {
571
- this.Render.Composite(
572
- this.Render.PictOp.In,
573
- this.clipMask.id,
574
- 0,
575
- this.fillMask.id,
576
- 0, 0, 0, 0, 0, 0,
577
- this.width,
578
- this.height
579
- );
609
+ // clipMask is surface-aligned, so it is sampled at the same offset
610
+ R.Composite(R.PictOp.In, this.clipMask.id, 0, this.fillMask.id, b.x, b.y, 0, 0, b.x, b.y, b.w, b.h);
580
611
  }
581
- this.Render.Composite(op, src.id, this.fillMask.id, this.picture.id, 0, 0, 0, 0, 0, 0, this.width, this.height);
612
+ // src is either a 1x1 repeating solid (offset irrelevant) or a
613
+ // surface-aligned gradient, so it is sampled at the same offset too
614
+ R.Composite(op, src.id, this.fillMask.id, this.picture.id, b.x, b.y, b.x, b.y, b.x, b.y, b.w, b.h);
582
615
  this._markDirty();
583
616
  }
584
617
 
@@ -754,6 +787,19 @@ class RenderingContext2d {
754
787
  this._markDirty();
755
788
  return;
756
789
  }
790
+ // Fast path: a rectangular clip is something the server can do itself.
791
+ // Two small requests around the ordinary glyph composite, instead of
792
+ // clearing and compositing a full-surface a8 mask three times — which
793
+ // costs the same on the wire but many times the pixel work.
794
+ const rect = this._clipRect();
795
+ if (rect) {
796
+ if (rect.w === 0 || rect.h === 0) return;
797
+ R.SetPictureClipRectangles(this.picture.id, 0, 0, [rect.x, rect.y, rect.w, rect.h]);
798
+ drawGlyphRuns(app, op, src.id, this.picture.id, positioned);
799
+ R.SetPictureClipRectangles(this.picture.id, 0, 0, [0, 0, this.width, this.height]);
800
+ this._markDirty();
801
+ return;
802
+ }
757
803
  this._ensureFillMask();
758
804
  this._glyphSource ??= this.createSolidPicture(1, 1, 1, 1);
759
805
  R.FillRectangles(R.PictOp.Src, this.fillMask.id, [0, 0, 0, 0], [0, 0, this.width, this.height]);
@@ -841,9 +887,66 @@ class RenderingContext2d {
841
887
  * Cleared by restore() to the state at the matching save() — like the
842
888
  * browser canvas, there is no other way to widen the clip again.
843
889
  */
890
+ /**
891
+ * The axis-aligned rectangle a flattened path describes, or null. Clips
892
+ * in practice are almost always rectangles — a content box, a scroll
893
+ * viewport, `overflow: hidden` — and a rectangular clip can be pushed
894
+ * to the server instead of rasterized into a mask.
895
+ */
896
+ static _rectOfPolys(polys) {
897
+ if (polys.length !== 1) return null;
898
+ const { pts } = polys[0];
899
+ if (pts.length !== 8) return null;
900
+ const [x0, y0, x1, y1, x2, y2, x3, y3] = pts;
901
+ const axis =
902
+ (y0 === y1 && x1 === x2 && y2 === y3 && x3 === x0) ||
903
+ (x0 === x1 && y1 === y2 && x2 === x3 && y3 === y0);
904
+ if (!axis) return null;
905
+ const xs = [x0, x1, x2, x3];
906
+ const ys = [y0, y1, y2, y3];
907
+ const x = Math.min(...xs);
908
+ const y = Math.min(...ys);
909
+ const w = Math.max(...xs) - x;
910
+ const h = Math.max(...ys) - y;
911
+ if (!(w > 0 && h > 0)) return null;
912
+ // Server clip rectangles are integers, so a fractional edge could only
913
+ // be honoured by rounding — which would differ from the mask path's
914
+ // antialiased edge by up to a pixel. Rare, so leave those to the mask
915
+ // rather than quietly changing what they look like.
916
+ const integral = (v) => Math.abs(v - Math.round(v)) < 1e-6;
917
+ if (![x, y, w, h].every(integral)) return null;
918
+ return { x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) };
919
+ }
920
+
921
+ /** Intersection of the whole clip stack, or null if any part is not a
922
+ * rectangle (or the stack is empty). */
923
+ _clipRect() {
924
+ if (!this._clips.length) return null;
925
+ let out = null;
926
+ for (const entry of this._clips) {
927
+ const r = entry.rect;
928
+ if (!r) return null;
929
+ if (!out) {
930
+ out = { ...r };
931
+ continue;
932
+ }
933
+ const x = Math.max(out.x, r.x);
934
+ const y = Math.max(out.y, r.y);
935
+ const right = Math.min(out.x + out.w, r.x + r.w);
936
+ const bottom = Math.min(out.y + out.h, r.y + r.h);
937
+ out = { x, y, w: Math.max(0, right - x), h: Math.max(0, bottom - y) };
938
+ }
939
+ // clamp into the surface: the server rejects out-of-range rectangles
940
+ const x = Math.max(0, Math.min(out.x, this.width));
941
+ const y = Math.max(0, Math.min(out.y, this.height));
942
+ const w = Math.max(0, Math.min(out.x + out.w, this.width) - x);
943
+ const h = Math.max(0, Math.min(out.y + out.h, this.height) - y);
944
+ return { x, y, w, h };
945
+ }
946
+
844
947
  clip(...args) {
845
948
  const { polys, rule } = this._polysFor(args);
846
- const entry = { polys, rule };
949
+ const entry = { polys, rule, rect: RenderingContext2d._rectOfPolys(polys) };
847
950
  // copy-on-write: earlier save() snapshots keep their own clip list
848
951
  this._clips = this._clips.concat([entry]);
849
952
 
@@ -328,29 +328,33 @@ export class TextLayout {
328
328
  draw(ctx, x = 0, y = 0) {
329
329
  const app = ctx.window.app;
330
330
  const Render = app.display.Render;
331
+ // One batch for the whole layout, not one per line: every positioned
332
+ // run carries its own absolute baseline, so a uniformly coloured
333
+ // paragraph becomes a single glyph composite instead of one per line.
334
+ // Only a colour change forces a flush.
335
+ let batch = [];
336
+ let batchColor;
337
+ const flush = () => {
338
+ if (batch.length === 0) return;
339
+ const src = batchColor ? ctx._stylePicture(batchColor) : ctx._backgroundPicture;
340
+ // via the context so the clip is applied (drawGlyphRuns composites
341
+ // straight onto the picture and cannot see it)
342
+ if (typeof ctx.drawGlyphs === 'function') {
343
+ ctx.drawGlyphs(Render.PictOp.Over, src, batch);
344
+ } else {
345
+ drawGlyphRuns(app, Render.PictOp.Over, src.id, ctx.picture.id, batch);
346
+ }
347
+ batch = [];
348
+ };
331
349
  for (const line of this.lines) {
332
- let batch = [];
333
- let batchColor;
334
- const flush = () => {
335
- if (batch.length === 0) return;
336
- const src = batchColor ? ctx._stylePicture(batchColor) : ctx._backgroundPicture;
337
- // via the context so the clip mask is applied (drawGlyphRuns
338
- // composites straight onto the picture and cannot see it)
339
- if (typeof ctx.drawGlyphs === 'function') {
340
- ctx.drawGlyphs(Render.PictOp.Over, src, batch);
341
- } else {
342
- drawGlyphRuns(app, Render.PictOp.Over, src.id, ctx.picture.id, batch);
343
- }
344
- batch = [];
345
- };
346
350
  for (const r of line.runs) {
347
351
  const color = r.span.color;
348
352
  if (batch.length && color !== batchColor) flush();
349
353
  batchColor = color;
350
354
  batch.push({ run: r.run, x: x + line.x + r.x, y: y + line.baseline });
351
355
  }
352
- flush();
353
356
  }
357
+ flush();
354
358
  ctx._markDirty();
355
359
  return this;
356
360
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "3.5.1",
3
+ "version": "3.5.3",
4
4
  "description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
5
5
  "author": "Andrey Sidorov <sidorares@yandex.ru>",
6
6
  "license": "MIT",