ntk 8.12.13 → 8.13.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
@@ -311,6 +311,63 @@ export default class App {
311
311
  for (const wnd of Window._cacheFor(this).values()) wnd._adoptDefaultFrameInterval(ms);
312
312
  }
313
313
 
314
+ /**
315
+ * Whether a compositing manager runs on this connection's screen: `true`,
316
+ * `false`, or `null` until the server has been asked, which the first
317
+ * window to present does (see docs/window.md "Under a compositor").
318
+ *
319
+ * Under one, the window's frames end on the fence and its blits are
320
+ * CopyArea (`Window#_preferFence`): the compositor already puts the screen
321
+ * together at the vertical blank, so a present synced to it buys no tearing
322
+ * a copy would cause, and its clock costs what issue #402 measured — up to
323
+ * a frame of latency on every change, and on NVIDIA's driver a completion
324
+ * whose phase drifts across the whole period, so half the frames a late
325
+ * one owes miss their vblank. Xwayland is the exception (`xwayland`): its
326
+ * Present clock is the Wayland compositor's frame callback, which is what
327
+ * stops an occluded window from rendering.
328
+ *
329
+ * Followed for the life of the connection, through the `_NET_WM_CM_S`
330
+ * selection the EWMH has a compositor own, so one started or stopped
331
+ * while the app runs moves every window to the clock that fits.
332
+ */
333
+ get compositing() {
334
+ if (this._compositorProbe === undefined) this._probeCompositor();
335
+ return this._compositing ?? null;
336
+ }
337
+
338
+ /** Whether this server is Xwayland, once `compositing` has asked. */
339
+ get xwayland() {
340
+ return this._xwayland ?? false;
341
+ }
342
+
343
+ _probeCompositor() {
344
+ this._compositorProbe = null; // in progress; only ever started once
345
+ const X = this.X;
346
+ if (connectionGone(X)) return;
347
+ const name = '_NET_WM_CM_S0';
348
+ X.QueryExtension('XWAYLAND', (xwErr, ext) => {
349
+ this._xwayland = !xwErr && !!ext?.present;
350
+ X.InternAtom(false, name, (atomErr, atom) => {
351
+ if (atomErr || !atom) return;
352
+ X.GetSelectionOwner(atom, (ownerErr, owner) => {
353
+ if (ownerErr) return;
354
+ this._setCompositing(owner !== 0);
355
+ this.clipboard
356
+ .watch(name, (ev) => this._setCompositing(ev.reason === 'new-owner' && ev.owner !== 0))
357
+ .catch(() => {
358
+ // no XFixes: the state at startup is what windows go by
359
+ });
360
+ });
361
+ });
362
+ });
363
+ }
364
+
365
+ _setCompositing(on) {
366
+ if (this._compositing === on) return;
367
+ this._compositing = on;
368
+ for (const wnd of Window._cacheFor(this).values()) wnd._compositingChanged?.();
369
+ }
370
+
314
371
  /** the text API entry point: font matching/loading, shaping, layout */
315
372
  get fonts() {
316
373
  if (!this._fonts) this._fonts = new FontManager({ source: this.options.fontSource });
@@ -12,6 +12,19 @@ const WS = new Set([0x20, 0x09]);
12
12
  const HARD_BREAKS = /[\n\r\u2028\u2029]+$/;
13
13
  const TRAILING_WS = /[ \t]+$/;
14
14
 
15
+ /** Scripts written without spaces between words, whose line breaking
16
+ * needs a dictionary (UAX #14's SA class, and Javanese, Balinese and
17
+ * Buginese, which are AL there): a token of theirs is a run of words. */
18
+ const UNSPACED =
19
+ /[\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}\p{Script=Tai_Le}\p{Script=New_Tai_Lue}\p{Script=Tai_Tham}\p{Script=Tai_Viet}\p{Script=Ahom}\p{Script=Javanese}\p{Script=Balinese}\p{Script=Buginese}]/u;
20
+
21
+ /** How wide a line's tokens are, the white space it ends on left out. */
22
+ function tokensWidth(toks) {
23
+ let width = 0;
24
+ for (const token of toks) width += token.width;
25
+ return toks.length ? width - toks[toks.length - 1].wsWidth : 0;
26
+ }
27
+
15
28
  function isWsGlyph(g) {
16
29
  return g.codePoints.length > 0 && g.codePoints.every((cp) => WS.has(cp));
17
30
  }
@@ -27,8 +40,12 @@ function isWsGlyph(g) {
27
40
  *
28
41
  * Content is a plain string or an array of spans
29
42
  * `{ text, family?, size?, weight?, style?, features?, language?,
30
- * letterSpacing?, color? }`;
31
- * span fields override the base style. Options:
43
+ * letterSpacing?, color?, nowrap? }`;
44
+ * span fields override the base style. Spans that share a truthy `nowrap` —
45
+ * `true`, or any value a caller tells its groups apart by — have no break
46
+ * opportunity inside them or between them, as the text of an element with
47
+ * CSS's `white-space: nowrap` has none: the break after the last of them is
48
+ * the next span's to allow. Options:
32
49
  *
33
50
  * - `maxWidth` — target container width (default: unlimited)
34
51
  * - `align` — 'left' | 'right' | 'center' | 'start' | 'end'
@@ -36,7 +53,16 @@ function isWsGlyph(g) {
36
53
  * - `direction` — 'ltr' | 'rtl' | 'auto' base paragraph direction
37
54
  * - `maxLines` — cap the number of lines (default: unlimited)
38
55
  * - `overflow` — 'clip' (default) or 'ellipsis', what a `maxLines` cut looks
39
- * like. See `_elide`.
56
+ * like, and a line that does not wrap cut at `maxWidth`. See `_elide`.
57
+ * - `wrap` — false lays the text out a line to each forced break however
58
+ * wide it is; with `overflow: 'ellipsis'` a line wider than `maxWidth` is
59
+ * cut there with an ellipsis, inside a word if need be, as CSS's
60
+ * `text-overflow` cuts text that does not wrap. Default true.
61
+ * - `overflowWrap` — 'break-word' (default) cuts a word wider than the line
62
+ * at a grapheme boundary; 'normal' keeps it whole on a line of its own,
63
+ * past the line's end, as CSS's `overflow-wrap: normal` does — but for
64
+ * text in a script written without spaces (Thai, Khmer, Javanese…), whose
65
+ * words the breaker cannot find, which is cut as a fallback
40
66
  *
41
67
  * The result is inspectable before/without drawing: `width`, `height`,
42
68
  * `truncated`, and `lines[] = { x, y, height, baseline, width, ascent,
@@ -75,6 +101,13 @@ export class TextLayout {
75
101
 
76
102
  // ---- greedy line fill ----
77
103
  const lineTokens = [];
104
+ // a word wider than the line stays whole, past the line's end, where
105
+ // the options say so; cut inside itself where they do not — and in a
106
+ // script written without spaces, where the breaker's "word" is a phrase
107
+ // it had no dictionary to cut, cut anyway (CSS Text 3, 5.1)
108
+ const whole = options.overflowWrap === 'normal';
109
+ // text that does not wrap is a line to each forced break, however wide
110
+ const wraps = options.wrap !== false;
78
111
  {
79
112
  let cur = [];
80
113
  let curWidth = 0;
@@ -84,9 +117,11 @@ export class TextLayout {
84
117
  curWidth = 0;
85
118
  };
86
119
  for (let token of tokens) {
87
- while (curWidth + token.width - token.wsWidth > maxWidth) {
120
+ while (wraps && curWidth + token.width - token.wsWidth > maxWidth) {
88
121
  if (cur.length > 0) {
89
122
  flush();
123
+ } else if (whole && !UNSPACED.test(text.slice(token.start, token.end))) {
124
+ break;
90
125
  } else {
91
126
  // single token wider than the container: force-break it
92
127
  const [head, rest] = this._forceBreak(token, maxWidth);
@@ -109,10 +144,13 @@ export class TextLayout {
109
144
  const maxLines = Number.isFinite(options.maxLines)
110
145
  ? Math.max(0, Math.floor(options.maxLines))
111
146
  : Infinity;
112
- /** did `maxLines` drop content? */
147
+ /** did `maxLines` drop content, or a line that does not wrap its end? */
113
148
  this.truncated = lineTokens.length > maxLines;
114
149
  if (this.truncated) lineTokens.length = maxLines;
115
- const elide = this.truncated && options.overflow === 'ellipsis';
150
+ const ellipses = options.overflow === 'ellipsis';
151
+ const elide = this.truncated && ellipses;
152
+ // and a line that does not wrap is cut where it runs past the width
153
+ const cutWide = ellipses && !wraps && Number.isFinite(maxWidth);
116
154
 
117
155
  // ---- assemble lines: strip trailing ws, bidi-reorder, position ----
118
156
  const baseSpan = spans[0];
@@ -126,10 +164,15 @@ export class TextLayout {
126
164
  const lineStart = toks.length ? toks[0].start : 0;
127
165
  let lineEnd = toks.length ? toks[toks.length - 1].end : lineStart;
128
166
 
129
- // Elision is the last kept line's business only, and it happens before
130
- // entries exist, because dropping content means re-shaping the tail.
131
- const ellipsis = elide && li === lineTokens.length - 1 ? this._elide(toks, baseSpan) : null;
132
- if (ellipsis) toks = this._fitBefore(toks, maxWidth - ellipsis.width);
167
+ // Elision is the last kept line's business, or a line's that does not
168
+ // wrap and runs past the width, and it happens before entries exist,
169
+ // because dropping content means re-shaping the tail.
170
+ const cut = (elide && li === lineTokens.length - 1) || (cutWide && tokensWidth(toks) > maxWidth);
171
+ const ellipsis = cut ? this._elide(toks, baseSpan) : null;
172
+ if (ellipsis) {
173
+ toks = this._fitBefore(toks, maxWidth - ellipsis.width);
174
+ this.truncated = true;
175
+ }
133
176
 
134
177
  // entries carry .level so reorderRuns can order them (UAX#9 L2);
135
178
  // .start/.end are absolute code-unit ranges into the full text, kept
@@ -238,10 +281,13 @@ export class TextLayout {
238
281
  let align = options.align ?? 'start';
239
282
  if (align === 'start') align = this.baseLevel ? 'right' : 'left';
240
283
  if (align === 'end') align = this.baseLevel ? 'left' : 'right';
241
- if (align !== 'left') {
284
+ if (align !== 'left' || this.baseLevel) {
242
285
  for (const line of this.lines) {
243
286
  const free = container - line.width;
244
- line.x = align === 'right' ? free : free / 2;
287
+ // a line too long for its container starts at its start edge and
288
+ // runs past its end, whatever the alignment (CSS Text 3, 7.1)
289
+ if (free < 0) line.x = this.baseLevel ? free : 0;
290
+ else if (align !== 'left') line.x = align === 'right' ? free : free / 2;
245
291
  }
246
292
  }
247
293
  }
@@ -314,12 +360,18 @@ export class TextLayout {
314
360
  };
315
361
 
316
362
  // ---- tokenize at UAX#14 break opportunities ----
363
+ // none between two characters of spans that share a `nowrap`
364
+ const held = spans.some((s) => s.nowrap);
317
365
  const tokens = [];
318
366
  {
319
367
  const breaker = new LineBreaker(text);
320
368
  let prev = 0;
321
369
  let bk;
322
370
  while ((bk = breaker.nextBreak())) {
371
+ if (held && !bk.required && bk.position < text.length) {
372
+ const before = spanAt(bk.position - 1).span.nowrap;
373
+ if (before && before === spanAt(bk.position).span.nowrap) continue;
374
+ }
323
375
  tokens.push(this._makeToken(text, prev, bk.position, levels, spanAt, bk.required));
324
376
  prev = bk.position;
325
377
  }
package/lib/window.js CHANGED
@@ -448,6 +448,16 @@ function decodeProperty(prop, as, X) {
448
448
  return { type: prop.type, data: prop.data };
449
449
  }
450
450
 
451
+ /**
452
+ * The frame clock `NTK_FRAME_CLOCK` names for every window that does not
453
+ * name one itself: `'auto'`, `'present'` or `'fence'` (see `frameClock`),
454
+ * for trying a machine both ways without touching the app.
455
+ */
456
+ function envFrameClock() {
457
+ const v = globalThis.process?.env?.NTK_FRAME_CLOCK;
458
+ return v === 'auto' || v === 'present' || v === 'fence' ? v : undefined;
459
+ }
460
+
451
461
  export default class Window extends Drawable {
452
462
  // one wrapper per X window id and connection: constructing a Window for a
453
463
  // known id returns the cached instance. Keyed by connection as well as
@@ -545,7 +555,7 @@ export default class Window extends Drawable {
545
555
  // everywhere else, including a server whose Present has no display behind
546
556
  // it (see _probeMsc); 'present' takes Present's clock even there; 'fence'
547
557
  // pins the round-trip clock
548
- this._frameClockPref = args.frameClock ?? 'auto';
558
+ this._frameClockPref = args.frameClock ?? envFrameClock() ?? 'auto';
549
559
  // _NET_WM_SYNC_REQUEST (see enableSyncRequest): the counter the window
550
560
  // manager watches, the value it last asked for, and the watchdog that
551
561
  // guarantees an answer even when nothing repaints
@@ -766,7 +776,10 @@ export default class Window extends Drawable {
766
776
  }
767
777
  // EWMH wants a pid and a client machine on top-level windows; child
768
778
  // windows are an implementation detail nothing asks about
769
- if (!args.id && args.pid !== false && parentId === app.display.screen[0].root) {
779
+ // a window of ours at the root: the one a window manager and a
780
+ // compositor treat as the app's window (see `_askNotToBypass`)
781
+ this._topLevel = !args.id && parentId === app.display.screen[0].root;
782
+ if (this._topLevel && args.pid !== false) {
770
783
  this.setPid(typeof args.pid === 'number' ? args.pid : undefined);
771
784
  }
772
785
  if (args.alwaysOnTop) {
@@ -1539,6 +1552,7 @@ export default class Window extends Drawable {
1539
1552
  this._frameSyncEnabled &&
1540
1553
  !this._clockStalled &&
1541
1554
  !this._presentAbandoned() &&
1555
+ !this._preferFence() &&
1542
1556
  !!this._presentExt &&
1543
1557
  !!this._presentEid
1544
1558
  );
@@ -2432,6 +2446,10 @@ export default class Window extends Drawable {
2432
2446
  * caller who opted out is not sent events it will not read.
2433
2447
  */
2434
2448
  _selectPresentInput() {
2449
+ // whether a compositor runs decides how this window's frames end, so the
2450
+ // first window to present asks (App#compositing)
2451
+ void this.app?.compositing;
2452
+ if (this._preferFence()) this._askNotToBypass();
2435
2453
  const P = this._presentExt;
2436
2454
  if (!P || this._presentEid || this._destroyed) return;
2437
2455
  if (this._frameClockPref === 'fence' || !this._frameSyncEnabled) return;
@@ -2453,10 +2471,62 @@ export default class Window extends Drawable {
2453
2471
  */
2454
2472
  _blitsThroughPresent() {
2455
2473
  return (
2456
- !!this._presentExt && !!this._fixesExt && !!this._updateRegion && !this._presentAbandoned()
2474
+ !!this._presentExt &&
2475
+ !!this._fixesExt &&
2476
+ !!this._updateRegion &&
2477
+ !this._presentAbandoned() &&
2478
+ !this._preferFence()
2457
2479
  );
2458
2480
  }
2459
2481
 
2482
+ /**
2483
+ * Whether this window leaves Present to the compositor: under a
2484
+ * compositing manager (`app.compositing`) the fence ends its frames and
2485
+ * CopyArea blits them, the configuration a window whose server makes its
2486
+ * vblanks up already runs on (`_presentAbandoned`). The blit goes with the
2487
+ * clock for the same reason as there — a present owns the backing store
2488
+ * until it executes, and a frame drawn behind one on the fence can put
2489
+ * half of itself on the screen (#223).
2490
+ *
2491
+ * Not on Xwayland, whose Present clock is the Wayland compositor's frame
2492
+ * callback; not while `frameClock: 'present'` asks for Present's clock by
2493
+ * name; and not until the server has said whether a compositor runs.
2494
+ * Reversible, unlike `_presentAbandoned`: a compositor that stops hands
2495
+ * the window back to Present's clock at its next frame.
2496
+ */
2497
+ _preferFence() {
2498
+ const app = this.app;
2499
+ return this._frameClockPref === 'auto' && app?.compositing === true && !app.xwayland;
2500
+ }
2501
+
2502
+ /**
2503
+ * EWMH `_NET_WM_BYPASS_COMPOSITOR` = 2 on a top-level window leaving
2504
+ * Present to the compositor: "do not unredirect me". Mutter, Muffin and
2505
+ * KWin unredirect a window that covers the screen, and its CopyArea blits
2506
+ * would then go to the scanout directly, where a copy can tear — what a
2507
+ * present synced to the vertical blank was there to prevent. Asked once;
2508
+ * a compositor that ignores it is one that keeps every window redirected.
2509
+ */
2510
+ _askNotToBypass() {
2511
+ if (this._bypassAsked || !this._topLevel || this._destroyed) return;
2512
+ this._bypassAsked = true;
2513
+ const X = this.X;
2514
+ this._withAtoms(['_NET_WM_BYPASS_COMPOSITOR'], (atoms) => {
2515
+ safeRelease(X, () => {
2516
+ X.ChangeProperty(0, this.id, atoms._NET_WM_BYPASS_COMPOSITOR, X.atoms.CARDINAL, 32, [2]);
2517
+ });
2518
+ });
2519
+ }
2520
+
2521
+ /** The compositor started or stopped: the next frame goes by the clock
2522
+ * that fits. A present in flight still gates it until its completion
2523
+ * hands the backing store back, whichever clock is running. */
2524
+ _compositingChanged() {
2525
+ if (this._preferFence()) this._askNotToBypass();
2526
+ const f = this._frame;
2527
+ if (f.pending.size || f.rafCbs.length || f.needsRedraw) this._scheduleFrame();
2528
+ }
2529
+
2460
2530
  /**
2461
2531
  * The Present form of the blit. Returns false when it did not happen, so
2462
2532
  * the caller falls back to CopyArea — which is also what runs before the
@@ -2601,13 +2671,21 @@ export default class Window extends Drawable {
2601
2671
  needConfigureWindow = true;
2602
2672
  configureProps.y = newState.y;
2603
2673
  }
2604
- if (newState.width !== undefined && this.width !== newState.width) {
2605
- needConfigureWindow = true;
2606
- configureProps.width = newState.width;
2607
- }
2608
- if (newState.height !== undefined && this.height !== newState.height) {
2674
+ // A new size goes out whole, both axes, even when only one of them
2675
+ // changed. A window manager answers a request that names one axis with
2676
+ // its own idea of the other, and Muffin (Cinnamon) takes the height as its
2677
+ // frame's, title bar included: every width-only request grew the window
2678
+ // by 32 px, the next one by 32 more. Xlib's XResizeWindow and GTK always
2679
+ // send both, which is why nobody else trips over it.
2680
+ if (
2681
+ (newState.width !== undefined && this.width !== newState.width) ||
2682
+ (newState.height !== undefined && this.height !== newState.height)
2683
+ ) {
2684
+ const width = newState.width ?? this.width;
2685
+ const height = newState.height ?? this.height;
2609
2686
  needConfigureWindow = true;
2610
- configureProps.height = newState.height;
2687
+ if (width !== undefined) configureProps.width = width;
2688
+ if (height !== undefined) configureProps.height = height;
2611
2689
  }
2612
2690
  if (needConfigureWindow) {
2613
2691
  this.X.ConfigureWindow(this.id, configureProps);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.12.13",
3
+ "version": "8.13.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",