ntk 6.6.1 → 7.0.0

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/lib/app.js CHANGED
@@ -8,6 +8,41 @@ import { ShmUploader } from './shm-upload.js';
8
8
  import FontManager from './text/fontmanager.js';
9
9
  import Window from './window.js';
10
10
 
11
+ /**
12
+ * Frame interval used before the display has been asked, in ms — and the one
13
+ * kept on a server that cannot answer.
14
+ */
15
+ const DEFAULT_FRAME_INTERVAL = 16;
16
+
17
+ /**
18
+ * The range a refresh rate has to fall in to be paced against, in Hz. Below
19
+ * this a display is not something a UI should be throttled to, and above it
20
+ * the numbers are not describing a display at all.
21
+ */
22
+ const MIN_REFRESH_RATE = 20;
23
+ const MAX_REFRESH_RATE = 1000;
24
+
25
+ /**
26
+ * A RandR mode's vertical refresh rate in Hz, or 0 when it does not describe
27
+ * one.
28
+ *
29
+ * `dot_clock / (h_total * v_total)`, with the two flags that make a frame
30
+ * something other than one pass down the mode, exactly as xrandr computes it:
31
+ * an interlaced mode paints half the lines per pass, a doublescan mode paints
32
+ * each line twice.
33
+ *
34
+ * The zero check is not defensive: a virtual output has no pixel clock to
35
+ * report and Xvfb — which is what CI runs against — fills all three fields
36
+ * with zeroes, so the arithmetic yields NaN rather than a rate.
37
+ */
38
+ function modeRate(mode) {
39
+ if (!mode || !mode.dot_clock || !mode.h_total || !mode.v_total) return 0;
40
+ let vTotal = mode.v_total;
41
+ if (mode.modeflags & 0x10) vTotal /= 2; // Interlace
42
+ if (mode.modeflags & 0x20) vTotal *= 2; // DoubleScan
43
+ return mode.dot_clock / (mode.h_total * vTotal);
44
+ }
45
+
11
46
  /**
12
47
  * A connection to an X server. Owns the underlying node-x11 client
13
48
  * (`app.X`) and acts as a factory for windows and pixmaps.
@@ -59,6 +94,79 @@ export default class App {
59
94
  });
60
95
  }
61
96
 
97
+ /**
98
+ * The fastest refresh rate any active output is running at, in Hz, or
99
+ * `null` until the display has been asked — and on a server with no RandR
100
+ * or no mode worth pacing to.
101
+ *
102
+ * The fastest rather than the one this or that window is on, because what
103
+ * it feeds is a rate *ceiling* (see `frameInterval`), and a window paced
104
+ * faster than its own monitor is bounded by the next gate along rather than
105
+ * drawing more than anyone can see. Tracking which output every window
106
+ * overlaps would cost a per-window RandR lookup plus change tracking, to
107
+ * make a ceiling slightly tighter.
108
+ */
109
+ get refreshRate() {
110
+ if (this._refreshProbe === undefined) this._probeRefreshRate();
111
+ return this._refreshRate ?? null;
112
+ }
113
+
114
+ /**
115
+ * The frame interval windows on this connection take by default, in ms:
116
+ * the display's own period rather than a guess, so an animation loop on a
117
+ * 165Hz output is not held to the 62.5fps a hardcoded 16 implies. `null`
118
+ * until the probe has answered; windows created before then start on the
119
+ * default and adopt this when it lands.
120
+ *
121
+ * A window that presents takes its rate from the display directly and needs
122
+ * none of this — see docs/window.md "What ends a frame". This is what the
123
+ * fence clock paces to, which is every window on a server without Present,
124
+ * and every window before its first frames have been shown.
125
+ */
126
+ get frameInterval() {
127
+ const rate = this.refreshRate;
128
+ return rate ? 1000 / rate : null;
129
+ }
130
+
131
+ /**
132
+ * Ask RandR what the outputs are running at, once per connection, in the
133
+ * background.
134
+ *
135
+ * Not on the connect path: `createClient` is already four round trips deep
136
+ * and this is three more, for something no window needs before its first
137
+ * frame. Failures are silent by design — every one of them means "no rate
138
+ * to be had", which is exactly what the default covers.
139
+ */
140
+ _probeRefreshRate() {
141
+ this._refreshProbe = null; // in progress; only ever started once
142
+ const X = this.X;
143
+ X.require('randr', (err, R) => {
144
+ if (err || !R) return;
145
+ const root = this.display.screen[0].root;
146
+ R.GetScreenResourcesCurrent(root, (resourcesError, resources) => {
147
+ if (resourcesError || !resources?.crtcs?.length) return;
148
+ const modes = new Map(resources.modeinfos.map((mode) => [mode.id, mode]));
149
+ let pending = resources.crtcs.length;
150
+ let best = 0;
151
+ for (const crtc of resources.crtcs) {
152
+ R.GetCrtcInfo(crtc, resources.config_timestamp, (crtcError, info) => {
153
+ // a crtc with no mode is one that is switched off
154
+ if (!crtcError && info) best = Math.max(best, modeRate(modes.get(info.mode)));
155
+ if (--pending) return;
156
+ if (best < MIN_REFRESH_RATE || best > MAX_REFRESH_RATE) return;
157
+ this._refreshRate = best;
158
+ this._adoptFrameInterval(1000 / best);
159
+ });
160
+ }
161
+ });
162
+ });
163
+ }
164
+
165
+ /** hand the measured default to the windows that are still on the guess */
166
+ _adoptFrameInterval(ms) {
167
+ for (const wnd of Window._cacheFor(this).values()) wnd._adoptDefaultFrameInterval(ms);
168
+ }
169
+
62
170
  /** the text API entry point: font matching/loading, shaping, layout */
63
171
  get fonts() {
64
172
  if (!this._fonts) this._fonts = new FontManager({ source: this.options.fontSource });
package/lib/index.js CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  defaultRasterizer,
37
37
  setDefaultRasterizer
38
38
  } from './rasterize.js';
39
+ import { DEFAULT_SHAPE_POLICY } from './shapeglyphs.js';
39
40
  import { TextLayout } from './text/layout.js';
40
41
  import HtmlView from './widgets/htmlview.js';
41
42
  import SvgView from './widgets/svgview.js';
@@ -214,6 +215,7 @@ export {
214
215
  defaultRasterizer,
215
216
  setDefaultRasterizer,
216
217
  DEFAULT_RASTER_POLICY,
218
+ DEFAULT_SHAPE_POLICY,
217
219
  TextLayout,
218
220
  HtmlView,
219
221
  SvgView,
package/lib/path.js CHANGED
@@ -45,6 +45,100 @@ export function matIsIdentity(m) {
45
45
  return m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1 && m[4] === 0 && m[5] === 0;
46
46
  }
47
47
 
48
+ // --------------------------------------------------------------------------
49
+ // arc tags
50
+ //
51
+ // A cubic emitted by ellipseCubics carries the arc it approximates, so the
52
+ // flattener can subdivide the arc itself instead of rediscovering its shape
53
+ // by bisecting the cubic (issue #213). The tag is the centre-plus-axis-
54
+ // vectors form:
55
+ //
56
+ // P(t) = (cx, cy) + u·cos t + v·sin t, t from t0 to t1
57
+ //
58
+ // u and v are the semi-axis *vectors*, which is what makes the tag survive
59
+ // transforms: an affine map takes this form to the same form — the centre
60
+ // moves, u and v go through the linear part — where radii plus a rotation
61
+ // angle would only survive a similarity, and a shear or a non-uniform scale
62
+ // would have to drop the tag and fall back.
63
+ //
64
+ // The tag also carries its own start point (sx, sy), which the lowering
65
+ // computes anyway. Flattening a cubic starts from the path's current point,
66
+ // so the arc route is only valid when that point is where the arc begins —
67
+ // true of every way a tagged cubic can be built here, and checked rather
68
+ // than assumed, since a path that broke the invariant would otherwise be
69
+ // drawn quietly wrong instead of falling back to bisection.
70
+ //
71
+ // Tags are treated as immutable: transformArc always builds a new one, so
72
+ // Path2D copies and addPath can share them by reference.
73
+
74
+ /** the flatness tolerance, in output pixels, everything here defaults to */
75
+ export const FLATTEN_TOLERANCE = 0.25;
76
+
77
+ // how many chords an arc may be split into, whatever the arithmetic says.
78
+ // Only reachable through a degenerate tolerance (0 or negative); a real
79
+ // drawing at tol 0.25 asks for ~800 chords for a full circle the size of
80
+ // the largest addressable surface.
81
+ const MAX_ARC_SEGMENTS = 4096;
82
+
83
+ /**
84
+ * The fewest equal chords that approximate an arc within `tol`.
85
+ *
86
+ * A chord spanning angle θ of a circle of radius R misses the arc by the
87
+ * sagitta R·(1 - cos(θ/2)) at its midpoint, so the largest angle a chord may
88
+ * span is 2·acos(1 - tol/R) and the count follows by division. Recursive
89
+ * bisection can only land on powers of two and so overshoots this by up to
90
+ * 2x — an arc needing 9 chords used to get 16.
91
+ *
92
+ * @param {number} sweep total angle covered, radians (unsigned)
93
+ * @param {number} radius the arc's radius — for an ellipse, the largest
94
+ * singular value of its axis matrix (see arcScale), which bounds the
95
+ * sagitta everywhere on the curve
96
+ * @param {number} tol allowed deviation, in the same units as radius
97
+ */
98
+ export function arcSegmentCount(sweep, radius, tol = FLATTEN_TOLERANCE) {
99
+ if (!(sweep > 0) || !(radius > 0)) return 1;
100
+ // tol >= 2R: the whole circle is within tolerance of a single chord
101
+ const cos = 1 - tol / radius;
102
+ if (!(cos > -1)) return 1;
103
+ const theta = 2 * Math.acos(Math.min(1, cos));
104
+ if (!(theta > 0)) return MAX_ARC_SEGMENTS; // tol <= 0: as fine as allowed
105
+ return Math.max(1, Math.min(MAX_ARC_SEGMENTS, Math.ceil(sweep / theta)));
106
+ }
107
+
108
+ /**
109
+ * The radius to measure an arc tag's sagitta against: the largest singular
110
+ * value of `[u | v]`, i.e. how far the map from the unit circle can stretch
111
+ * a distance. For a circle that is exactly its radius; for an ellipse it is
112
+ * the semi-major axis, which bounds the deviation everywhere on the curve
113
+ * (|A·d| <= σmax·|d|) and so is safe, if conservative on eccentric ones.
114
+ *
115
+ * σ1² + σ2² = ‖A‖_F² and σ1·σ2 = |det A| give it in closed form.
116
+ */
117
+ function arcScale({ ux, uy, vx, vy }) {
118
+ const frob = ux * ux + uy * uy + vx * vx + vy * vy;
119
+ const det = ux * vy - uy * vx;
120
+ const disc = Math.sqrt(Math.max(0, frob * frob - 4 * det * det));
121
+ return Math.sqrt((frob + disc) / 2);
122
+ }
123
+
124
+ /** an arc tag through an affine map — the centre and start point move, the
125
+ * axes go through the linear part; exact for every affine, shear and
126
+ * reflection included */
127
+ function transformArc(a, m) {
128
+ return {
129
+ cx: m[0] * a.cx + m[2] * a.cy + m[4],
130
+ cy: m[1] * a.cx + m[3] * a.cy + m[5],
131
+ ux: m[0] * a.ux + m[2] * a.uy,
132
+ uy: m[1] * a.ux + m[3] * a.uy,
133
+ vx: m[0] * a.vx + m[2] * a.vy,
134
+ vy: m[1] * a.vx + m[3] * a.vy,
135
+ sx: m[0] * a.sx + m[2] * a.sy + m[4],
136
+ sy: m[1] * a.sx + m[3] * a.sy + m[5],
137
+ t0: a.t0,
138
+ t1: a.t1
139
+ };
140
+ }
141
+
48
142
  // --------------------------------------------------------------------------
49
143
  // elliptical arcs -> cubics
50
144
 
@@ -53,6 +147,9 @@ export function matIsIdentity(m) {
53
147
  * radii (rx, ry), rotated by phi, from angle a0 sweeping by da (signed,
54
148
  * |da| <= 2π). Returns { start: {x, y}, cmds: [{type:'C', ...}, ...] };
55
149
  * segments are split to <= 90° so the approximation error stays tiny.
150
+ *
151
+ * Each command carries an `arc` tag (see above) describing the piece of the
152
+ * true arc it stands for, which is what `flattenPath` subdivides.
56
153
  */
57
154
  export function ellipseCubics(cx, cy, rx, ry, phi, a0, da) {
58
155
  const cosPhi = Math.cos(phi);
@@ -68,6 +165,12 @@ export function ellipseCubics(cx, cy, rx, ry, phi, a0, da) {
68
165
  return [cosPhi * x - sinPhi * y, sinPhi * x + cosPhi * y];
69
166
  };
70
167
 
168
+ // the ellipse's semi-axis vectors, the form the arc tag keeps
169
+ const ux = rx * cosPhi;
170
+ const uy = rx * sinPhi;
171
+ const vx = -ry * sinPhi;
172
+ const vy = ry * cosPhi;
173
+
71
174
  const [sx, sy] = point(a0);
72
175
  const cmds = [];
73
176
  const n = Math.max(1, Math.ceil(Math.abs(da) / (Math.PI / 2)));
@@ -87,7 +190,8 @@ export function ellipseCubics(cx, cy, rx, ry, phi, a0, da) {
87
190
  x2: x3 - k * dx3,
88
191
  y2: y3 - k * dy3,
89
192
  x: x3,
90
- y: y3
193
+ y: y3,
194
+ arc: { cx, cy, ux, uy, vx, vy, sx: x0, sy: y0, t0: a, t1: b }
91
195
  });
92
196
  a = b;
93
197
  }
@@ -416,12 +520,22 @@ export class Path2D {
416
520
  this._y = null;
417
521
  this._sx = null; // subpath start
418
522
  this._sy = null;
523
+ // When the whole path is exactly one roundRect(), this records what
524
+ // roundRect knew — { x, y, w, h, radii: [tl, tr, br, bl] } with the
525
+ // radii already normalized — so the 2d context can recognize the shape
526
+ // and route it to corner glyphs + FillRectangles instead of polygon
527
+ // rasterization (issue #211). Any other path verb clears it. The 2d
528
+ // context parks a bail-out reason in _roundRectMiss when it records a
529
+ // box the fast path can never take (e.g. under rotation).
530
+ this._roundRect = null;
531
+ this._roundRectMiss = null;
419
532
  if (init instanceof Path2D) {
420
533
  this._cmds = init._cmds.map((c) => ({ ...c }));
421
534
  this._x = init._x;
422
535
  this._y = init._y;
423
536
  this._sx = init._sx;
424
537
  this._sy = init._sy;
538
+ this._roundRect = init._roundRect;
425
539
  } else if (typeof init === 'string') {
426
540
  this._append(parseSvgPath(init));
427
541
  }
@@ -443,6 +557,10 @@ export class Path2D {
443
557
  }
444
558
 
445
559
  _append(cmds) {
560
+ // every mutation funnels through here; a path that is no longer exactly
561
+ // one roundRect() loses the tag (roundRect itself re-sets it at the end)
562
+ this._roundRect = null;
563
+ this._roundRectMiss = null;
446
564
  for (const c of cmds) {
447
565
  this._cmds.push(c);
448
566
  this._track(c);
@@ -532,7 +650,18 @@ export class Path2D {
532
650
  }
533
651
 
534
652
  roundRect(x, y, w, h, radii) {
535
- if (typeof radii === 'number' && radii === 0) return this.rect(x, y, w, h);
653
+ // The tag is set only when this call is the whole path: recorded after
654
+ // the geometry lands (the builder calls below clear it via _append) and
655
+ // only if the path held nothing before.
656
+ const wasEmpty = this._cmds.length === 0;
657
+ if (typeof radii === 'number' && radii === 0) {
658
+ this.rect(x, y, w, h);
659
+ if (wasEmpty && w >= 0 && h >= 0) {
660
+ const zero = { x: 0, y: 0 };
661
+ this._roundRect = { x, y, w, h, radii: [zero, zero, zero, zero] };
662
+ }
663
+ return;
664
+ }
536
665
  const [tl, tr, br, bl] = normalizeRadii(Math.abs(w), Math.abs(h), radii);
537
666
  if (w < 0 || h < 0) {
538
667
  // degenerate: fall back to a plain rect on flipped geometry
@@ -549,6 +678,7 @@ export class Path2D {
549
678
  if (tl.x || tl.y) this.ellipse(x + tl.x, y + tl.y, tl.x, tl.y, 0, Math.PI, Math.PI * 1.5);
550
679
  this.closePath();
551
680
  this.moveTo(x, y);
681
+ if (wasEmpty) this._roundRect = { x, y, w, h, radii: [tl, tr, br, bl] };
552
682
  }
553
683
 
554
684
  /** append another path, optionally transformed ([a,b,c,d,e,f] or {a..f}) */
@@ -578,7 +708,12 @@ export function transformCommands(cmds, m) {
578
708
  const [x1, y1] = matApply(m, c.x1, c.y1);
579
709
  const [x2, y2] = matApply(m, c.x2, c.y2);
580
710
  const [x, y] = matApply(m, c.x, c.y);
581
- out.push({ type: 'C', x1, y1, x2, y2, x, y });
711
+ const out_ = { type: 'C', x1, y1, x2, y2, x, y };
712
+ // an arc tag transforms exactly under any affine, so baking the CTM
713
+ // into the commands (which is what the context's default path does)
714
+ // keeps the arc route available downstream
715
+ if (c.arc) out_.arc = transformArc(c.arc, m);
716
+ out.push(out_);
582
717
  break;
583
718
  }
584
719
  case 'Q': {
@@ -604,7 +739,6 @@ function addCubic(pts, x0, y0, x1, y1, x2, y2, x3, y3, tol2, depth) {
604
739
  // cross products = (distance of control point from the chord) * |chord|
605
740
  const d1 = (x1 - x0) * dy - (y1 - y0) * dx;
606
741
  const d2 = (x2 - x0) * dy - (y2 - y0) * dx;
607
- const err = (Math.abs(d1) + Math.abs(d2)) ** 2;
608
742
  let flat;
609
743
  if (chord2 < 1e-9) {
610
744
  // degenerate chord: flat only if the control points are also close
@@ -612,7 +746,12 @@ function addCubic(pts, x0, y0, x1, y1, x2, y2, x3, y3, tol2, depth) {
612
746
  const c2 = (x2 - x0) ** 2 + (y2 - y0) ** 2;
613
747
  flat = Math.max(c1, c2) <= tol2;
614
748
  } else {
615
- // (dist1 + dist2)² <= tol² — absolute flatness in output pixels
749
+ // The curve's distance from its chord is bounded by (3/4)·max(dist1,
750
+ // dist2): B(t) - chord(t) = 3t(1-t)·((1-t)·D1 + t·D2), and 3t(1-t)
751
+ // peaks at 3/4. Testing dist1 + dist2 here (as this used to) is up to
752
+ // 8/3 stricter than that bound on symmetric cubics — every arc — which
753
+ // over-subdivided a quarter circle ~2x at any radius (issue #213).
754
+ const err = (0.75 * Math.max(Math.abs(d1), Math.abs(d2))) ** 2;
616
755
  flat = err <= tol2 * chord2;
617
756
  }
618
757
  if (flat || depth >= 18) {
@@ -636,14 +775,64 @@ function addCubic(pts, x0, y0, x1, y1, x2, y2, x3, y3, tol2, depth) {
636
775
  addCubic(pts, mx, my, bcx, bcy, cx, cy, x3, y3, tol2, depth + 1);
637
776
  }
638
777
 
778
+ /**
779
+ * Chords of the arc a tagged cubic stands for, appended to `pts`. Returns
780
+ * false — flatten the cubic instead — when the path's current point is not
781
+ * where the arc begins, so a caller that spliced commands together still
782
+ * gets a curve from where it actually is.
783
+ *
784
+ * The count comes from the sagitta formula rather than from bisecting the
785
+ * cubic, so an arc gets the fewest chords its tolerance allows instead of
786
+ * the next power of two (issue #213). The points are evaluated on the arc
787
+ * itself — closer to the geometry the caller asked for than the cubic
788
+ * proxy, whose own error is ~2.7e-4·R for the <=90° pieces used here.
789
+ *
790
+ * The last point is the command's own endpoint rather than a re-evaluated
791
+ * one, so consecutive segments still meet exactly, and an SVG arc whose
792
+ * final point was snapped to the requested endpoint keeps that snap.
793
+ */
794
+ function addArcChords(pts, cmd, m, tol, x0, y0) {
795
+ const arc = m ? transformArc(cmd.arc, m) : cmd.arc;
796
+ const scale = arcScale(arc);
797
+ // The two agree bit-for-bit when the lowering produced both, so this only
798
+ // fires on spliced paths — and on the ~1e-13 drift where an SVG arc's
799
+ // endpoint was snapped and the next arc starts from the snapped value.
800
+ const slack = 1e-6 * (1 + scale);
801
+ if (Math.abs(x0 - arc.sx) > slack || Math.abs(y0 - arc.sy) > slack) {
802
+ return false;
803
+ }
804
+ const sweep = arc.t1 - arc.t0;
805
+ const n = arcSegmentCount(Math.abs(sweep), scale, tol);
806
+ for (let i = 1; i < n; i++) {
807
+ const t = arc.t0 + (sweep * i) / n;
808
+ const cos = Math.cos(t);
809
+ const sin = Math.sin(t);
810
+ pts.push(
811
+ arc.cx + arc.ux * cos + arc.vx * sin,
812
+ arc.cy + arc.uy * cos + arc.vy * sin
813
+ );
814
+ }
815
+ if (m) {
816
+ const [x, y] = matApply(m, cmd.x, cmd.y);
817
+ pts.push(x, y);
818
+ } else {
819
+ pts.push(cmd.x, cmd.y);
820
+ }
821
+ return true;
822
+ }
823
+
639
824
  /**
640
825
  * Flatten normalized commands into polylines. `m` (optional affine) is
641
826
  * applied to control points before subdivision, so the flatness tolerance
642
827
  * `tol` (default 0.25) is measured in output/device pixels.
643
828
  *
829
+ * Cubics that came from an arc carry a tag and are subdivided from their own
830
+ * geometry (see addArcChords); genuine beziers — hand-built paths, SVG curve
831
+ * commands, font outlines — take the adaptive bisection route.
832
+ *
644
833
  * @returns {Array<{pts: number[], closed: boolean}>} flat [x0,y0,x1,y1,…]
645
834
  */
646
- export function flattenPath(cmds, m = null, tol = 0.25) {
835
+ export function flattenPath(cmds, m = null, tol = FLATTEN_TOLERANCE) {
647
836
  if (m && matIsIdentity(m)) m = null;
648
837
  const tol2 = tol * tol;
649
838
  const polys = [];
@@ -668,6 +857,7 @@ export function flattenPath(cmds, m = null, tol = 0.25) {
668
857
  if (!pts) break;
669
858
  const x0 = pts.pts[pts.pts.length - 2];
670
859
  const y0 = pts.pts[pts.pts.length - 1];
860
+ if (c.arc && addArcChords(pts.pts, c, m, tol, x0, y0)) break;
671
861
  const [x1, y1] = p(c.x1, c.y1);
672
862
  const [x2, y2] = p(c.x2, c.y2);
673
863
  const [x, y] = p(c.x, c.y);