ntk 5.2.0 → 5.3.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.
@@ -202,6 +202,95 @@ function pathBBox(path) {
202
202
  return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
203
203
  }
204
204
 
205
+ /**
206
+ * How many distinct colours a document actually commits to, decided once at
207
+ * parse time.
208
+ *
209
+ * `mono` means every fill and stroke that reaches a shape is `none` or the
210
+ * *same* paint — one literal colour, or `currentColor` throughout. Such a
211
+ * drawing is really a coverage mask plus a colour, so a caller can render it
212
+ * once and recolour it on every draw. `multi` is everything else: a second
213
+ * distinct paint, or a gradient/pattern reference, whose colours belong to
214
+ * the drawing rather than to the UI around it.
215
+ *
216
+ * Opacity does not enter into it: `opacity`, `fill-opacity` and
217
+ * `stroke-opacity` scale coverage, which a mask carries perfectly well.
218
+ *
219
+ * The walk mirrors `_style` and `_renderNode` — fill and stroke inherit,
220
+ * inline `style` beats the presentation attribute, the initial fill is black
221
+ * and the initial stroke is none, `<line>` never fills, `<use>` paints its
222
+ * target with the *use* element's style, and non-rendered subtrees
223
+ * contribute nothing. It starts at the root's children rather than the root,
224
+ * because `draw` does: presentation attributes on the root `<svg>` are not
225
+ * applied, and a scan that applied them would disagree with what is painted.
226
+ *
227
+ * @returns {{ kind: 'mono'|'multi', solo: string|null }} `solo` is the one
228
+ * paint a `mono` document uses — a colour, or the literal `'currentColor'`
229
+ * when the document defers to its caller — and null when nothing paints.
230
+ */
231
+ function scanPaints(root, ids) {
232
+ const paints = new Set();
233
+ let multi = false;
234
+
235
+ const paintOf = (node, name, inherited) => {
236
+ let v = attr(node, name);
237
+ for (const decl of (node.attribs?.style || '').split(';')) {
238
+ const idx = decl.indexOf(':');
239
+ if (idx > 0 && decl.slice(0, idx).trim() === name) v = decl.slice(idx + 1);
240
+ }
241
+ if (v === undefined || v === '' || v === 'inherit') return inherited;
242
+ return String(v).trim();
243
+ };
244
+
245
+ const note = (paint) => {
246
+ if (paint === 'none') return;
247
+ if (/^url\(/i.test(paint)) multi = true;
248
+ else paints.add(paint);
249
+ };
250
+
251
+ const kids = (node, fill, stroke, depth) => {
252
+ for (const child of node.children || []) {
253
+ if (child.type === 'tag') visit(child, fill, stroke, depth + 1);
254
+ }
255
+ };
256
+
257
+ const visit = (node, fill, stroke, depth) => {
258
+ if (multi || depth > 32) return;
259
+ const name = tag(node);
260
+ if (NON_RENDERED.has(name)) return;
261
+ const f = paintOf(node, 'fill', fill);
262
+ const s = paintOf(node, 'stroke', stroke);
263
+ switch (name) {
264
+ case 'svg':
265
+ case 'g':
266
+ case 'a':
267
+ kids(node, f, s, depth);
268
+ return;
269
+ case 'use': {
270
+ const href = node.attribs?.href || node.attribs?.['xlink:href'] || '';
271
+ const target = href.startsWith('#') ? ids.get(href.slice(1)) : null;
272
+ if (!target) return;
273
+ // <symbol> is non-rendered on its own but renders through <use>
274
+ if (tag(target) === 'symbol') kids(target, f, s, depth);
275
+ else visit(target, f, s, depth + 1);
276
+ return;
277
+ }
278
+ case 'text':
279
+ note(f);
280
+ return;
281
+ default:
282
+ if (name !== 'line') note(f);
283
+ note(s);
284
+ }
285
+ };
286
+
287
+ kids(root, INHERITED.fill, INHERITED.stroke, 0);
288
+ return {
289
+ kind: multi || paints.size > 1 ? 'multi' : 'mono',
290
+ solo: paints.size === 1 ? [...paints][0] : null
291
+ };
292
+ }
293
+
205
294
  /**
206
295
  * Widget rendering a static SVG document into a window (or any 2d context
207
296
  * via `draw()`). Scripting, CSS stylesheets, filters, masks and external
@@ -213,12 +302,22 @@ export default class SvgView {
213
302
  this.theme = { background: 'white', ...(opts.theme || {}) };
214
303
  /** fit mode in window mode: 'contain' (default) | 'fill' */
215
304
  this.fit = opts.fit || 'contain';
305
+ /**
306
+ * What `currentColor` resolves to, for documents that defer their colour
307
+ * to the surrounding UI the way an icon set does. Per-draw `opts.color`
308
+ * overrides it; both fall back to the CSS initial value, black.
309
+ */
310
+ this.color = opts.color ?? INHERITED.color;
216
311
 
217
312
  this._root = null;
218
313
  this._ids = new Map();
219
314
  this.naturalWidth = 0;
220
315
  this.naturalHeight = 0;
221
316
  this.viewBox = null;
317
+ /** see `scanPaints`: 'mono' | 'multi', and the single paint of a mono
318
+ * document — `'currentColor'` when it defers its colour to the caller */
319
+ this.paintKind = 'mono';
320
+ this.soloPaint = null;
222
321
 
223
322
  if (this.window) {
224
323
  this._ctx = this.window.getContext('2d');
@@ -263,6 +362,10 @@ export default class SvgView {
263
362
  };
264
363
  collect(this._root);
265
364
 
365
+ const scanned = scanPaints(this._root, this._ids);
366
+ this.paintKind = scanned.kind;
367
+ this.soloPaint = scanned.solo;
368
+
266
369
  const a = this._root.attribs || {};
267
370
  const vb = (attr(this._root, 'viewBox') || '')
268
371
  .split(/[\s,]+/)
@@ -305,8 +408,15 @@ export default class SvgView {
305
408
  /**
306
409
  * Draw the document into any 2d context. `w`/`h` default to the
307
410
  * document's natural size; the viewBox (when present) is scaled to fit.
411
+ *
412
+ * `opts.color` is what `fill="currentColor"` and `stroke="currentColor"`
413
+ * resolve to for this draw, overriding the view's own `color`. That is how
414
+ * an icon takes its colour from the UI around it — the document itself
415
+ * names no colour, so the same parsed document paints in whatever the
416
+ * caller is using, and a caller caching the result can recolour a cached
417
+ * `paintKind === 'mono'` drawing without re-rendering it.
308
418
  */
309
- draw(ctx, x = 0, y = 0, w = this.naturalWidth, h = this.naturalHeight) {
419
+ draw(ctx, x = 0, y = 0, w = this.naturalWidth, h = this.naturalHeight, opts = {}) {
310
420
  if (!this._root) return;
311
421
  ctx.save();
312
422
  ctx.translate(x, y);
@@ -319,7 +429,13 @@ export default class SvgView {
319
429
  } else if (this.naturalWidth > 0 && this.naturalHeight > 0) {
320
430
  ctx.scale(w / this.naturalWidth, h / this.naturalHeight);
321
431
  }
322
- this._renderChildren(this._root, ctx, { ...INHERITED }, 1, 0);
432
+ this._renderChildren(
433
+ this._root,
434
+ ctx,
435
+ { ...INHERITED, color: opts.color ?? this.color },
436
+ 1,
437
+ 0
438
+ );
323
439
  ctx.restore();
324
440
  }
325
441
 
package/lib/window.js CHANGED
@@ -2,6 +2,7 @@ import x11 from 'x11';
2
2
 
3
3
  import { safeRelease } from './cleanup.js';
4
4
  import Drawable from './drawable.js';
5
+ import { packIcons, unpackIcons } from './imagedata.js';
5
6
  import Pixmap from './pixmap.js';
6
7
  import * as xevents from './events_map.js';
7
8
  import { decodeKey } from './keyboard.js';
@@ -432,6 +433,11 @@ export default class Window extends Drawable {
432
433
  if (args.windowType) {
433
434
  this.setWindowType(args.windowType);
434
435
  }
436
+ if (args.icon) {
437
+ // needs the atom interned first, so unlike the writers above it is not
438
+ // on the wire by the time createWindow returns
439
+ this.setIcon(args.icon).catch((err) => app.options.onXError?.(err));
440
+ }
435
441
  // ICCCM/EWMH hints at creation: `hints: { ... }`, the `sizeHints`/
436
442
  // `resizable` arguments that predate it, or the hint names on their own
437
443
  // at the top level. All of them land in one setHints call so that a
@@ -505,6 +511,9 @@ export default class Window extends Drawable {
505
511
  // them in the order they happened (a drag sees the move, then the up)
506
512
  this._flushCoalesced();
507
513
  this.emit(eventName, ntkev);
514
+ // a WM_DELETE_WINDOW ClientMessage is the window manager *asking*, and
515
+ // 'close' is that question in a form an application can answer
516
+ if (eventName === 'message') this._emitCloseRequest(ntkev);
508
517
  // anything drawn during the handlers becomes visible in one blit
509
518
  if (this._dirty) this._present();
510
519
  });
@@ -527,6 +536,18 @@ export default class Window extends Drawable {
527
536
  });
528
537
 
529
538
  this.on('newListener', (name) => {
539
+ // Listening for 'close' is the opt-in. WM_DELETE_WINDOW only reaches a
540
+ // client that advertised it in WM_PROTOCOLS — a window manager kills
541
+ // anyone else outright — and having to know that, on top of decoding a
542
+ // ClientMessage by hand, is the protocol showing through the toolkit.
543
+ if (name === 'close' && !this._closeArmed) {
544
+ this._closeArmed = true;
545
+ // interning WM_PROTOCOLS here as well as in addProtocol: the
546
+ // dispatch path matches against both atoms and node-x11 caches them
547
+ // per connection, so this costs one round trip and no more
548
+ this.atom('WM_PROTOCOLS').catch(() => {});
549
+ this.addProtocol('WM_DELETE_WINDOW').catch((err) => this.app.options.onXError?.(err));
550
+ }
530
551
  // 'statechange' is derived from a PropertyNotify, so it needs the atom
531
552
  // to compare against as well as the mask the table below selects
532
553
  if (name === 'statechange' && !this._netWmStateAtom) {
@@ -1415,6 +1436,9 @@ export default class Window extends Drawable {
1415
1436
  * atom name: `'WM_DELETE_WINDOW'`, `'WM_TAKE_FOCUS'`, `'_NET_WM_PING'`,
1416
1437
  * `'_NET_WM_SYNC_REQUEST'`. Each arrives as a `'message'` event.
1417
1438
  *
1439
+ * `WM_DELETE_WINDOW` needs none of this by hand: listening for the
1440
+ * `'close'` event adds the protocol and decodes the message for you.
1441
+ *
1418
1442
  * Replaces the whole list. `addProtocol`/`removeProtocol` are the
1419
1443
  * accumulating forms, and the ones to reach for: the property is a *set*,
1420
1444
  * and a plain write of one atom silently drops the rest.
@@ -1528,6 +1552,82 @@ export default class Window extends Drawable {
1528
1552
  });
1529
1553
  }
1530
1554
 
1555
+ /**
1556
+ * EWMH `_NET_WM_ICON` — the icon a taskbar, alt-tab switcher or titlebar
1557
+ * draws for this window.
1558
+ *
1559
+ * wnd.setIcon(await loadImage('icon-48.png'));
1560
+ * wnd.setIcon([icon16, icon32, icon48]);
1561
+ * wnd.setIcon(await ctx.getImageData(0, 0, 64, 64));
1562
+ * wnd.setIcon(null); // remove it
1563
+ *
1564
+ * An image is an ntk [`Image`](images.md), an `ImageData`, or anything
1565
+ * with `{ width, height, data }` where `data` is straight
1566
+ * (non-premultiplied) RGBA — the same contract as the rest of ntk, so
1567
+ * whatever `loadImage()` or `getImageData()` gives you goes straight in.
1568
+ *
1569
+ * Passing several is the useful case: supply the sizes you have and the
1570
+ * window manager picks whichever suits the slot it is filling, instead of
1571
+ * scaling a single one badly. Nothing requires a particular order or a
1572
+ * particular set of sizes; 16, 32 and 48 cover most desktops.
1573
+ *
1574
+ * This is the modern mechanism. `setWmHints({ icon })` writes the ICCCM
1575
+ * pixmap instead, which is 1-bit-or-matched-depth and has no alpha; a
1576
+ * window manager that understands both prefers this one.
1577
+ *
1578
+ * @param {object|object[]|null} images
1579
+ * @returns {Promise<Window>}
1580
+ */
1581
+ async setIcon(images) {
1582
+ const list = images == null ? [] : Array.isArray(images) ? images : [images];
1583
+ if (!list.length) return this.deleteProperty('_NET_WM_ICON');
1584
+
1585
+ const data = packIcons(list, this.app.display);
1586
+ const property = await this.atom('_NET_WM_ICON');
1587
+ const cardinal = this.X.atoms.CARDINAL;
1588
+ if (this._destroyed) return this;
1589
+
1590
+ // A ChangeProperty carries 24 bytes of header, so a full icon set can
1591
+ // outrun the request limit on a connection created with
1592
+ // `disableBigRequests` — 256x256 alone is 262152 bytes against a 262140
1593
+ // cap. Replace with the first chunk, append the rest.
1594
+ const maxBytes = ((this.app.display.max_request_length ?? 65535) - 6) * 4;
1595
+ safeRelease(this.X, () => {
1596
+ for (let o = 0; o < data.length; o += maxBytes) {
1597
+ this.X.ChangeProperty(
1598
+ o === 0 ? 0 : 2, // Replace, then Append
1599
+ this.id,
1600
+ property,
1601
+ cardinal,
1602
+ 32,
1603
+ data.subarray(o, Math.min(o + maxBytes, data.length))
1604
+ );
1605
+ }
1606
+ });
1607
+ return this;
1608
+ }
1609
+
1610
+ /**
1611
+ * Read `_NET_WM_ICON` back — every size the window advertises, largest
1612
+ * last or in whatever order its client wrote them, as `ImageData`.
1613
+ *
1614
+ * Useful on your own window, and the point of it on someone else's: a
1615
+ * window manager drawing titlebars reads this off each client it frames.
1616
+ * Property bytes from another client are not to be trusted, so a
1617
+ * truncated or nonsensical run yields the images that parsed cleanly
1618
+ * rather than throwing.
1619
+ *
1620
+ * Resolves to `null` when the window has no icon.
1621
+ *
1622
+ * @returns {Promise<ImageData[]|null>}
1623
+ */
1624
+ async getIcon() {
1625
+ const prop = await this.getProperty('_NET_WM_ICON');
1626
+ if (!prop || !prop.data.length) return null;
1627
+ const icons = unpackIcons(prop.data, this.app.display);
1628
+ return icons.length ? icons : null;
1629
+ }
1630
+
1531
1631
  /** os.hostname(), looked up once, and absent in a browser bundle. */
1532
1632
  static _hostname() {
1533
1633
  if (Window._cachedHostname === undefined) {
@@ -2363,6 +2463,42 @@ export default class Window extends Drawable {
2363
2463
  return this;
2364
2464
  }
2365
2465
 
2466
+ /**
2467
+ * Turn a WM_DELETE_WINDOW ClientMessage into the `close` event.
2468
+ *
2469
+ * Only fires when something is listening, so a window that handles the
2470
+ * raw `message` event itself — the only way to do this before `close`
2471
+ * existed — keeps behaving exactly as it did, rather than suddenly
2472
+ * acquiring a second handler that destroys it.
2473
+ */
2474
+ _emitCloseRequest(ev) {
2475
+ if (ev.format !== 32 || !this.listenerCount('close')) return;
2476
+ // node-x11 caches interned atoms per connection, so these are the ids
2477
+ // the 'close' listener's addProtocol already interned
2478
+ const X = this.X;
2479
+ if (!X.atoms.WM_PROTOCOLS || ev.message_type !== X.atoms.WM_PROTOCOLS) return;
2480
+ if (!X.atoms.WM_DELETE_WINDOW || ev.data?.[0] !== X.atoms.WM_DELETE_WINDOW) return;
2481
+
2482
+ let prevented = false;
2483
+ this.emit('close', {
2484
+ name: 'close',
2485
+ window: this,
2486
+ target: this,
2487
+ // the timestamp the window manager stamped the request with, for
2488
+ // passing back to setInputFocus and friends
2489
+ time: ev.data[1],
2490
+ preventDefault() {
2491
+ prevented = true;
2492
+ },
2493
+ get defaultPrevented() {
2494
+ return prevented;
2495
+ }
2496
+ });
2497
+ // the default action, and the reason preventDefault has to be called
2498
+ // synchronously: there is no point at which it could be awaited
2499
+ if (!prevented && !this._destroyed) this.destroy();
2500
+ }
2501
+
2366
2502
  destroy() {
2367
2503
  this._destroyed = true;
2368
2504
  this._forget();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "5.2.0",
3
+ "version": "5.3.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",