ntk 3.5.0 → 3.5.2

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.
@@ -736,6 +736,47 @@ class RenderingContext2d {
736
736
  this._markDirty();
737
737
  }
738
738
 
739
+ /**
740
+ * Composite shaped glyph runs onto this context, honouring the clip.
741
+ *
742
+ * CompositeGlyphs writes straight to the destination picture, so it has
743
+ * no way to consult our clip mask — text drawn through it used to spill
744
+ * out of clipped boxes while every fill and stroke stayed inside. With a
745
+ * clip active, render the glyph coverage into the scratch a8 mask
746
+ * instead, intersect that with the clip, and paint the real source
747
+ * through the result — the same shape as _fillPolys.
748
+ */
749
+ drawGlyphs(op, src, positioned) {
750
+ const app = this.window.app;
751
+ const R = this.Render;
752
+ if (!this.clipMask) {
753
+ drawGlyphRuns(app, op, src.id, this.picture.id, positioned);
754
+ this._markDirty();
755
+ return;
756
+ }
757
+ // Fast path: a rectangular clip is something the server can do itself.
758
+ // Two small requests around the ordinary glyph composite, instead of
759
+ // clearing and compositing a full-surface a8 mask three times — which
760
+ // costs the same on the wire but many times the pixel work.
761
+ const rect = this._clipRect();
762
+ if (rect) {
763
+ if (rect.w === 0 || rect.h === 0) return;
764
+ R.SetPictureClipRectangles(this.picture.id, 0, 0, [rect.x, rect.y, rect.w, rect.h]);
765
+ drawGlyphRuns(app, op, src.id, this.picture.id, positioned);
766
+ R.SetPictureClipRectangles(this.picture.id, 0, 0, [0, 0, this.width, this.height]);
767
+ this._markDirty();
768
+ return;
769
+ }
770
+ this._ensureFillMask();
771
+ this._glyphSource ??= this.createSolidPicture(1, 1, 1, 1);
772
+ R.FillRectangles(R.PictOp.Src, this.fillMask.id, [0, 0, 0, 0], [0, 0, this.width, this.height]);
773
+ // solid white through the glyphs leaves their coverage in the a8 mask
774
+ drawGlyphRuns(app, R.PictOp.Over, this._glyphSource.id, this.fillMask.id, positioned);
775
+ R.Composite(R.PictOp.In, this.clipMask.id, 0, this.fillMask.id, 0, 0, 0, 0, 0, 0, this.width, this.height);
776
+ R.Composite(op, src.id, this.fillMask.id, this.picture.id, 0, 0, 0, 0, 0, 0, this.width, this.height);
777
+ this._markDirty();
778
+ }
779
+
739
780
  // combined clip ∩ globalAlpha mask for direct composites (rect/image
740
781
  // fast paths); returns a picture id or 0. Reuses the fill scratch mask.
741
782
  _compositeMask() {
@@ -813,9 +854,66 @@ class RenderingContext2d {
813
854
  * Cleared by restore() to the state at the matching save() — like the
814
855
  * browser canvas, there is no other way to widen the clip again.
815
856
  */
857
+ /**
858
+ * The axis-aligned rectangle a flattened path describes, or null. Clips
859
+ * in practice are almost always rectangles — a content box, a scroll
860
+ * viewport, `overflow: hidden` — and a rectangular clip can be pushed
861
+ * to the server instead of rasterized into a mask.
862
+ */
863
+ static _rectOfPolys(polys) {
864
+ if (polys.length !== 1) return null;
865
+ const { pts } = polys[0];
866
+ if (pts.length !== 8) return null;
867
+ const [x0, y0, x1, y1, x2, y2, x3, y3] = pts;
868
+ const axis =
869
+ (y0 === y1 && x1 === x2 && y2 === y3 && x3 === x0) ||
870
+ (x0 === x1 && y1 === y2 && x2 === x3 && y3 === y0);
871
+ if (!axis) return null;
872
+ const xs = [x0, x1, x2, x3];
873
+ const ys = [y0, y1, y2, y3];
874
+ const x = Math.min(...xs);
875
+ const y = Math.min(...ys);
876
+ const w = Math.max(...xs) - x;
877
+ const h = Math.max(...ys) - y;
878
+ if (!(w > 0 && h > 0)) return null;
879
+ // Server clip rectangles are integers, so a fractional edge could only
880
+ // be honoured by rounding — which would differ from the mask path's
881
+ // antialiased edge by up to a pixel. Rare, so leave those to the mask
882
+ // rather than quietly changing what they look like.
883
+ const integral = (v) => Math.abs(v - Math.round(v)) < 1e-6;
884
+ if (![x, y, w, h].every(integral)) return null;
885
+ return { x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) };
886
+ }
887
+
888
+ /** Intersection of the whole clip stack, or null if any part is not a
889
+ * rectangle (or the stack is empty). */
890
+ _clipRect() {
891
+ if (!this._clips.length) return null;
892
+ let out = null;
893
+ for (const entry of this._clips) {
894
+ const r = entry.rect;
895
+ if (!r) return null;
896
+ if (!out) {
897
+ out = { ...r };
898
+ continue;
899
+ }
900
+ const x = Math.max(out.x, r.x);
901
+ const y = Math.max(out.y, r.y);
902
+ const right = Math.min(out.x + out.w, r.x + r.w);
903
+ const bottom = Math.min(out.y + out.h, r.y + r.h);
904
+ out = { x, y, w: Math.max(0, right - x), h: Math.max(0, bottom - y) };
905
+ }
906
+ // clamp into the surface: the server rejects out-of-range rectangles
907
+ const x = Math.max(0, Math.min(out.x, this.width));
908
+ const y = Math.max(0, Math.min(out.y, this.height));
909
+ const w = Math.max(0, Math.min(out.x + out.w, this.width) - x);
910
+ const h = Math.max(0, Math.min(out.y + out.h, this.height) - y);
911
+ return { x, y, w, h };
912
+ }
913
+
816
914
  clip(...args) {
817
915
  const { polys, rule } = this._polysFor(args);
818
- const entry = { polys, rule };
916
+ const entry = { polys, rule, rect: RenderingContext2d._rectOfPolys(polys) };
819
917
  // copy-on-write: earlier save() snapshots keep their own clip list
820
918
  this._clips = this._clips.concat([entry]);
821
919
 
@@ -928,8 +1026,7 @@ class RenderingContext2d {
928
1026
  positioned.push({ run, x: cursor, y: oy });
929
1027
  cursor += run.width;
930
1028
  }
931
- drawGlyphRuns(app, this.Render.PictOp.Over, this._backgroundPicture.id, this.picture.id, positioned);
932
- this._markDirty();
1029
+ this.drawGlyphs(this.Render.PictOp.Over, this._backgroundPicture, positioned);
933
1030
  }
934
1031
 
935
1032
  /**
@@ -328,23 +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
- 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;
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 {
337
345
  drawGlyphRuns(app, Render.PictOp.Over, src.id, ctx.picture.id, batch);
338
- batch = [];
339
- };
346
+ }
347
+ batch = [];
348
+ };
349
+ for (const line of this.lines) {
340
350
  for (const r of line.runs) {
341
351
  const color = r.span.color;
342
352
  if (batch.length && color !== batchColor) flush();
343
353
  batchColor = color;
344
354
  batch.push({ run: r.run, x: x + line.x + r.x, y: y + line.baseline });
345
355
  }
346
- flush();
347
356
  }
357
+ flush();
348
358
  ctx._markDirty();
349
359
  return this;
350
360
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "3.5.0",
3
+ "version": "3.5.2",
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",