ntk 8.1.0 → 8.2.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.
@@ -52,6 +52,8 @@ import {
52
52
  compositeTraps,
53
53
  drawGlyphRuns,
54
54
  encodeGlyphItems,
55
+ positionedRunsInk,
56
+ runId,
55
57
  } from "./text/glyphs.js";
56
58
  import { TextLayout } from "./text/layout.js";
57
59
  import { reorderRuns } from "./text/shape.js";
@@ -1099,6 +1101,110 @@ class RenderingContext2d {
1099
1101
  );
1100
1102
  }
1101
1103
 
1104
+ /**
1105
+ * The shadow of positioned glyph runs, cached — what `drawGlyphs`, and
1106
+ * therefore every `TextLayout.draw`, casts (issue #283).
1107
+ *
1108
+ * A paragraph gets **one** coverage surface, not one per line: the runs
1109
+ * already carry their own baselines, so they all go into the same surface
1110
+ * exactly as they all go into the same glyph composite. Nothing is
1111
+ * re-shaped — the caller handed us the runs, which is why this path is
1112
+ * cheaper than `_shadowOfText`, not dearer.
1113
+ *
1114
+ * The cached copy is position-independent, as `fillText`'s is: geometry is
1115
+ * stored relative to the first run's origin and the composite carries it
1116
+ * to wherever the text is drawn. The key is that relative geometry plus
1117
+ * the identity of each run, so the same string laid out to two widths —
1118
+ * same runs, different line origins — is two shadows, and re-drawing one
1119
+ * layout is one lookup no matter how many glyphs are in it.
1120
+ *
1121
+ * @param {Array<{run, x, y, textRendering?}>} positioned device-space runs
1122
+ */
1123
+ _shadowOfGlyphs(positioned) {
1124
+ if (!positioned.length) return;
1125
+ const app = this.window.app;
1126
+ const policy = shadowPolicyOf(app);
1127
+ const sigma = shadowSigma(this._shadowBlur, policy);
1128
+ const reach = shadowReach(sigma);
1129
+
1130
+ // Run origins relative to the first, rounded: whole-pixel offsets are
1131
+ // what the bitmap glyph path draws at anyway, and they keep the key
1132
+ // stable as the paragraph moves — `(x + a) - (x + b)` is not exactly
1133
+ // `a - b` in floating point, and an origin-dependent key would miss the
1134
+ // cache on every scroll.
1135
+ const ax = positioned[0].x;
1136
+ const ay = positioned[0].y;
1137
+ const local = positioned.map((p) => ({
1138
+ run: p.run,
1139
+ x: Math.round(p.x - ax),
1140
+ y: Math.round(p.y - ay),
1141
+ textRendering: p.textRendering,
1142
+ }));
1143
+ const key = `${local
1144
+ .map((p) => `${runId(p.run)},${p.x},${p.y},${p.textRendering ?? ""}`)
1145
+ .join("\u0000")}\u0000${sigma}`;
1146
+
1147
+ let ink = null;
1148
+ const surface = cachedShadow(app, key, () => {
1149
+ ink = positionedRunsInk(local);
1150
+ if (!ink) return null; // a line of spaces inks nothing
1151
+ const box = {
1152
+ x: Math.floor(ink.minX) - 1 - reach,
1153
+ y: Math.floor(ink.minY) - 1 - reach,
1154
+ };
1155
+ box.w = Math.ceil(ink.maxX) + 1 + reach - box.x;
1156
+ box.h = Math.ceil(ink.maxY) + 1 + reach - box.y;
1157
+ if (box.w * box.h > policy.maxPixels) return null;
1158
+ let coverage = new Surface(app, {
1159
+ width: box.w,
1160
+ height: box.h,
1161
+ format: "a8",
1162
+ });
1163
+ coverage.render((sctx) => {
1164
+ this._loadShadowState(sctx, 0, 0);
1165
+ // the origins are already device-space and placed by hand
1166
+ sctx._m = [1, 0, 0, 1, 0, 0];
1167
+ sctx._drawGlyphsDevice(
1168
+ this.Render.PictOp.Over,
1169
+ sctx._backgroundPicture,
1170
+ local.map((p) => ({ ...p, x: p.x - box.x, y: p.y - box.y })),
1171
+ );
1172
+ });
1173
+ if (sigma > 0) coverage = blurCoverage(coverage, sigma);
1174
+ // where the anchor sits inside the surface — whole pixels, so the
1175
+ // composite below can carry it anywhere
1176
+ coverage._shadowOrigin = { x: -box.x, y: -box.y };
1177
+ return coverage;
1178
+ });
1179
+ if (surface) {
1180
+ const origin = surface._shadowOrigin;
1181
+ this._paintShadow(
1182
+ surface,
1183
+ Math.round(ax + this._shadowOffsetX) - origin.x,
1184
+ Math.round(ay + this._shadowOffsetY) - origin.y,
1185
+ );
1186
+ return;
1187
+ }
1188
+ if (!ink) return;
1189
+ // Padded ink larger than a shadow surface may be: fall back to the
1190
+ // clipped, uncached path, which sizes itself to the part of the shadow
1191
+ // that can actually be seen — the same escape `_shadowOfText` takes.
1192
+ this._shadowOfDrawing(
1193
+ {
1194
+ minX: ax + ink.minX,
1195
+ maxX: ax + ink.maxX,
1196
+ minY: ay + ink.minY,
1197
+ maxY: ay + ink.maxY,
1198
+ },
1199
+ (sctx, dx, dy) =>
1200
+ sctx._drawGlyphsDevice(
1201
+ this.Render.PictOp.Over,
1202
+ sctx._backgroundPicture,
1203
+ positioned.map((p) => ({ ...p, x: p.x + dx, y: p.y + dy })),
1204
+ ),
1205
+ );
1206
+ }
1207
+
1102
1208
  /** the shadow of a `fill()`/`stroke()`-shaped call, from its arguments */
1103
1209
  _shadowOfPath(args, stroke) {
1104
1210
  if (stroke) {
@@ -2131,7 +2237,9 @@ class RenderingContext2d {
2131
2237
  * @param {Picture} src source picture the glyphs paint with — a solid
2132
2238
  * (`ctx.createSolidPicture(r, g, b, a)`, premultiplied 0..1) or a gradient
2133
2239
  * @param {Array<{run, x, y, textRendering?}>} positioned runs in visual
2134
- * order; `x`/`y` is the run's baseline origin in device space. `run` is
2240
+ * order; `x`/`y` is the run's baseline origin in **user space** — the
2241
+ * current transform applies to it, as it does to every other drawing
2242
+ * call. `run` is
2135
2243
  * `{ font, size, glyphs }` — a `Font`, a pixel size, and glyphs
2136
2244
  * `{ id, ax, dx, dy }` in drawing order: `id` a font glyph id
2137
2245
  * (`Font.shape()`'s `glyphs[].id`, or `Font.glyphIdFor(cp)`), `ax` the
@@ -2142,6 +2250,39 @@ class RenderingContext2d {
2142
2250
  * (`codePoints`, `width`, …) are ignored. `textRendering` optionally
2143
2251
  * overrides the bitmap/vector routing per run (docs/text.md).
2144
2252
  *
2253
+ * The transform moves each run's origin, exactly as `fillText` moves its
2254
+ * anchor; the glyphs themselves are not rotated or scaled by it (size the
2255
+ * font via `ctx.font`, or `run.size`, instead). Advances and `dx`/`dy` are
2256
+ * therefore device pixels on both calls. Without this, a `TextLayout`
2257
+ * drawn into a translated context — which is every react-x11 canvas that
2258
+ * is not at the window's origin — landed at the untransformed coordinates
2259
+ * and was then cut by the clip, while the neighbouring `fillRect` and
2260
+ * `drawImage` moved (issue #280).
2261
+ *
2262
+ * The shadow state applies too, as it does to `fillText`: one blurred
2263
+ * coverage surface for the whole call, cached on the runs' identity and
2264
+ * relative positions, painted under the glyphs (issue #283). A paragraph
2265
+ * whose spans change colour draws as several calls, and — as several
2266
+ * `fillText`s would — casts a shadow per call.
2267
+ */
2268
+ drawGlyphs(op, src, positioned) {
2269
+ const m = this._m;
2270
+ if (!matIsIdentity(m)) {
2271
+ positioned = positioned.map((p) => {
2272
+ const [x, y] = matApply(m, p.x, p.y);
2273
+ return { ...p, x, y };
2274
+ });
2275
+ }
2276
+ if (this._shadowed()) this._shadowOfGlyphs(positioned);
2277
+ this._drawGlyphsDevice(op, src, positioned);
2278
+ }
2279
+
2280
+ /**
2281
+ * `drawGlyphs` with the origins already in device space — the primitive
2282
+ * under it, for callers that place glyphs themselves (`fillText`, which
2283
+ * has to add the alignment and baseline offsets *after* the transform,
2284
+ * because glyph advances are device pixels).
2285
+ *
2145
2286
  * CompositeGlyphs writes straight to the destination picture, so it has
2146
2287
  * no way to consult our clip mask — text drawn through it used to spill
2147
2288
  * out of clipped boxes while every fill and stroke stayed inside. With a
@@ -2150,7 +2291,7 @@ class RenderingContext2d {
2150
2291
  * through the result — the same shape as _fillPolys. A rectangular clip
2151
2292
  * takes the server-side fast path below instead of the mask.
2152
2293
  */
2153
- drawGlyphs(op, src, positioned) {
2294
+ _drawGlyphsDevice(op, src, positioned) {
2154
2295
  const app = this.window.app;
2155
2296
  const R = this.Render;
2156
2297
  if (!prepareStyle(src, this._m)) return;
@@ -3314,7 +3455,9 @@ class RenderingContext2d {
3314
3455
  });
3315
3456
  cursor += run.width;
3316
3457
  }
3317
- this.drawGlyphs(
3458
+ // already device space: the anchor went through the matrix above, and
3459
+ // the offsets and advances added to it are device pixels
3460
+ this._drawGlyphsDevice(
3318
3461
  this.Render.PictOp.Over,
3319
3462
  this._backgroundPicture,
3320
3463
  positioned,
@@ -225,6 +225,67 @@ export function positionGlyphs(positioned) {
225
225
  return out;
226
226
  }
227
227
 
228
+ /**
229
+ * Ink extents of positioned runs, in whatever coordinates their origins are
230
+ * given in — the union of every glyph's bounding box, laid out exactly as
231
+ * `positionGlyphs` lays it out (pen at `x`, glyph at `pen + dx`, `y - dy`,
232
+ * pen advanced by `ax`).
233
+ *
234
+ * Returns `null` when nothing inks: a run of spaces has extents but no
235
+ * bounding box, and neither does an empty array. Blank glyphs report an
236
+ * empty `cbox` (`minX` infinite), which the comparisons below drop on their
237
+ * own.
238
+ *
239
+ * This is what sizes a glyph shadow's coverage surface — the run-shaped
240
+ * counterpart of the context's `_shapedInk`, which measures one shaped
241
+ * string from its own origin.
242
+ *
243
+ * @param {Array<{run, x, y}>} positioned
244
+ * @returns {{minX: number, minY: number, maxX: number, maxY: number}|null}
245
+ */
246
+ export function positionedRunsInk(positioned) {
247
+ let minX = Infinity;
248
+ let minY = Infinity;
249
+ let maxX = -Infinity;
250
+ let maxY = -Infinity;
251
+ for (const { run, x, y } of positioned) {
252
+ let cursor = x;
253
+ for (const g of run.glyphs) {
254
+ const e = run.font.glyphExtents(g.id, run.size);
255
+ const gx = cursor + g.dx;
256
+ const gy = y - g.dy;
257
+ cursor += g.ax;
258
+ if (gx + e.minX < minX) minX = gx + e.minX;
259
+ if (gx + e.maxX > maxX) maxX = gx + e.maxX;
260
+ if (gy + e.minY < minY) minY = gy + e.minY;
261
+ if (gy + e.maxY > maxY) maxY = gy + e.maxY;
262
+ }
263
+ }
264
+ return minX <= maxX && minY <= maxY ? { minX, minY, maxX, maxY } : null;
265
+ }
266
+
267
+ // Identity, not content, for anything that wants to name a run cheaply.
268
+ // A shaped run is immutable and shared — the shaping memo hands the same
269
+ // object back, and a TextLayout holds on to the ones its lines are made of
270
+ // — so a small integer per object is a complete name for the glyphs in it,
271
+ // bought at O(1) instead of O(glyphs). Weak, so naming a run keeps nothing
272
+ // alive.
273
+ const runIds = new WeakMap();
274
+ let nextRunId = 0;
275
+
276
+ /**
277
+ * A stable small integer for one run object, for cache keys that would
278
+ * otherwise have to serialize its glyphs. Runs built fresh on every draw
279
+ * (rather than kept, as a `TextLayout` keeps them) get a fresh id each time
280
+ * and so never hit such a cache — which is the honest answer, since nothing
281
+ * cheap can tell them apart.
282
+ */
283
+ export function runId(run) {
284
+ let id = runIds.get(run);
285
+ if (id === undefined) runIds.set(run, (id = ++nextRunId));
286
+ return id;
287
+ }
288
+
228
289
  /**
229
290
  * Decide how a (face, size) renders: cached bitmap glyphs or per-draw
230
291
  * trapezoids. See DEFAULT_TEXT_POLICY for the reasoning; the middle band
@@ -468,9 +468,21 @@ export class TextLayout {
468
468
  }
469
469
 
470
470
  /**
471
- * Draw onto a 2d context at (x, y) = top-left of the layout box.
471
+ * Draw onto a 2d context at (x, y) = top-left of the layout box, in the
472
+ * context's **user space**: the current transform applies to the origin,
473
+ * the same way it applies to `fillText`, `fillRect` and `drawImage`, so a
474
+ * paragraph drawn into a translated context lands where the rest of the
475
+ * drawing does (issue #280). The glyphs themselves are not rotated or
476
+ * scaled by it — set the span size instead.
477
+ *
472
478
  * Span `color`s override the context fillStyle; consecutive same-color
473
479
  * runs are batched into single CompositeGlyphs requests.
480
+ *
481
+ * `caretPosition`/`indexAt` and the line/run geometry speak the same
482
+ * layout-relative coordinates as the (x, y) here, so hit testing stays
483
+ * `layout.indexAt(px - x, py - y)` — with (px, py) in user space too,
484
+ * which for a pointer event under a transformed context means undoing
485
+ * `ctx.getTransform()` first.
474
486
  */
475
487
  draw(ctx, x = 0, y = 0) {
476
488
  const app = ctx.window.app;
@@ -484,8 +496,9 @@ export class TextLayout {
484
496
  const flush = () => {
485
497
  if (batch.length === 0) return;
486
498
  const src = batchColor ? ctx._stylePicture(batchColor) : ctx._backgroundPicture;
487
- // via the context so the clip is applied (drawGlyphRuns composites
488
- // straight onto the picture and cannot see it)
499
+ // via the context so the clip and the transform are applied
500
+ // (drawGlyphRuns composites straight onto the picture and can see
501
+ // neither)
489
502
  if (typeof ctx.drawGlyphs === 'function') {
490
503
  ctx.drawGlyphs(Render.PictOp.Over, src, batch);
491
504
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.1.0",
3
+ "version": "8.2.0",
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",