ntk 3.2.0 → 3.4.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.
@@ -30,8 +30,13 @@ function isWsGlyph(g) {
30
30
  * - `direction` — 'ltr' | 'rtl' | 'auto' base paragraph direction
31
31
  *
32
32
  * The result is inspectable before/without drawing: `width`, `height`, and
33
- * `lines[] = { x, y, baseline, width, ascent, descent, runs }` with
34
- * `runs[] = { x, width, run, span }` in visual order.
33
+ * `lines[] = { x, y, baseline, width, ascent, descent, runs, start, end }`
34
+ * with `runs[] = { x, width, run, span, start, end }` in visual order
35
+ * (`start`/`end` are logical UTF-16 ranges into the full text).
36
+ *
37
+ * `caretPosition(index)` / `indexAt(x, y)` map logical code-point indices
38
+ * to visual caret geometry and back (bidi/ligature/trailing-whitespace
39
+ * aware) — see docs/text.md.
35
40
  */
36
41
  export class TextLayout {
37
42
  constructor(fonts, content, style = {}, options = {}) {
@@ -59,6 +64,8 @@ export class TextLayout {
59
64
  });
60
65
 
61
66
  const text = spans.map((s) => s.text).join('');
67
+ this._text = text;
68
+ this._cpOffsets = null; // lazy code-point index -> code-unit offset table
62
69
  const emb = embeddingLevels(text, options.direction);
63
70
  const levels = emb.levels;
64
71
  this.baseLevel = emb.paragraphs.length ? emb.paragraphs[0].level & 1 : 0;
@@ -139,16 +146,31 @@ export class TextLayout {
139
146
  let layoutWidth = 0;
140
147
 
141
148
  for (const toks of lineTokens) {
142
- // entries carry .level so reorderRuns can order them (UAX#9 L2)
149
+ // entries carry .level so reorderRuns can order them (UAX#9 L2);
150
+ // .start/.end are absolute code-unit ranges into the full text, kept
151
+ // for caret positioning (caretPosition / indexAt)
143
152
  let entries = [];
144
153
  for (const token of toks) {
145
154
  for (const frag of token.fragments) {
146
155
  for (const run of frag.shaped.runs) {
147
- entries.push({ run, span: frag.span, level: run.level });
156
+ entries.push({
157
+ run,
158
+ span: frag.span,
159
+ level: run.level,
160
+ start: frag.start + run.start,
161
+ end: frag.start + run.end
162
+ });
148
163
  }
149
164
  }
150
165
  }
151
- stripTrailingWhitespace(entries);
166
+ const lineStart = toks[0].start;
167
+ const lineEnd = toks[toks.length - 1].end;
168
+ const trailing = stripTrailingWhitespace(entries);
169
+ const contentEnd = trailing
170
+ ? trailing.start
171
+ : entries.length
172
+ ? entries[entries.length - 1].end
173
+ : lineStart;
152
174
  entries = reorderRuns(entries);
153
175
 
154
176
  let ascent = 0;
@@ -157,7 +179,7 @@ export class TextLayout {
157
179
  const runs = [];
158
180
  let x = 0;
159
181
  for (const e of entries) {
160
- runs.push({ x, width: e.run.width, run: e.run, span: e.span });
182
+ runs.push({ x, width: e.run.width, run: e.run, span: e.span, start: e.start, end: e.end });
161
183
  x += e.run.width;
162
184
  const m = e.run.font.metrics(e.run.size);
163
185
  if (m.ascent > ascent) ascent = m.ascent;
@@ -172,7 +194,19 @@ export class TextLayout {
172
194
  natural = m.lineHeight;
173
195
  }
174
196
  if (x > layoutWidth) layoutWidth = x;
175
- this.lines.push({ x: 0, y, baseline: y + ascent, width: x, ascent, descent, runs });
197
+ this.lines.push({
198
+ x: 0,
199
+ y,
200
+ baseline: y + ascent,
201
+ width: x,
202
+ ascent,
203
+ descent,
204
+ runs,
205
+ start: lineStart,
206
+ end: lineEnd,
207
+ _contentEnd: contentEnd,
208
+ _trailing: trailing
209
+ });
176
210
  y += natural * lineHeightMul;
177
211
  }
178
212
 
@@ -204,7 +238,7 @@ export class TextLayout {
204
238
  if (fragText.length > 0) {
205
239
  const fragLevels = normalizedLevels(levels, pos, pos + fragText.length);
206
240
  const shaped = this.fonts._shapeCached(fragText, span, fragLevels);
207
- fragments.push({ text: fragText, span, shaped });
241
+ fragments.push({ text: fragText, span, shaped, start: pos });
208
242
  width += shaped.width;
209
243
  }
210
244
  pos = fragEnd;
@@ -219,7 +253,7 @@ export class TextLayout {
219
253
  wsWidth = m[0].length * spaceGlyph.advanceWidth * last.span.font.scale(last.span.size);
220
254
  }
221
255
  }
222
- return { fragments, width, wsWidth, required };
256
+ return { fragments, width, wsWidth, required, start, end };
223
257
  }
224
258
 
225
259
  // split an over-wide token at the widest cluster boundary that fits
@@ -249,7 +283,9 @@ export class TextLayout {
249
283
  hi = mid - 1;
250
284
  }
251
285
  }
252
- if (best) headFrags.push({ text: best.text, span: frag.span, shaped: best.shaped });
286
+ if (best) {
287
+ headFrags.push({ text: best.text, span: frag.span, shaped: best.shaped, start: frag.start });
288
+ }
253
289
 
254
290
  const restFrags = [];
255
291
  const restText = frag.text.slice(best ? best.len : 0);
@@ -257,26 +293,30 @@ export class TextLayout {
257
293
  restFrags.push({
258
294
  text: restText,
259
295
  span: frag.span,
260
- shaped: this.fonts._shapeCached(restText, frag.span, '0')
296
+ shaped: this.fonts._shapeCached(restText, frag.span, '0'),
297
+ start: frag.start + (best ? best.len : 0)
261
298
  });
262
299
  }
263
300
  restFrags.push(...token.fragments.slice(i + 1));
264
301
  const sum = (frags) => frags.reduce((w, f) => w + f.shaped.width, 0);
302
+ const splitAt = restFrags.length ? restFrags[0].start : token.end;
265
303
  const head = headFrags.length
266
- ? { fragments: headFrags, width: sum(headFrags), wsWidth: 0, required: false }
304
+ ? { fragments: headFrags, width: sum(headFrags), wsWidth: 0, required: false, start: token.start, end: splitAt }
267
305
  : null;
268
306
  const rest = {
269
307
  fragments: restFrags,
270
308
  width: sum(restFrags),
271
309
  wsWidth: token.wsWidth,
272
- required: token.required
310
+ required: token.required,
311
+ start: splitAt,
312
+ end: token.end
273
313
  };
274
314
  return [head, rest];
275
315
  }
276
316
  // everything fit after all (float rounding): no split needed
277
317
  return [
278
318
  { ...token, required: false },
279
- { fragments: [], width: 0, wsWidth: 0, required: token.required }
319
+ { fragments: [], width: 0, wsWidth: 0, required: token.required, start: token.end, end: token.end }
280
320
  ];
281
321
  }
282
322
 
@@ -308,11 +348,283 @@ export class TextLayout {
308
348
  ctx._markDirty();
309
349
  return this;
310
350
  }
351
+
352
+ // ---- caret positioning / hit testing ----------------------------------
353
+ //
354
+ // Both methods speak logical **code-point** indices (what you get from
355
+ // `Array.from(text)` / caret arithmetic on code points), converted
356
+ // internally to the code-unit ranges the shaped runs carry.
357
+ //
358
+ // Conventions (v1):
359
+ // - Direction boundaries: a single caret, placed at the trailing edge of
360
+ // the character logically before the index (the run containing the
361
+ // previous character wins). At a line start the leading edge of the
362
+ // run containing the index is used. Indices on both sides of a
363
+ // direction boundary may therefore map to the same visual x.
364
+ // - Ligature/cluster interior indices interpolate proportionally (by
365
+ // code-point count) across the cluster's advance.
366
+ // - An index just after a hard break belongs to the next line; the index
367
+ // of the break character itself sits at the end of its line. An index
368
+ // at a soft wrap boundary belongs to the start of the wrapped line.
369
+ // - Trailing whitespace stripped from a line end still advances the
370
+ // caret, extending past the line edge on the paragraph-direction side.
371
+
372
+ /** lazy code-point index -> code-unit offset table (n + 1 entries) */
373
+ _offsets() {
374
+ if (!this._cpOffsets) {
375
+ const offs = [];
376
+ let cu = 0;
377
+ for (const ch of this._text) {
378
+ offs.push(cu);
379
+ cu += ch.length;
380
+ }
381
+ offs.push(cu);
382
+ this._cpOffsets = offs;
383
+ }
384
+ return this._cpOffsets;
385
+ }
386
+
387
+ /** code-unit offset -> code-point index (binary search) */
388
+ _cpOf(cu) {
389
+ const offs = this._offsets();
390
+ let lo = 0;
391
+ let hi = offs.length - 1;
392
+ while (lo < hi) {
393
+ const mid = (lo + hi + 1) >> 1;
394
+ if (offs[mid] <= cu) lo = mid;
395
+ else hi = mid - 1;
396
+ }
397
+ return lo;
398
+ }
399
+
400
+ /**
401
+ * Visual caret geometry for a logical code-point index in
402
+ * `[0, codePointCount]` (out-of-range indices clamp).
403
+ *
404
+ * @returns {{ x, y, height, line }} `x` is the caret's visual x within
405
+ * the layout box (alignment included), `y` the top of the line box,
406
+ * `height` = ascent + descent, `line` the line index.
407
+ */
408
+ caretPosition(index) {
409
+ const offs = this._offsets();
410
+ const n = offs.length - 1;
411
+ const i = Math.max(0, Math.min(Math.floor(index), n));
412
+ const cu = offs[i];
413
+ let li = 0;
414
+ for (let l = this.lines.length - 1; l >= 0; l--) {
415
+ if (cu >= this.lines[l].start) {
416
+ li = l;
417
+ break;
418
+ }
419
+ }
420
+ const line = this.lines[li];
421
+ return {
422
+ x: this._caretXInLine(line, cu),
423
+ y: line.y,
424
+ height: line.ascent + line.descent,
425
+ line: li
426
+ };
427
+ }
428
+
429
+ /**
430
+ * Hit test: the logical code-point index of the caret boundary closest
431
+ * to layout-box coordinates (x, y). Picks the line by y (clamping above
432
+ * the first / below the last line), then the nearest boundary by x in
433
+ * visual order — a click past the midpoint of a cluster snaps to its far
434
+ * edge. Inverse of `caretPosition` up to bidi boundary ambiguity.
435
+ */
436
+ indexAt(x, y) {
437
+ const lines = this.lines;
438
+ if (lines.length === 0) return 0;
439
+ let li = lines.length - 1;
440
+ for (let l = 0; l < lines.length; l++) {
441
+ const bottom = l + 1 < lines.length ? lines[l + 1].y : Infinity;
442
+ if (y < bottom) {
443
+ li = l;
444
+ break;
445
+ }
446
+ }
447
+ const line = lines[li];
448
+ const lx = x - line.x;
449
+ const t = line._trailing;
450
+ const rightSide = !(this.baseLevel & 1);
451
+ if (t && (rightSide ? lx > line.width : lx < 0)) {
452
+ // in the stripped trailing-whitespace zone past the line edge
453
+ const dist = rightSide ? lx - line.width : -lx;
454
+ let pos = t.start;
455
+ let edge = 0;
456
+ for (const g of t.glyphs) {
457
+ if (dist < edge + g.ax / 2) return this._cpOf(pos);
458
+ edge += g.ax;
459
+ pos += g.len;
460
+ }
461
+ return this._cpOf(pos);
462
+ }
463
+ if (line.runs.length === 0) return this._cpOf(line.start);
464
+ const cx = Math.max(0, Math.min(lx, line.width));
465
+ let target = line.runs[line.runs.length - 1];
466
+ for (const r of line.runs) {
467
+ if (cx <= r.x + r.width) {
468
+ target = r;
469
+ break;
470
+ }
471
+ }
472
+ return this._cpOf(this._runIndexAt(target, cx));
473
+ }
474
+
475
+ /** caret x (layout-box coords) for an absolute code-unit offset on `line` */
476
+ _caretXInLine(line, cu) {
477
+ const t = line._trailing;
478
+ if (t && cu > t.start) {
479
+ // inside (or past) the stripped trailing whitespace: extend beyond
480
+ // the visual line edge on the paragraph-direction side
481
+ let adv = 0;
482
+ let pos = t.start;
483
+ for (const g of t.glyphs) {
484
+ if (cu >= pos + g.len) {
485
+ adv += g.ax;
486
+ pos += g.len;
487
+ } else {
488
+ if (cu > pos) adv += (g.ax * (cu - pos)) / g.len;
489
+ break;
490
+ }
491
+ }
492
+ return this.baseLevel & 1 ? line.x - adv : line.x + line.width + adv;
493
+ }
494
+ if (line.runs.length === 0) return line.x;
495
+ const cc = Math.min(cu, line._contentEnd);
496
+ // previous-character rule: the run containing the char before the index
497
+ let target = null;
498
+ if (cc > line.start) {
499
+ for (const r of line.runs) {
500
+ if (r.start < cc && cc <= r.end) {
501
+ target = r;
502
+ break;
503
+ }
504
+ }
505
+ }
506
+ if (!target) {
507
+ for (const r of line.runs) {
508
+ if (r.start <= cc && cc < r.end) {
509
+ target = r;
510
+ break;
511
+ }
512
+ }
513
+ }
514
+ if (!target) target = line.runs[this.baseLevel & 1 ? line.runs.length - 1 : 0];
515
+ return line.x + this._boundaryX(target, cc);
516
+ }
517
+
518
+ /** x of logical boundary `cu` within a positioned line run (run-local + run.x) */
519
+ _boundaryX(lineRun, cu) {
520
+ const { run, start, end } = lineRun;
521
+ let x = lineRun.x;
522
+ if (run.direction === 'rtl') {
523
+ // glyphs stored in visual order; leftmost glyph is logically last
524
+ let pos = end;
525
+ for (const g of run.glyphs) {
526
+ const len = cuLength(g.codePoints);
527
+ if (len === 0) {
528
+ x += g.ax;
529
+ continue;
530
+ }
531
+ if (cu >= pos) return x;
532
+ if (cu > pos - len) {
533
+ // fraction of the cluster left of the caret = code points after cu
534
+ const cps = g.codePoints;
535
+ let k = 0;
536
+ let c = pos;
537
+ for (let j = cps.length - 1; j >= 0; j--) {
538
+ const l = cps[j] > 0xffff ? 2 : 1;
539
+ if (c - l < cu) break;
540
+ c -= l;
541
+ k++;
542
+ }
543
+ return x + (g.ax * k) / cps.length;
544
+ }
545
+ pos -= len;
546
+ x += g.ax;
547
+ }
548
+ return x;
549
+ }
550
+ let pos = start;
551
+ for (const g of run.glyphs) {
552
+ const len = cuLength(g.codePoints);
553
+ if (len === 0) {
554
+ x += g.ax;
555
+ continue;
556
+ }
557
+ if (cu <= pos) return x;
558
+ if (cu < pos + len) {
559
+ const cps = g.codePoints;
560
+ let k = 0;
561
+ let c = pos;
562
+ for (const cp of cps) {
563
+ const l = cp > 0xffff ? 2 : 1;
564
+ if (c + l > cu) break;
565
+ c += l;
566
+ k++;
567
+ }
568
+ return x + (g.ax * k) / cps.length;
569
+ }
570
+ pos += len;
571
+ x += g.ax;
572
+ }
573
+ return x;
574
+ }
575
+
576
+ /** nearest caret boundary (code units) to line-local x within a line run */
577
+ _runIndexAt(lineRun, lx) {
578
+ const { run, start, end } = lineRun;
579
+ const rtl = run.direction === 'rtl';
580
+ let pos = rtl ? end : start;
581
+ let gx = lineRun.x;
582
+ for (const g of run.glyphs) {
583
+ const len = cuLength(g.codePoints);
584
+ if (len === 0 || g.ax <= 0) {
585
+ gx += g.ax;
586
+ if (g.ax <= 0) pos += rtl ? -len : len;
587
+ continue;
588
+ }
589
+ if (lx <= gx + g.ax) {
590
+ const cps = g.codePoints;
591
+ let k = Math.round(((lx - gx) / g.ax) * cps.length);
592
+ k = Math.max(0, Math.min(k, cps.length));
593
+ // move k code points into the cluster from its left edge
594
+ let c = pos;
595
+ if (rtl) {
596
+ for (let j = cps.length - 1; j >= cps.length - k; j--) {
597
+ c -= cps[j] > 0xffff ? 2 : 1;
598
+ }
599
+ } else {
600
+ for (let j = 0; j < k; j++) c += cps[j] > 0xffff ? 2 : 1;
601
+ }
602
+ return c;
603
+ }
604
+ pos += rtl ? -len : len;
605
+ gx += g.ax;
606
+ }
607
+ return pos;
608
+ }
609
+ }
610
+
611
+ // UTF-16 length of a glyph cluster's codePoints array
612
+ function cuLength(codePoints) {
613
+ let len = 0;
614
+ for (const cp of codePoints) len += cp > 0xffff ? 2 : 1;
615
+ return len;
311
616
  }
312
617
 
313
618
  // Drop trailing whitespace glyphs from the logical end of a line.
314
619
  // Shaped runs are shared via the shaping cache — clone instead of mutating.
620
+ // Returns what was stripped — `{ start, glyphs: [{ ax, len }] }` in logical
621
+ // order (absolute code-unit start, per-glyph advance and code-unit length) —
622
+ // so caret positioning can still walk through trailing spaces, or null when
623
+ // nothing was stripped. Adjusts the surviving entries' logical `end`.
315
624
  function stripTrailingWhitespace(entries) {
625
+ const stripped = [];
626
+ let strippedStart = null;
627
+ const result = () => (stripped.length ? { start: strippedStart, glyphs: stripped } : null);
316
628
  for (let i = entries.length - 1; i >= 0; i--) {
317
629
  const run = entries[i].run;
318
630
  // rtl runs store glyphs in visual order: their logical end is index 0
@@ -325,21 +637,35 @@ function stripTrailingWhitespace(entries) {
325
637
  count++;
326
638
  wsWidth += g.ax;
327
639
  }
328
- if (count === 0) return;
640
+ if (count === 0) return result();
641
+ // stripped glyphs in logical order (rtl glyph storage is reversed)
642
+ const tail = fromFront
643
+ ? run.glyphs.slice(0, count).reverse()
644
+ : run.glyphs.slice(run.glyphs.length - count);
645
+ let cuStripped = 0;
646
+ const info = tail.map((g) => {
647
+ const len = cuLength(g.codePoints);
648
+ cuStripped += len;
649
+ return { ax: g.ax, len };
650
+ });
651
+ stripped.unshift(...info);
652
+ strippedStart = entries[i].end - cuStripped;
329
653
  if (count === run.glyphs.length) {
330
654
  entries.splice(i, 1);
331
655
  continue;
332
656
  }
333
657
  entries[i] = {
334
658
  ...entries[i],
659
+ end: entries[i].end - cuStripped,
335
660
  run: {
336
661
  ...run,
337
662
  glyphs: fromFront ? run.glyphs.slice(count) : run.glyphs.slice(0, -count),
338
663
  width: run.width - wsWidth
339
664
  }
340
665
  };
341
- return;
666
+ return result();
342
667
  }
668
+ return result();
343
669
  }
344
670
 
345
671
  // compact levels key for the shaping cache: single char when uniform
@@ -65,6 +65,9 @@ const VOID_SKIP = new Set(['script', 'style', 'head', 'title', 'meta', 'link', '
65
65
  * Standalone (windowless) use mirrors MarkdownView:
66
66
  * const view = new HtmlView(null, { fonts });
67
67
  * view.setHtml(html); view.layout(width); view.draw(ctx, x, y);
68
+ * Content that arrives asynchronously (images) invalidates the layout —
69
+ * pass `onInvalidate` to re-layout/redraw when that happens (window mode
70
+ * repaints automatically).
68
71
  */
69
72
  export default class HtmlView {
70
73
  constructor(window, opts = {}) {
@@ -74,6 +77,10 @@ export default class HtmlView {
74
77
 
75
78
  /** called with (href, event, element) when a link is activated */
76
79
  this.onLink = opts.onLink ?? null;
80
+ /** called when async content (an image) invalidates the layout;
81
+ * standalone hosts re-layout/redraw here — window mode also repaints
82
+ * on its own */
83
+ this.onInvalidate = opts.onInvalidate ?? null;
77
84
  /** extra author stylesheet(s) applied after document <style>s */
78
85
  this._userSheets = [].concat(opts.stylesheet ?? []);
79
86
  /**
@@ -407,13 +414,11 @@ export default class HtmlView {
407
414
  if (res instanceof SvgView) entry.svg = res;
408
415
  else entry.image = res;
409
416
  if (!res) entry.failed = true;
410
- this._layoutWidth = -1;
411
- if (this.window && this.window._mapped) this.render();
417
+ this._invalidate();
412
418
  },
413
419
  () => {
414
420
  entry.failed = true;
415
- this._layoutWidth = -1;
416
- if (this.window && this.window._mapped) this.render();
421
+ this._invalidate();
417
422
  }
418
423
  );
419
424
  }
@@ -422,6 +427,13 @@ export default class HtmlView {
422
427
  for (const child of box.children) this._collectImages(child);
423
428
  }
424
429
 
430
+ /** async content arrived: drop the cached layout, repaint and/or notify */
431
+ _invalidate() {
432
+ this._layoutWidth = -1;
433
+ if (this.window && this.window._mapped) this.render();
434
+ if (this.onInvalidate) this.onInvalidate();
435
+ }
436
+
425
437
  // decoded bytes -> Image (PNG/JPEG) or SvgView (sniffed SVG markup)
426
438
  _decodeResource(buf) {
427
439
  const head = buf.subarray(0, 1024).toString('utf8');
@@ -51,7 +51,10 @@ const DEFAULT_THEME = {
51
51
  * wnd.map();
52
52
  *
53
53
  * Standalone use (no window attach): `view.layout(width)` then
54
- * `view.draw(ctx, x, y)`; `view.contentHeight` after layout.
54
+ * `view.draw(ctx, x, y)`; `view.contentHeight` after layout. Content that
55
+ * arrives asynchronously (mermaid models) invalidates the layout — pass
56
+ * `onInvalidate` to re-layout/redraw when that happens (window mode
57
+ * repaints automatically).
55
58
  */
56
59
  export default class MarkdownView {
57
60
  constructor(window, opts = {}) {
@@ -70,6 +73,10 @@ export default class MarkdownView {
70
73
  /** called with (href, event) when a rendered link is clicked (window
71
74
  * mode wires mousedown automatically; standalone callers use linkAt) */
72
75
  this.onLink = opts.onLink ?? null;
76
+ /** called when async content (a mermaid model) invalidates the layout;
77
+ * standalone hosts re-layout/redraw here — window mode also repaints
78
+ * on its own */
79
+ this.onInvalidate = opts.onInvalidate ?? null;
73
80
 
74
81
  if (this.window) {
75
82
  this._ctx = this.window.getContext('2d');
@@ -116,8 +123,7 @@ export default class MarkdownView {
116
123
  parseMermaid(text).then(
117
124
  (model) => {
118
125
  entry.model = model;
119
- this._layoutWidth = -1;
120
- if (this.window && this.window._mapped) this.render();
126
+ this._invalidate();
121
127
  },
122
128
  () => {
123
129
  entry.failed = true; // unsupported/invalid: stays a code block
@@ -127,6 +133,13 @@ export default class MarkdownView {
127
133
  return entry;
128
134
  }
129
135
 
136
+ /** async content arrived: drop the cached layout, repaint and/or notify */
137
+ _invalidate() {
138
+ this._layoutWidth = -1;
139
+ if (this.window && this.window._mapped) this.render();
140
+ if (this.onInvalidate) this.onInvalidate();
141
+ }
142
+
130
143
  /** lay content out for a container width; returns total content height */
131
144
  layout(width) {
132
145
  const fonts = this._fonts();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "3.2.0",
3
+ "version": "3.4.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",