ntk 5.4.0 → 6.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.
Files changed (2) hide show
  1. package/lib/text/layout.js +195 -21
  2. package/package.json +1 -1
@@ -28,11 +28,17 @@ function isWsGlyph(g) {
28
28
  * - `align` — 'left' | 'right' | 'center' | 'start' | 'end'
29
29
  * - `lineHeight` — multiplier over natural font line height (default 1)
30
30
  * - `direction` — 'ltr' | 'rtl' | 'auto' base paragraph direction
31
+ * - `maxLines` — cap the number of lines (default: unlimited)
32
+ * - `overflow` — 'clip' (default) or 'ellipsis', what a `maxLines` cut looks
33
+ * like. See `_elide`.
31
34
  *
32
- * The result is inspectable before/without drawing: `width`, `height`, and
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).
35
+ * The result is inspectable before/without drawing: `width`, `height`,
36
+ * `truncated`, and `lines[] = { x, y, height, baseline, width, ascent,
37
+ * descent, runs, start, end }` with
38
+ * `runs[] = { x, width, run, span, start, end }` in visual order
39
+ * (`start`/`end` are logical UTF-16 ranges into the full text). A line's
40
+ * box is `y` to `y + height`; its glyphs sit centred in it, from
41
+ * `baseline - ascent` to `baseline + descent`.
36
42
  *
37
43
  * `caretPosition(index)` / `indexAt(x, y)` map logical code-point indices
38
44
  * to visual caret geometry and back (bidi/ligature/trailing-whitespace
@@ -143,6 +149,17 @@ export class TextLayout {
143
149
  if (cur.length) lineTokens.push(cur);
144
150
  }
145
151
 
152
+ // ---- cap the line count ----
153
+ // The cut happens here, between filling and assembly, so the dropped
154
+ // lines cost nothing to position and `height` counts only what is kept.
155
+ const maxLines = Number.isFinite(options.maxLines)
156
+ ? Math.max(0, Math.floor(options.maxLines))
157
+ : Infinity;
158
+ /** did `maxLines` drop content? */
159
+ this.truncated = lineTokens.length > maxLines;
160
+ if (this.truncated) lineTokens.length = maxLines;
161
+ const elide = this.truncated && options.overflow === 'ellipsis';
162
+
146
163
  // ---- assemble lines: strip trailing ws, bidi-reorder, position ----
147
164
  const baseSpan = spans[0];
148
165
  const lineHeightMul = options.lineHeight ?? 1;
@@ -150,7 +167,16 @@ export class TextLayout {
150
167
  let y = 0;
151
168
  let layoutWidth = 0;
152
169
 
153
- for (const toks of lineTokens) {
170
+ for (let li = 0; li < lineTokens.length; li++) {
171
+ let toks = lineTokens[li];
172
+ const lineStart = toks.length ? toks[0].start : 0;
173
+ let lineEnd = toks.length ? toks[toks.length - 1].end : lineStart;
174
+
175
+ // Elision is the last kept line's business only, and it happens before
176
+ // entries exist, because dropping content means re-shaping the tail.
177
+ const ellipsis = elide && li === lineTokens.length - 1 ? this._elide(toks, baseSpan) : null;
178
+ if (ellipsis) toks = this._fitBefore(toks, maxWidth - ellipsis.width);
179
+
154
180
  // entries carry .level so reorderRuns can order them (UAX#9 L2);
155
181
  // .start/.end are absolute code-unit ranges into the full text, kept
156
182
  // for caret positioning (caretPosition / indexAt)
@@ -168,14 +194,34 @@ export class TextLayout {
168
194
  }
169
195
  }
170
196
  }
171
- const lineStart = toks[0].start;
172
- const lineEnd = toks[toks.length - 1].end;
173
- const trailing = stripTrailingWhitespace(entries);
197
+ let trailing = stripTrailingWhitespace(entries);
174
198
  const contentEnd = trailing
175
199
  ? trailing.start
176
200
  : entries.length
177
201
  ? entries[entries.length - 1].end
178
202
  : lineStart;
203
+ if (ellipsis) {
204
+ // The ellipsis takes the *paragraph* level, which is what a neutral
205
+ // at the end of a paragraph resolves to under UAX#9 — so reorderRuns
206
+ // puts it at the right edge of an LTR line and the left edge of an
207
+ // RTL one, without a special case here. Its logical range is empty
208
+ // and pinned at the cut, so caret mapping treats it as the end of
209
+ // the visible text rather than as characters of its own.
210
+ for (const run of ellipsis.shaped.runs) {
211
+ entries.push({
212
+ run,
213
+ span: ellipsis.span,
214
+ level: this.baseLevel,
215
+ start: contentEnd,
216
+ end: contentEnd,
217
+ ellipsis: true
218
+ });
219
+ }
220
+ // whatever whitespace was stripped is gone for good, not merely
221
+ // pushed past the line edge: there is no wrap for it to precede
222
+ trailing = null;
223
+ lineEnd = contentEnd;
224
+ }
179
225
  entries = reorderRuns(entries);
180
226
 
181
227
  let ascent = 0;
@@ -184,7 +230,9 @@ export class TextLayout {
184
230
  const runs = [];
185
231
  let x = 0;
186
232
  for (const e of entries) {
187
- runs.push({ x, width: e.run.width, run: e.run, span: e.span, start: e.start, end: e.end });
233
+ const positioned = { x, width: e.run.width, run: e.run, span: e.span, start: e.start, end: e.end };
234
+ if (e.ellipsis) positioned.ellipsis = true;
235
+ runs.push(positioned);
188
236
  x += e.run.width;
189
237
  const m = e.run.font.metrics(e.run.size);
190
238
  if (m.ascent > ascent) ascent = m.ascent;
@@ -199,10 +247,23 @@ export class TextLayout {
199
247
  natural = m.lineHeight;
200
248
  }
201
249
  if (x > layoutWidth) layoutWidth = x;
250
+ // Half-leading (CSS Inline Layout 3): the slack between the glyphs and
251
+ // the line box is split evenly above and below, rather than all of it
252
+ // landing under the text. This is what makes a single line sit
253
+ // centred in a box measured from `layout.height`, and it applies at
254
+ // `lineHeight: 1` too — a font's natural line height includes its line
255
+ // gap, which is 8px at 16px for some UI faces.
256
+ //
257
+ // A multiplier small enough to make the box shorter than the glyphs
258
+ // gives negative leading and the text overflows evenly on both sides,
259
+ // which is also what CSS does.
260
+ const box = natural * lineHeightMul;
261
+ const leading = (box - (ascent + descent)) / 2;
202
262
  this.lines.push({
203
263
  x: 0,
204
264
  y,
205
- baseline: y + ascent,
265
+ height: box,
266
+ baseline: y + leading + ascent,
206
267
  width: x,
207
268
  ascent,
208
269
  descent,
@@ -212,7 +273,7 @@ export class TextLayout {
212
273
  _contentEnd: contentEnd,
213
274
  _trailing: trailing
214
275
  });
215
- y += natural * lineHeightMul;
276
+ y += box;
216
277
  }
217
278
 
218
279
  this.width = layoutWidth;
@@ -243,7 +304,9 @@ export class TextLayout {
243
304
  if (fragText.length > 0) {
244
305
  const fragLevels = normalizedLevels(levels, pos, pos + fragText.length);
245
306
  const shaped = this.fonts._shapeCached(fragText, span, fragLevels);
246
- fragments.push({ text: fragText, span, shaped, start: pos });
307
+ // the levels ride along so a later split can re-shape a piece at the
308
+ // level it actually has, rather than assuming ltr
309
+ fragments.push({ text: fragText, span, shaped, start: pos, levels: fragLevels });
247
310
  width += shaped.width;
248
311
  }
249
312
  pos = fragEnd;
@@ -261,6 +324,67 @@ export class TextLayout {
261
324
  return { fragments, width, wsWidth, required, start, end };
262
325
  }
263
326
 
327
+ /**
328
+ * The ellipsis to append to a line that was cut short: `{ text, span,
329
+ * shaped, width }`.
330
+ *
331
+ * Shaped in the style of the line's **logically trailing** span, so an
332
+ * elided line ending in a large or bold word gets a matching ellipsis
333
+ * rather than one in the paragraph's base style. That span is chosen
334
+ * before the cut, not after: choosing it after would make the ellipsis
335
+ * width depend on a cut that depends on the ellipsis width.
336
+ *
337
+ * U+2026 is not universal — a subsetted icon or maths face may well lack
338
+ * it — so when neither the span's font nor any fallback covers it, three
339
+ * periods stand in. Shaping the real character through a font that has no
340
+ * glyph for it would draw a .notdef box, which is a worse way to say
341
+ * "there is more text".
342
+ */
343
+ _elide(toks, baseSpan) {
344
+ let span = baseSpan;
345
+ for (let i = toks.length - 1; i >= 0; i--) {
346
+ const frags = toks[i].fragments;
347
+ if (frags.length) {
348
+ span = frags[frags.length - 1].span;
349
+ break;
350
+ }
351
+ }
352
+ const covered =
353
+ span.font.hasGlyph(0x2026) || this.fonts.fallbackFor(0x2026, span.family, span) !== null;
354
+ const text = covered ? '…' : '...';
355
+ const shaped = this.fonts._shapeCached(text, span, String(this.baseLevel));
356
+ return { text, span, shaped, width: shaped.width };
357
+ }
358
+
359
+ /**
360
+ * The longest prefix of a line's tokens whose width fits `budget` — whole
361
+ * tokens while they fit, then a grapheme-boundary cut into the first one
362
+ * that does not.
363
+ *
364
+ * Cutting has to happen here rather than by slicing the text, because the
365
+ * tail is re-shaped: kerning and ligatures across the cut change widths,
366
+ * and in a mixed-direction line the visually-last run is not the logically
367
+ * last one. Working in tokens keeps both facts inside the machinery that
368
+ * already knows them.
369
+ */
370
+ _fitBefore(toks, budget) {
371
+ const kept = [];
372
+ let used = 0;
373
+ for (const token of toks) {
374
+ // trailing whitespace does not count against the budget, exactly as it
375
+ // does not count during the greedy fill
376
+ if (used + token.width - token.wsWidth <= budget) {
377
+ kept.push(token);
378
+ used += token.width;
379
+ continue;
380
+ }
381
+ const [head] = this._forceBreak(token, budget - used);
382
+ if (head && head.fragments.length) kept.push(head);
383
+ break;
384
+ }
385
+ return kept;
386
+ }
387
+
264
388
  // split an over-wide token at the widest cluster boundary that fits
265
389
  _forceBreak(token, maxWidth) {
266
390
  const headFrags = [];
@@ -272,15 +396,22 @@ export class TextLayout {
272
396
  used += frag.shaped.width;
273
397
  continue;
274
398
  }
275
- // binary search the longest codepoint prefix of this fragment that fits
276
- const cps = Array.from(frag.text);
399
+ // binary search the longest grapheme prefix of this fragment that fits.
400
+ // Graphemes rather than code points: cutting between a base character
401
+ // and its combining mark, or inside an emoji ZWJ sequence, leaves a
402
+ // dotted circle or a pair of half-emoji on the two sides of the break.
403
+ const cps = graphemes(frag.text);
277
404
  let lo = 0;
278
405
  let hi = cps.length - 1;
279
406
  let best = null;
280
407
  while (lo <= hi) {
281
408
  const mid = (lo + hi) >> 1;
282
409
  const prefix = cps.slice(0, mid + 1).join('');
283
- const shaped = this.fonts._shapeCached(prefix, frag.span, '0');
410
+ // shaped at the bidi level this text actually has: assuming level 0
411
+ // here re-shapes an rtl word as ltr, which lays its glyphs out
412
+ // backwards and hands reorderRuns an even level that stops it from
413
+ // being reordered at all
414
+ const shaped = this.fonts._shapeCached(prefix, frag.span, sliceLevels(frag.levels, 0, prefix.length));
284
415
  if (used + shaped.width <= maxWidth) {
285
416
  best = { len: prefix.length, shaped, text: prefix };
286
417
  lo = mid + 1;
@@ -289,17 +420,26 @@ export class TextLayout {
289
420
  }
290
421
  }
291
422
  if (best) {
292
- headFrags.push({ text: best.text, span: frag.span, shaped: best.shaped, start: frag.start });
423
+ headFrags.push({
424
+ text: best.text,
425
+ span: frag.span,
426
+ shaped: best.shaped,
427
+ start: frag.start,
428
+ levels: sliceLevels(frag.levels, 0, best.len)
429
+ });
293
430
  }
294
431
 
295
432
  const restFrags = [];
296
- const restText = frag.text.slice(best ? best.len : 0);
433
+ const cut = best ? best.len : 0;
434
+ const restText = frag.text.slice(cut);
297
435
  if (restText) {
436
+ const restLevels = sliceLevels(frag.levels, cut, frag.text.length);
298
437
  restFrags.push({
299
438
  text: restText,
300
439
  span: frag.span,
301
- shaped: this.fonts._shapeCached(restText, frag.span, '0'),
302
- start: frag.start + (best ? best.len : 0)
440
+ shaped: this.fonts._shapeCached(restText, frag.span, restLevels),
441
+ start: frag.start + cut,
442
+ levels: restLevels
303
443
  });
304
444
  }
305
445
  restFrags.push(...token.fragments.slice(i + 1));
@@ -417,8 +557,13 @@ export class TextLayout {
417
557
  * `[0, codePointCount]` (out-of-range indices clamp).
418
558
  *
419
559
  * @returns {{ x, y, height, line }} `x` is the caret's visual x within
420
- * the layout box (alignment included), `y` the top of the line box,
560
+ * the layout box (alignment included), `y` the top of the **glyphs**,
421
561
  * `height` = ascent + descent, `line` the line index.
562
+ *
563
+ * `y` tracks the text rather than the line box, so a caret drawn from it
564
+ * stays locked to the glyphs whatever the leading. For a full-height
565
+ * selection band instead, the line box is `line.y` to `line.y +
566
+ * line.height`.
422
567
  */
423
568
  caretPosition(index) {
424
569
  const offs = this._offsets();
@@ -435,7 +580,7 @@ export class TextLayout {
435
580
  const line = this.lines[li];
436
581
  return {
437
582
  x: this._caretXInLine(line, cu),
438
- y: line.y,
583
+ y: line.baseline - line.ascent,
439
584
  height: line.ascent + line.descent,
440
585
  line: li
441
586
  };
@@ -484,6 +629,9 @@ export class TextLayout {
484
629
  break;
485
630
  }
486
631
  }
632
+ // the ellipsis stands for text that is not displayed, so anywhere on it
633
+ // is the end of what is: it has no indices of its own to land in
634
+ if (target.ellipsis) return this._cpOf(line._contentEnd);
487
635
  return this._cpOf(this._runIndexAt(target, cx));
488
636
  }
489
637
 
@@ -623,6 +771,24 @@ export class TextLayout {
623
771
  }
624
772
  }
625
773
 
774
+ // Grapheme clusters (UAX#29), for cut points that never land inside one.
775
+ // Intl.Segmenter is in node >= 16 and every current browser; where it is
776
+ // somehow absent, code points are the old behaviour and still safe for the
777
+ // scripts that reach a force-break most often.
778
+ let segmenter;
779
+ function graphemes(text) {
780
+ if (segmenter === undefined) {
781
+ segmenter =
782
+ typeof Intl !== 'undefined' && Intl.Segmenter
783
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
784
+ : null;
785
+ }
786
+ if (!segmenter) return Array.from(text);
787
+ const out = [];
788
+ for (const { segment } of segmenter.segment(text)) out.push(segment);
789
+ return out;
790
+ }
791
+
626
792
  // UTF-16 length of a glyph cluster's codePoints array
627
793
  function cuLength(codePoints) {
628
794
  let len = 0;
@@ -683,6 +849,14 @@ function stripTrailingWhitespace(entries) {
683
849
  return result();
684
850
  }
685
851
 
852
+ // The levels key for a code-unit slice of a fragment. A uniform key covers
853
+ // any slice of itself; a per-character one has to be cut to match.
854
+ function sliceLevels(key, start, end) {
855
+ if (key === undefined) return '0';
856
+ if (!key.includes(',')) return key;
857
+ return key.split(',').slice(start, end).join(',');
858
+ }
859
+
686
860
  // compact levels key for the shaping cache: single char when uniform
687
861
  function normalizedLevels(levels, start, end) {
688
862
  let uniform = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "5.4.0",
3
+ "version": "6.0.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",