ntk 5.1.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.
@@ -1,9 +1,11 @@
1
1
  import parseFontStyle from 'canvas-fontstyle';
2
2
  import extrudePolyline from 'extrude-polyline';
3
3
 
4
+ import { safeRelease } from './cleanup.js';
4
5
  import { cssColor } from './color.js';
5
6
  import Drawable from './drawable.js';
6
7
  import { Image } from './image.js';
8
+ import { ImageData, fromStraightRgba, pixelLayout, toStraightRgba } from './imagedata.js';
7
9
  import {
8
10
  Path2D,
9
11
  flattenPath,
@@ -25,6 +27,32 @@ import { trapezoidize } from './trapezoid.js';
25
27
 
26
28
  const DEFAULT_FONT = '20px sans-serif';
27
29
 
30
+ /**
31
+ * What `drawImage` takes as a server-side source: anything that knows its own
32
+ * size and can hand over a `Picture` for this connection.
33
+ *
34
+ * `Image` is client pixels uploaded once; `Surface` is pixels the server drew
35
+ * itself. Neither is special — the contract is the two members, so a caller
36
+ * with its own cache of rendered things can satisfy it without ntk knowing
37
+ * the type. A `RenderingContext2d` deliberately does not match: its `picture`
38
+ * is a property rather than a method, and it keeps its own branch below.
39
+ */
40
+ /** a fillStyle with a single colour behind it, as opposed to a gradient or a
41
+ * caller-supplied Picture */
42
+ function isPlainColor(style) {
43
+ return typeof style === 'string' || Array.isArray(style);
44
+ }
45
+
46
+ function isPictureSource(image) {
47
+ return (
48
+ image instanceof Image ||
49
+ (image != null &&
50
+ typeof image.picture === 'function' &&
51
+ Number.isFinite(image.width) &&
52
+ Number.isFinite(image.height))
53
+ );
54
+ }
55
+
28
56
  // canvas globalCompositeOperation -> XRender PictOp name. Porter-Duff ops
29
57
  // map directly; with a clip/shape mask active the op only applies inside
30
58
  // the mask coverage (outside pixels are left untouched).
@@ -148,7 +176,9 @@ class RenderingContext2d {
148
176
  this.X = X;
149
177
 
150
178
  this.window = window;
151
- this.Render = window.app.display.Render;
179
+ this.display = window.app.display;
180
+ this.Render = this.display.Render;
181
+ this._layoutCache = null;
152
182
 
153
183
  // draw into the window's backing pixmap when it has one (double
154
184
  // buffering); re-bind when the backing pixmap is reallocated on resize
@@ -200,6 +230,7 @@ class RenderingContext2d {
200
230
  const target = this.window._backing || this.window;
201
231
  if (this._target === target) return;
202
232
  this._target = target;
233
+ this._layoutCache = null; // the new target may be a different depth
203
234
 
204
235
  if (!this._gc) {
205
236
  // one GC is enough: it stays valid for any drawable of the same
@@ -208,9 +239,16 @@ class RenderingContext2d {
208
239
  this.X.CreateGC(this._gc, target.id);
209
240
  }
210
241
  if (this.picture) this.picture.destroy();
211
- // TODO: support a8
242
+ // a8 targets are coverage, not colour: drawing into one and using the
243
+ // result as a mask is how a monochrome drawing gets rendered once and
244
+ // recoloured on every use (see Surface).
212
245
  const depth = target.depth ?? this.window.depth;
213
- const format = depth === 32 ? this.Render.rgba32 : this.Render.rgb24;
246
+ const format =
247
+ depth === 32
248
+ ? this.Render.rgba32
249
+ : depth === 8
250
+ ? this.Render.a8
251
+ : this.Render.rgb24;
214
252
  this.picture = new Picture(this.window.app, {
215
253
  drawable: target,
216
254
  format,
@@ -220,6 +258,59 @@ class RenderingContext2d {
220
258
  this._dropMasks();
221
259
  }
222
260
 
261
+ /**
262
+ * Release everything this context allocated server-side. Idempotent, and
263
+ * the context must not be drawn with afterwards.
264
+ *
265
+ * A context bound to a window normally lives as long as the window and the
266
+ * connection outlives both, which is why this went missing for so long (see
267
+ * issue #156). It matters as soon as contexts are created *dynamically* —
268
+ * one per offscreen `Surface`, say — because without it each one
269
+ * permanently costs a GC, a Picture, and a 1x1 pixmap per fill colour used.
270
+ *
271
+ * `_backgroundPicture` and `_glyphSource` are deliberately not freed here:
272
+ * both are either aliases into `_solidPictures`, already freed below, or a
273
+ * `Picture`/`CanvasGradient` the caller passed in through `fillStyle`,
274
+ * which is not ours to free.
275
+ */
276
+ destroy() {
277
+ if (this._destroyed) return;
278
+ this._destroyed = true;
279
+
280
+ this._clips = [];
281
+ this._dropMasks();
282
+ for (const gc of [this._gc, this._fillMaskGC]) {
283
+ if (!gc) continue;
284
+ safeRelease(this.X, () => {
285
+ this.X.FreeGC(gc);
286
+ this.X.ReleaseID(gc);
287
+ });
288
+ }
289
+ this._gc = this._fillMaskGC = null;
290
+
291
+ for (const picture of this._solidPictures.values()) {
292
+ picture.destroy();
293
+ picture._sourcePixmap?.destroy();
294
+ }
295
+ this._solidPictures.clear();
296
+ this._backgroundPicture = this._glyphSource = null;
297
+
298
+ if (this.picture) {
299
+ // a picture on a *window* is freed by the server with the window;
300
+ // asking again would raise BadPicture. Same rule as `_destroyed`.
301
+ if (this._target === this.window && !(this.window instanceof Pixmap)) {
302
+ this.picture.forget();
303
+ } else {
304
+ this.picture.destroy();
305
+ }
306
+ this.picture = null;
307
+ }
308
+ }
309
+
310
+ [Symbol.dispose]() {
311
+ this.destroy();
312
+ }
313
+
223
314
  _dropMasks() {
224
315
  if (this.fillMask) {
225
316
  this.fillMask.destroy();
@@ -1320,25 +1411,137 @@ class RenderingContext2d {
1320
1411
  return new CanvasGradient('conical', this, x0, y0, angle);
1321
1412
  }
1322
1413
 
1323
- createImageData(w, h) {
1324
- return {
1325
- width: w,
1326
- height: h,
1327
- data: Buffer.alloc(w * h * 4)
1328
- };
1414
+ /** the pixel layout of whatever this context currently draws into */
1415
+ get _layout() {
1416
+ const depth = this._target.depth ?? this.window.depth ?? this.display.screen[0].root_depth;
1417
+ if (!this._layoutCache || this._layoutCache.depth !== depth) {
1418
+ this._layoutCache = pixelLayout(this.display, depth);
1419
+ }
1420
+ return this._layoutCache;
1421
+ }
1422
+
1423
+ /**
1424
+ * A blank `ImageData`, or a copy of one — both canvas forms:
1425
+ *
1426
+ * createImageData(width, height)
1427
+ * createImageData(imagedata)
1428
+ */
1429
+ createImageData(a, b) {
1430
+ if (typeof a === 'number') return new ImageData(a, b);
1431
+ if (a && typeof a.width === 'number' && a.data) return new ImageData(a.width, a.height);
1432
+ throw new TypeError('createImageData: pass (width, height) or an ImageData');
1329
1433
  }
1330
1434
 
1331
- // TODO: remove, add drawImage
1332
- putImageData(data, x, y) {
1333
- // todo: use constants
1334
- this.X.PutImage(2, this._target.id, this._gc, data.width, data.height, x, y, 0, 24, data.data);
1435
+ /**
1436
+ * Write straight RGBA pixels into the drawable at `(x, y)`.
1437
+ *
1438
+ * `data` is an `ImageData` or anything shaped like one; its bytes are
1439
+ * non-premultiplied RGBA, and they are converted to the drawable's own
1440
+ * pixel layout on the way out. The optional `dirty*` rectangle limits the
1441
+ * write to part of the source, as in the canvas spec.
1442
+ */
1443
+ putImageData(data, x, y, dirtyX = 0, dirtyY = 0, dirtyWidth, dirtyHeight) {
1444
+ const { width, height } = data;
1445
+ const src = data.data;
1446
+ if (!src || src.length !== width * height * 4) {
1447
+ throw new Error(
1448
+ `putImageData: data must be ${width * height * 4} RGBA bytes for ${width}x${height}`
1449
+ );
1450
+ }
1451
+ dirtyWidth ??= width;
1452
+ dirtyHeight ??= height;
1453
+ // the spec normalises negative extents by moving the origin
1454
+ if (dirtyWidth < 0) { dirtyX += dirtyWidth; dirtyWidth = -dirtyWidth; }
1455
+ if (dirtyHeight < 0) { dirtyY += dirtyHeight; dirtyHeight = -dirtyHeight; }
1456
+ const sx = Math.max(0, dirtyX);
1457
+ const sy = Math.max(0, dirtyY);
1458
+ const sw = Math.min(width, dirtyX + dirtyWidth) - sx;
1459
+ const sh = Math.min(height, dirtyY + dirtyHeight) - sy;
1460
+ if (!(sw > 0) || !(sh > 0)) return;
1461
+
1462
+ let rgba = src;
1463
+ if (sx !== 0 || sy !== 0 || sw !== width || sh !== height) {
1464
+ const cropped = new Uint8ClampedArray(sw * sh * 4);
1465
+ for (let row = 0; row < sh; row++) {
1466
+ cropped.set(
1467
+ src.subarray((sy + row) * width * 4 + sx * 4, (sy + row) * width * 4 + (sx + sw) * 4),
1468
+ row * sw * 4
1469
+ );
1470
+ }
1471
+ rgba = cropped;
1472
+ }
1473
+
1474
+ const layout = this._layout;
1475
+ const bytes = fromStraightRgba(rgba, layout, sw, sh);
1476
+ // stay under the server's maximum request size by uploading row bands
1477
+ const stride = sw * 4;
1478
+ const maxBytes = ((this.display.max_request_length ?? 65535) - 8) * 4;
1479
+ const rowsPerBand = Math.max(1, Math.floor(maxBytes / stride));
1480
+ for (let row = 0; row < sh; row += rowsPerBand) {
1481
+ const rows = Math.min(rowsPerBand, sh - row);
1482
+ this.X.PutImage(
1483
+ 2, // ZPixmap
1484
+ this._target.id,
1485
+ this._gc,
1486
+ sw, rows,
1487
+ x + sx, y + sy + row,
1488
+ 0,
1489
+ layout.depth,
1490
+ bytes.subarray(row * stride, (row + rows) * stride)
1491
+ );
1492
+ }
1335
1493
  this._markDirty();
1336
1494
  }
1337
1495
 
1496
+ /**
1497
+ * Read pixels back as canvas `ImageData` — straight (non-premultiplied)
1498
+ * RGBA in a `Uint8ClampedArray`, exactly like the browser's.
1499
+ *
1500
+ * Reads the backing pixmap on double-buffered windows, so it is valid even
1501
+ * where the window is occluded. Returns a promise; a trailing
1502
+ * `cb(err, imageData)` is still accepted.
1503
+ *
1504
+ * The drawable's own bytes are none of those things — see
1505
+ * `lib/imagedata.js` — so this costs a pass over the pixels. `readPixels()`
1506
+ * is the way to skip that when you want the server's layout.
1507
+ */
1338
1508
  getImageData(x, y, w, h, cb) {
1339
- // reads the backing pixmap on double-buffered windows: always valid,
1340
- // even where the window is occluded on screen
1341
- this.X.GetImage(2, this._target.id, x, y, w, h, 0xffffffff, cb);
1509
+ const promise = this.readPixels(x, y, w, h).then(
1510
+ (raw) => new ImageData(toStraightRgba(raw.data, raw.layout, w, h), w, h)
1511
+ );
1512
+ if (typeof cb === 'function') {
1513
+ promise.then((data) => cb(null, data), cb);
1514
+ return undefined;
1515
+ }
1516
+ return promise;
1517
+ }
1518
+
1519
+ /**
1520
+ * Read pixels in the server's own layout, with no conversion.
1521
+ *
1522
+ * The escape hatch under `getImageData` for code that wants to hand the
1523
+ * bytes straight back to `PutImage`, feed a codec, or do its own unpacking.
1524
+ * Unlike a bare `GetImage` it says what the bytes mean:
1525
+ *
1526
+ * { width, height, data, depth, bitsPerPixel, byteOrder, masks,
1527
+ * premultiplied }
1528
+ *
1529
+ * `byteOrder` is `'lsb'` or `'msb'` and is the *server's* pixel order,
1530
+ * which is a different handshake field from the one this connection
1531
+ * speaks. `masks` gives the bit position of each channel inside a pixel
1532
+ * word, `alpha` being 0 when the drawable has no alpha channel — in which
1533
+ * case the spare byte is undefined padding, not opacity.
1534
+ *
1535
+ * @returns {Promise<object>}
1536
+ */
1537
+ readPixels(x, y, w, h) {
1538
+ const layout = this._layout;
1539
+ return new Promise((resolve, reject) => {
1540
+ this.X.GetImage(2, this._target.id, x, y, w, h, 0xffffffff, (err, img) => {
1541
+ if (err) return reject(err);
1542
+ resolve({ width: w, height: h, data: img.data, layout, ...layout });
1543
+ });
1544
+ });
1342
1545
  }
1343
1546
 
1344
1547
  /**
@@ -1354,8 +1557,127 @@ class RenderingContext2d {
1354
1557
  * RenderingContext2d, or a node-canvas-like object. Images honor the
1355
1558
  * current transform (server-side, via the picture transform).
1356
1559
  */
1560
+ /**
1561
+ * Clip and mask for a direct composite, as a bracket around it.
1562
+ *
1563
+ * A rectangular clip is something the server can do itself: two small
1564
+ * requests around the composite instead of intersecting a full-surface a8
1565
+ * mask, which costs the same on the wire and many times the pixel work.
1566
+ * This is the fast path `drawGlyphs` already has — and a renderer that
1567
+ * clips to a damage rect makes it the common case, not a rare one.
1568
+ *
1569
+ * Uniform alpha needs no surface-sized mask either: a 1x1 repeating
1570
+ * picture is the same thing to the server, with no pixel work at all.
1571
+ */
1572
+ _beginDirectComposite() {
1573
+ const rect = this.clipMask ? this._clipRect() : null;
1574
+ if (!rect) return { mask: this._compositeMask(), clipped: false, empty: false };
1575
+ if (rect.w === 0 || rect.h === 0) return { mask: 0, clipped: false, empty: true };
1576
+ this.Render.SetPictureClipRectangles(this.picture.id, 0, 0, [rect.x, rect.y, rect.w, rect.h]);
1577
+ const mask =
1578
+ this.globalAlpha >= 1 ? 0 : this.createSolidPicture(0, 0, 0, this.globalAlpha).id;
1579
+ return { mask, clipped: true, empty: false };
1580
+ }
1581
+
1582
+ _endDirectComposite(state) {
1583
+ if (!state.clipped) return;
1584
+ this.Render.SetPictureClipRectangles(this.picture.id, 0, 0, [0, 0, this.width, this.height]);
1585
+ }
1586
+
1587
+ /** The source colour for a coverage composite, with `globalAlpha` folded
1588
+ * into it. The mask slot is taken by the coverage, so the alpha has to go
1589
+ * somewhere — and a 1x1 solid is free, where a surface-sized alpha mask is
1590
+ * not. Falls back to the fill picture for gradients, which have no single
1591
+ * colour to fold into; `_drawCoverage` routes those through the scratch. */
1592
+ _coverageSource() {
1593
+ const style = this._fillStyle;
1594
+ if (this.globalAlpha >= 1 || !isPlainColor(style)) return this._backgroundPicture;
1595
+ const c = parseColor(style);
1596
+ return this.createSolidPicture(c[0], c[1], c[2], c[3] * this.globalAlpha);
1597
+ }
1598
+
1599
+ /**
1600
+ * Composite a coverage source: the a8 picture is the *mask* and the current
1601
+ * `fillStyle` is what gets painted through it.
1602
+ *
1603
+ * This is the same shape as `drawGlyphs`, and for the same reason — a
1604
+ * monochrome drawing rendered once as coverage can then be painted in any
1605
+ * colour, so a hover, a disabled state and a theme change all reuse the one
1606
+ * rendered copy instead of each needing their own.
1607
+ */
1608
+ _drawCoverage(picture, sx, sy, sw, sh, dx, dy, dw, dh, op) {
1609
+ const R = this.Render;
1610
+ const scaled = dw !== sw || dh !== sh;
1611
+ if (scaled) {
1612
+ R.SetPictureTransform(picture.id, [sw / dw, 0, sx, 0, sh / dh, sy, 0, 0, 1]);
1613
+ picture.setFilter('bilinear');
1614
+ }
1615
+ // with a transform in place the mask is already sampled from (sx, sy)
1616
+ const mx = scaled ? 0 : sx;
1617
+ const my = scaled ? 0 : sy;
1618
+
1619
+ const rect = this.clipMask ? this._clipRect() : null;
1620
+ // the mask slot can hold exactly one picture, so anything that cannot be
1621
+ // folded into the colour or handed to the server as a clip rectangle has
1622
+ // to be intersected into the scratch mask first
1623
+ const scratch =
1624
+ (this.clipMask && !rect) || (this.globalAlpha < 1 && !isPlainColor(this._fillStyle));
1625
+
1626
+ if (scratch) {
1627
+ this._ensureFillMask();
1628
+ R.FillRectangles(R.PictOp.Src, this.fillMask.id, [0, 0, 0, 0], [0, 0, this.width, this.height]);
1629
+ R.Composite(R.PictOp.Src, picture.id, 0, this.fillMask.id, mx, my, 0, 0, dx, dy, dw, dh);
1630
+ if (this.globalAlpha < 1) {
1631
+ R.Composite(
1632
+ R.PictOp.InReverse,
1633
+ this.createSolidPicture(0, 0, 0, this.globalAlpha).id,
1634
+ 0,
1635
+ this.fillMask.id,
1636
+ 0, 0, 0, 0, 0, 0,
1637
+ this.width, this.height
1638
+ );
1639
+ }
1640
+ if (this.clipMask) {
1641
+ R.Composite(R.PictOp.In, this.clipMask.id, 0, this.fillMask.id, 0, 0, 0, 0, 0, 0, this.width, this.height);
1642
+ }
1643
+ R.Composite(
1644
+ op,
1645
+ this._backgroundPicture.id,
1646
+ this.fillMask.id,
1647
+ this.picture.id,
1648
+ 0, 0, 0, 0,
1649
+ dx, dy,
1650
+ dw, dh
1651
+ );
1652
+ } else if (!rect || (rect.w > 0 && rect.h > 0)) {
1653
+ if (rect) {
1654
+ R.SetPictureClipRectangles(this.picture.id, 0, 0, [rect.x, rect.y, rect.w, rect.h]);
1655
+ }
1656
+ R.Composite(
1657
+ op,
1658
+ this._coverageSource().id,
1659
+ picture.id,
1660
+ this.picture.id,
1661
+ 0, 0,
1662
+ mx, my,
1663
+ dx, dy,
1664
+ dw, dh
1665
+ );
1666
+ if (rect) {
1667
+ R.SetPictureClipRectangles(this.picture.id, 0, 0, [0, 0, this.width, this.height]);
1668
+ }
1669
+ }
1670
+
1671
+ if (scaled) {
1672
+ // restore defaults so the cached surface stays reusable as-is
1673
+ R.SetPictureTransform(picture.id, [1, 0, 0, 0, 1, 0, 0, 0, 1]);
1674
+ picture.setFilter('nearest');
1675
+ }
1676
+ this._markDirty();
1677
+ }
1678
+
1357
1679
  drawImage(image, ...args) {
1358
- if (image instanceof Image) {
1680
+ if (isPictureSource(image)) {
1359
1681
  let sx = 0;
1360
1682
  let sy = 0;
1361
1683
  let sw = image.width;
@@ -1383,7 +1705,14 @@ class RenderingContext2d {
1383
1705
  return;
1384
1706
  }
1385
1707
 
1386
- const mask = this._compositeMask();
1708
+ if (image.format === 'a8') {
1709
+ this._drawCoverage(picture, sx, sy, sw, sh, dx, dy, dw, dh, op);
1710
+ return;
1711
+ }
1712
+
1713
+ const state = this._beginDirectComposite();
1714
+ if (state.empty) return;
1715
+ const mask = state.mask;
1387
1716
  const scaled = dw !== sw || dh !== sh;
1388
1717
  if (scaled) {
1389
1718
  // the picture transform maps composite coordinates into source
@@ -1415,6 +1744,7 @@ class RenderingContext2d {
1415
1744
  dw, dh
1416
1745
  );
1417
1746
  }
1747
+ this._endDirectComposite(state);
1418
1748
  this._markDirty();
1419
1749
  return;
1420
1750
  }
@@ -1424,7 +1754,8 @@ class RenderingContext2d {
1424
1754
  const sx = 0;
1425
1755
  const sy = 0;
1426
1756
 
1427
- // TODO: allow to draw Window, Pixmap, Picture
1757
+ // Drawables reach the branch above by way of Surface, which is what a
1758
+ // Window/Pixmap/Picture source actually needs: a size and a Picture.
1428
1759
  if (image instanceof RenderingContext2d) {
1429
1760
  // TODO: if need to scale, set transform
1430
1761
  // TODO: respect global compositing blend mode
@@ -1441,20 +1772,23 @@ class RenderingContext2d {
1441
1772
  } else if (image && image.context && typeof image.context.getImageData === 'function') {
1442
1773
  // node-canvas Canvas ( or any ctx with ctx.canvas.getImageData returning pixels)
1443
1774
  const imageData = image.context.getImageData(sx, sy, sWidth, sHeight);
1444
- // rgba -> bgra
1445
- const data = Buffer.alloc(imageData.data.length);
1446
- for (let i = 0; i < data.length; i += 4) {
1447
- data[i + 2] = imageData.data[i];
1448
- data[i + 1] = imageData.data[i + 1];
1449
- data[i] = imageData.data[i + 2];
1450
- data[i + 3] = imageData.data[i + 3]; // multiply source alpha by context globalAlpha?
1451
- }
1775
+ // node-canvas hands over straight RGBA, and the rgba32 picture below is
1776
+ // premultiplied — this used to swap the channels and stop there, which
1777
+ // composited translucent images at full brightness
1778
+ const data = fromStraightRgba(
1779
+ imageData.data,
1780
+ pixelLayout(this.display, 32),
1781
+ sWidth,
1782
+ sHeight
1783
+ );
1452
1784
 
1453
1785
  const pixmap = new Pixmap(this.window.app, { depth: 32, width: sWidth, height: sHeight });
1454
1786
  const picture = new Picture(this.window.app, { drawable: pixmap, format: this.Render.rgba32 });
1455
1787
  pixmap._gc = this.X.AllocID();
1456
1788
  this.X.CreateGC(pixmap._gc, pixmap.id);
1457
- this.X.PutImage(2, pixmap.id, pixmap._gc, sWidth, sHeight, sx, sy, 0, 32, data);
1789
+ // the crop already happened in getImageData, so it lands at the pixmap's
1790
+ // origin — sx/sy here wrote it off the edge
1791
+ this.X.PutImage(2, pixmap.id, pixmap._gc, sWidth, sHeight, 0, 0, 0, 32, data);
1458
1792
 
1459
1793
  this.Render.Composite(
1460
1794
  this.Render.PictOp.Over,
package/lib/surface.js ADDED
@@ -0,0 +1,118 @@
1
+ import Picture from './picture.js';
2
+ import Pixmap from './pixmap.js';
3
+
4
+ /**
5
+ * An offscreen drawing surface: a pixmap, its Picture, and enough of the
6
+ * `Image` shape that `ctx.drawImage` takes it as a source.
7
+ *
8
+ * const surface = new Surface(app, { width: 20, height: 20 });
9
+ * surface.render((ctx) => icon.draw(ctx, 0, 0, 20, 20));
10
+ * ctx.drawImage(surface, x, y); // one server-side composite
11
+ *
12
+ * The point is drawing something once and compositing it many times. An
13
+ * `Image` already does that for decoded PNG/JPEG pixels uploaded from the
14
+ * client; a Surface is the same contract for pixels drawn *by* the server,
15
+ * so nothing crosses the wire but the composite.
16
+ *
17
+ * ### Coverage surfaces
18
+ *
19
+ * `format: 'a8'` makes a surface that stores coverage instead of colour.
20
+ * Drawn through `drawImage` it acts as a mask for the current `fillStyle`,
21
+ * so one rendered copy of a monochrome drawing serves every colour it is
22
+ * ever asked for — the trick the glyph cache already runs on text, applied
23
+ * to arbitrary drawings. See `SvgView.paintKind` for deciding when a drawing
24
+ * qualifies.
25
+ *
26
+ * ### Lifetime
27
+ *
28
+ * `destroy()` frees the pixmap and the picture; both of those carry their own
29
+ * finalizers, so a dropped surface still releases. `render()` builds a
30
+ * context, hands it over, and
31
+ * destroys it again, which keeps the steady-state cost of a surface to two
32
+ * server objects — a context is much heavier than it looks, and holding one
33
+ * per surface is what issue #156 is about.
34
+ */
35
+ export class Surface {
36
+ constructor(app, { width, height, format = 'argb32' } = {}) {
37
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
38
+ throw new Error('Surface: width and height must be positive integers');
39
+ }
40
+ if (format !== 'argb32' && format !== 'a8') {
41
+ throw new Error(`Surface: unknown format ${JSON.stringify(format)} (argb32 or a8)`);
42
+ }
43
+ this.app = app;
44
+ this.width = width;
45
+ this.height = height;
46
+ this.format = format;
47
+ this.depth = format === 'a8' ? 8 : 32;
48
+
49
+ this.pixmap = new Pixmap(app, { depth: this.depth, width, height });
50
+ this._picture = new Picture(app, {
51
+ drawable: this.pixmap,
52
+ format: format === 'a8' ? app.display.Render.a8 : app.display.Render.rgba32
53
+ });
54
+ // a fresh pixmap's contents are undefined per the protocol, so a surface
55
+ // that is only partly drawn would otherwise composite server garbage
56
+ this.clear();
57
+ }
58
+
59
+ /** bytes of server-side storage — what a cache budgets against */
60
+ get bytes() {
61
+ return this.width * this.height * (this.depth === 8 ? 1 : 4);
62
+ }
63
+
64
+ /** The server-side Picture, on the app this surface belongs to. Mirrors
65
+ * `Image.picture(app)` so `drawImage` can take either without asking which
66
+ * it has. */
67
+ picture(app) {
68
+ if (app && app !== this.app) {
69
+ throw new Error('Surface: belongs to a different X connection');
70
+ }
71
+ return this._picture;
72
+ }
73
+
74
+ /** reset every pixel to fully transparent */
75
+ clear() {
76
+ const R = this.app.display.Render;
77
+ R.FillRectangles(R.PictOp.Src, this._picture.id, [0, 0, 0, 0], [0, 0, this.width, this.height]);
78
+ return this;
79
+ }
80
+
81
+ /**
82
+ * Draw into the surface through a 2d context, in surface-local coordinates
83
+ * where (0, 0) is its top-left corner.
84
+ *
85
+ * The context is created for the call and destroyed after it, so a surface
86
+ * kept in a cache holds no context of its own. Callers doing many draws
87
+ * into the same surface should `getContext('2d')` once and destroy it
88
+ * themselves instead.
89
+ */
90
+ render(fn) {
91
+ const ctx = this.getContext('2d');
92
+ try {
93
+ fn(ctx);
94
+ } finally {
95
+ ctx.destroy();
96
+ }
97
+ return this;
98
+ }
99
+
100
+ /** a rendering context on the backing pixmap — the caller owns it, and
101
+ * owes it a `destroy()` */
102
+ getContext(name, ...args) {
103
+ return this.pixmap.getContext(name, ...args);
104
+ }
105
+
106
+ destroy() {
107
+ if (this._destroyed) return;
108
+ this._destroyed = true;
109
+ this._picture.destroy();
110
+ this.pixmap.destroy();
111
+ }
112
+
113
+ [Symbol.dispose]() {
114
+ this.destroy();
115
+ }
116
+ }
117
+
118
+ export default Surface;