ntk 3.10.0 → 3.10.2
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/renderingcontext_2d.js +36 -1
- package/lib/widgets/markdownview.js +57 -5
- package/lib/window.js +157 -3
- package/package.json +1 -1
|
@@ -236,7 +236,14 @@ class RenderingContext2d {
|
|
|
236
236
|
|
|
237
237
|
// notify a double-buffered window that its backing content changed
|
|
238
238
|
_markDirty() {
|
|
239
|
-
if (typeof this.window._markDirty
|
|
239
|
+
if (typeof this.window._markDirty !== 'function') return;
|
|
240
|
+
// The clip bounds everything this operation could have touched, so it is
|
|
241
|
+
// also the region the window has to blit. Reporting it lets the present
|
|
242
|
+
// copy the part of the backing store that changed instead of all of it —
|
|
243
|
+
// a hover repaint of two tab headers used to blit the whole window. A
|
|
244
|
+
// context with no rectangular clip reports nothing, and the window falls
|
|
245
|
+
// back to a full blit, which is what any drawing outside a clip needs.
|
|
246
|
+
this.window._markDirty(this._clips.length ? this._clipRect() : null);
|
|
240
247
|
}
|
|
241
248
|
|
|
242
249
|
// html context2d compatibility: canvas.getContext('2d').canvas == canvas
|
|
@@ -999,6 +1006,34 @@ class RenderingContext2d {
|
|
|
999
1006
|
|
|
1000
1007
|
// rasterize one clip entry into a temp a8 and In-composite it onto the mask
|
|
1001
1008
|
_intersectClip(entry) {
|
|
1009
|
+
// Intersecting with a *rectangle* is just "keep the inside, clear the
|
|
1010
|
+
// outside", and up to four FillRectangles say that exactly — no temp
|
|
1011
|
+
// pixmap to allocate and free, no rasterization, and no full-surface
|
|
1012
|
+
// Composite. The general path below costs the whole surface per clip
|
|
1013
|
+
// however small the rectangle is, which is what made nested clipping the
|
|
1014
|
+
// dominant cost of a frame: scrolling a table clipped roughly 170 times
|
|
1015
|
+
// per repaint, and at 900x600 that alone was ~92 megapixels of work
|
|
1016
|
+
// before a single glyph was drawn.
|
|
1017
|
+
if (entry.rect) {
|
|
1018
|
+
const r = entry.rect;
|
|
1019
|
+
const x = Math.max(0, Math.min(r.x, this.width));
|
|
1020
|
+
const y = Math.max(0, Math.min(r.y, this.height));
|
|
1021
|
+
const right = Math.max(x, Math.min(r.x + r.w, this.width));
|
|
1022
|
+
const bottom = Math.max(y, Math.min(r.y + r.h, this.height));
|
|
1023
|
+
const outside = [];
|
|
1024
|
+
if (y > 0) outside.push(0, 0, this.width, y);
|
|
1025
|
+
if (bottom < this.height) {
|
|
1026
|
+
outside.push(0, bottom, this.width, this.height - bottom);
|
|
1027
|
+
}
|
|
1028
|
+
if (x > 0) outside.push(0, y, x, bottom - y);
|
|
1029
|
+
if (right < this.width) {
|
|
1030
|
+
outside.push(right, y, this.width - right, bottom - y);
|
|
1031
|
+
}
|
|
1032
|
+
if (outside.length) {
|
|
1033
|
+
this.Render.FillRectangles(this.Render.PictOp.Src, this.clipMask.id, [0, 0, 0, 0], outside);
|
|
1034
|
+
}
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1002
1037
|
const tmpPixmap = new Pixmap(this.window.app, { depth: 8, width: this.width, height: this.height });
|
|
1003
1038
|
const tmpMask = new Picture(this.window.app, { drawable: tmpPixmap, format: this.Render.a8 });
|
|
1004
1039
|
this.Render.FillRectangles(this.Render.PictOp.Src, tmpMask.id, [0, 0, 0, 0], [0, 0, this.width, this.height]);
|
|
@@ -352,14 +352,66 @@ export default class MarkdownView {
|
|
|
352
352
|
}
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
+
// Two widths per column from here on: `cols` is the outer width the grid
|
|
356
|
+
// is drawn at, `inner` is what a cell is laid out at. `inner` is kept
|
|
357
|
+
// alongside rather than derived as `cols[c] - hpad * 2`, and that is not
|
|
358
|
+
// tidiness. Adding a padding and subtracting it again is not the identity
|
|
359
|
+
// in binary floating point — for a natural width of 48.953125 at size 16
|
|
360
|
+
// the round trip returns 48.95312499999999 — and a token handed a maxWidth
|
|
361
|
+
// a few ULPs below its own measured width is *wider than its container*,
|
|
362
|
+
// so TextLayout force-breaks it. That is a `value` header rendered as
|
|
363
|
+
// `valu` over `e`, on some fonts and not others, depending on nothing more
|
|
364
|
+
// than whether the width happens to survive the round trip.
|
|
355
365
|
const cols = natural.map((w) => w + hpad * 2);
|
|
366
|
+
const inner = natural.slice();
|
|
356
367
|
const total = cols.reduce((a, b) => a + b, 0);
|
|
357
368
|
if (total > width) {
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
|
|
369
|
+
// Shrink towards each column's *min-content* width — the widest word it
|
|
370
|
+
// holds that cannot be broken — and never past it. The floor used to be
|
|
371
|
+
// a flat 2.5em guess with no relation to the content, so a column whose
|
|
372
|
+
// longest word was wider than that got a maxWidth below the word and the
|
|
373
|
+
// word came apart.
|
|
374
|
+
//
|
|
375
|
+
// Min-content is the widest whitespace-delimited token, each measured
|
|
376
|
+
// *unconstrained*. Laying the whole cell out at a tiny maxWidth looks
|
|
377
|
+
// like the obvious probe and is not one: a token wider than the
|
|
378
|
+
// container is force-broken rather than left to overflow whenever a
|
|
379
|
+
// single cluster fits, so the probe reports a fragment's width. It is
|
|
380
|
+
// not even monotonic in maxWidth — see the test that pins that. An
|
|
381
|
+
// unconstrained token cannot be broken at all.
|
|
382
|
+
//
|
|
383
|
+
// A token split across spans — `**val**ue` — is measured as its halves
|
|
384
|
+
// and so under-measured. Rare, and still far above the flat guess this
|
|
385
|
+
// replaced. Only measured here: a table that fits needs no minimums.
|
|
386
|
+
const content = new Array(ncols).fill(0);
|
|
387
|
+
for (const row of cellSpans) {
|
|
388
|
+
for (let c = 0; c < ncols; c++) {
|
|
389
|
+
for (const span of row[c]) {
|
|
390
|
+
for (const token of String(span.text ?? '').split(/\s+/)) {
|
|
391
|
+
if (!token) continue;
|
|
392
|
+
const measure = new TextLayout(fonts, [{ ...span, text: token }], styles.base, {
|
|
393
|
+
lineHeight: t.lineHeight
|
|
394
|
+
});
|
|
395
|
+
content[c] = Math.max(content[c], measure.width);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const mins = content.map((w) => w + hpad * 2);
|
|
401
|
+
// Space above the minimums is shared in proportion to how much each
|
|
402
|
+
// column asked for, so a wide column gives up more than a narrow one.
|
|
403
|
+
// When the minimums themselves do not fit, the table overflows rather
|
|
404
|
+
// than breaking words — which is what a browser does with the same
|
|
405
|
+
// content, and is the lesser of the two evils.
|
|
406
|
+
const slack = width - mins.reduce((a, b) => a + b, 0);
|
|
407
|
+
const want = cols.reduce((a, w, c) => a + Math.max(0, w - mins[c]), 0);
|
|
361
408
|
for (let c = 0; c < ncols; c++) {
|
|
362
|
-
cols[c] =
|
|
409
|
+
cols[c] =
|
|
410
|
+
slack <= 0 || want <= 0
|
|
411
|
+
? mins[c]
|
|
412
|
+
: mins[c] + (Math.max(0, cols[c] - mins[c]) / want) * slack;
|
|
413
|
+
// Never below min-content, whatever the arithmetic above produced.
|
|
414
|
+
inner[c] = Math.max(content[c], cols[c] - hpad * 2);
|
|
363
415
|
}
|
|
364
416
|
}
|
|
365
417
|
const tableW = cols.reduce((a, b) => a + b, 0);
|
|
@@ -369,7 +421,7 @@ export default class MarkdownView {
|
|
|
369
421
|
for (let r = 0; r < cellSpans.length; r++) {
|
|
370
422
|
const layouts = cellSpans[r].map((spans, c) =>
|
|
371
423
|
new TextLayout(fonts, spans.length ? spans : '', styles.base, {
|
|
372
|
-
maxWidth:
|
|
424
|
+
maxWidth: inner[c],
|
|
373
425
|
align: block.align[c] ?? 'left',
|
|
374
426
|
lineHeight: t.lineHeight
|
|
375
427
|
})
|
package/lib/window.js
CHANGED
|
@@ -6,6 +6,108 @@ import Drawable from './drawable.js';
|
|
|
6
6
|
import Pixmap from './pixmap.js';
|
|
7
7
|
import * as xevents from './events_map.js';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* How many rectangles a frame's dirty region is allowed to hold.
|
|
11
|
+
*
|
|
12
|
+
* Drawing reports one region per operation, so a repaint reports hundreds, and
|
|
13
|
+
* an uncapped list would turn every one of them into its own CopyArea. Past
|
|
14
|
+
* the cap the cheapest pair is merged (see addDirtyRect), which keeps
|
|
15
|
+
* genuinely separated clusters apart while adjacent ones coalesce.
|
|
16
|
+
*/
|
|
17
|
+
const MAX_DIRTY_RECTS = 8;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* How much of the surrounding box a split has to save to be worth making.
|
|
21
|
+
*
|
|
22
|
+
* Several rectangles are several CopyArea requests, and they only beat the one
|
|
23
|
+
* box around them when they are meaningfully smaller than it: two tab headers
|
|
24
|
+
* side by side describe nearly the same area either way, two corners of the
|
|
25
|
+
* window do not. Below this fraction the region is collapsed to its box.
|
|
26
|
+
*/
|
|
27
|
+
const SPLIT_SAVING = 0.75;
|
|
28
|
+
|
|
29
|
+
function rectArea(r) {
|
|
30
|
+
return Math.max(0, r.w) * Math.max(0, r.h);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function coversRect(outer, inner) {
|
|
34
|
+
return (
|
|
35
|
+
inner.x >= outer.x &&
|
|
36
|
+
inner.y >= outer.y &&
|
|
37
|
+
inner.x + inner.w <= outer.x + outer.w &&
|
|
38
|
+
inner.y + inner.h <= outer.y + outer.h
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function mergeRects(a, b) {
|
|
43
|
+
const x = Math.min(a.x, b.x);
|
|
44
|
+
const y = Math.min(a.y, b.y);
|
|
45
|
+
const right = Math.max(a.x + a.w, b.x + b.w);
|
|
46
|
+
const bottom = Math.max(a.y + a.h, b.y + b.h);
|
|
47
|
+
return { x, y, w: right - x, h: bottom - y };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The box around a non-empty list of rectangles. */
|
|
51
|
+
function rectsBounds(rects) {
|
|
52
|
+
let out = rects[0];
|
|
53
|
+
for (let i = 1; i < rects.length; i++) out = mergeRects(out, rects[i]);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Add one rectangle to a capped list, returning a new list.
|
|
59
|
+
*
|
|
60
|
+
* A rectangle already covered by one in the list adds nothing, which is the
|
|
61
|
+
* common case by a wide margin: consecutive operations under the same clip all
|
|
62
|
+
* report the same region. Otherwise it goes on the end, and if that overflows
|
|
63
|
+
* the cap the pair whose merge wastes the least area is merged — waste being
|
|
64
|
+
* the area the merged rectangle covers that neither of the two did, so
|
|
65
|
+
* overlapping and adjacent pairs go first and far-apart ones last.
|
|
66
|
+
*/
|
|
67
|
+
function addDirtyRect(rects, add) {
|
|
68
|
+
for (const r of rects) {
|
|
69
|
+
if (coversRect(r, add)) return rects;
|
|
70
|
+
}
|
|
71
|
+
const out = rects.filter((r) => !coversRect(add, r));
|
|
72
|
+
out.push({ x: add.x, y: add.y, w: add.w, h: add.h });
|
|
73
|
+
if (out.length <= MAX_DIRTY_RECTS) return out;
|
|
74
|
+
let bestI = 0;
|
|
75
|
+
let bestJ = 1;
|
|
76
|
+
let bestMerged = null;
|
|
77
|
+
let bestWaste = Infinity;
|
|
78
|
+
for (let i = 0; i < out.length; i++) {
|
|
79
|
+
for (let j = i + 1; j < out.length; j++) {
|
|
80
|
+
const merged = mergeRects(out[i], out[j]);
|
|
81
|
+
const waste = rectArea(merged) - rectArea(out[i]) - rectArea(out[j]);
|
|
82
|
+
if (waste < bestWaste) {
|
|
83
|
+
bestWaste = waste;
|
|
84
|
+
bestI = i;
|
|
85
|
+
bestJ = j;
|
|
86
|
+
bestMerged = merged;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
out[bestI] = bestMerged;
|
|
91
|
+
out.splice(bestJ, 1);
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Union a reported region into a frame's dirty region, where `undefined` is
|
|
97
|
+
* "nothing yet" and `null` is "unbounded".
|
|
98
|
+
*
|
|
99
|
+
* The asymmetry is the whole point: once any operation declines to say where it
|
|
100
|
+
* drew, the region cannot be trusted to bound the frame, and it has to stay
|
|
101
|
+
* unbounded no matter what is unioned into it afterwards. Getting that
|
|
102
|
+
* backwards means blitting less than was drawn, which leaves stale pixels on
|
|
103
|
+
* screen — so the absorbing state is the safe one.
|
|
104
|
+
*/
|
|
105
|
+
function unionDirty(current, add) {
|
|
106
|
+
if (current === null || add === null || add === undefined) return null;
|
|
107
|
+
if (current === undefined) return [{ x: add.x, y: add.y, w: add.w, h: add.h }];
|
|
108
|
+
return addDirtyRect(current, add);
|
|
109
|
+
}
|
|
110
|
+
|
|
9
111
|
// X window attributes forwarded verbatim from constructor args into the
|
|
10
112
|
// CreateWindow value list (node-x11 valueMask names, see x11
|
|
11
113
|
// lib/corereqs.js). Deliberately not forwarded:
|
|
@@ -397,9 +499,25 @@ export default class Window extends Drawable {
|
|
|
397
499
|
this._scheduleFrame();
|
|
398
500
|
}
|
|
399
501
|
|
|
400
|
-
|
|
401
|
-
|
|
502
|
+
/**
|
|
503
|
+
* Called by rendering contexts after each drawing operation.
|
|
504
|
+
*
|
|
505
|
+
* `bounds` is the region that operation could have touched — a context
|
|
506
|
+
* reports its clip rectangle, which by definition contains everything it
|
|
507
|
+
* drew. The regions accumulate, and the next blit copies just those instead
|
|
508
|
+
* of the whole backing store: a repaint of two tab headers is a 125x31
|
|
509
|
+
* CopyArea rather than a 1000x700 one. They accumulate as a short list of
|
|
510
|
+
* rectangles rather than one box around them all, so two repaints at
|
|
511
|
+
* opposite corners of the window no longer drag everything between them
|
|
512
|
+
* along. An operation that reports nothing, because it was not clipped,
|
|
513
|
+
* gives up the optimisation for this frame and the blit covers everything —
|
|
514
|
+
* the safe direction, and the reason this needs no cooperation from the
|
|
515
|
+
* caller to be correct.
|
|
516
|
+
*/
|
|
517
|
+
_markDirty(bounds) {
|
|
402
518
|
if (!this._backing) return;
|
|
519
|
+
if (!this._dirty) this._dirtyRegion = undefined;
|
|
520
|
+
this._dirtyRegion = unionDirty(this._dirtyRegion, bounds);
|
|
403
521
|
this._dirty = true;
|
|
404
522
|
this._backingValid = true;
|
|
405
523
|
if (this._presentScheduled) return;
|
|
@@ -591,14 +709,50 @@ export default class Window extends Drawable {
|
|
|
591
709
|
this._presentPending = false;
|
|
592
710
|
if (!this._backing) return;
|
|
593
711
|
this._dirty = false;
|
|
712
|
+
const region = this._dirtyRegion;
|
|
713
|
+
this._dirtyRegion = undefined;
|
|
594
714
|
const w = Math.min(this.width, this._backing.width);
|
|
595
715
|
const h = Math.min(this.height, this._backing.height);
|
|
716
|
+
// Clamped to both drawables: CopyArea outside either is a no-op region at
|
|
717
|
+
// best, and the backing store is grow-only so it can be larger than the
|
|
718
|
+
// window after a shrink. A rectangle that survives clamping with no area
|
|
719
|
+
// left is dropped; if that leaves nothing, nothing needs copying.
|
|
720
|
+
const clamped = [];
|
|
721
|
+
for (const rect of region ?? [{ x: 0, y: 0, w, h }]) {
|
|
722
|
+
const x0 = Math.max(0, Math.floor(rect.x));
|
|
723
|
+
const y0 = Math.max(0, Math.floor(rect.y));
|
|
724
|
+
const x1 = Math.min(w, Math.ceil(rect.x + rect.w));
|
|
725
|
+
const y1 = Math.min(h, Math.ceil(rect.y + rect.h));
|
|
726
|
+
if (x1 > x0 && y1 > y0) clamped.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
|
|
727
|
+
}
|
|
728
|
+
if (!clamped.length) return;
|
|
729
|
+
const rects = this._blitList(clamped);
|
|
596
730
|
// a paced frame can fire after the connection started closing
|
|
597
731
|
safeRelease(this.X, () => {
|
|
598
|
-
|
|
732
|
+
for (const r of rects) {
|
|
733
|
+
this.X.CopyArea(this._backing.id, this.id, this._presentGc, r.x, r.y, r.x, r.y, r.w, r.h);
|
|
734
|
+
}
|
|
599
735
|
});
|
|
600
736
|
}
|
|
601
737
|
|
|
738
|
+
/**
|
|
739
|
+
* The rectangles a present actually copies, given the ones drawing reported.
|
|
740
|
+
*
|
|
741
|
+
* The list is not used as it stands: each rectangle is a request, and a
|
|
742
|
+
* region whose pieces nearly fill the box around them is better served by
|
|
743
|
+
* one copy of the box. So the split is kept only when it saves enough of
|
|
744
|
+
* that box to pay for the extra requests, and collapsed otherwise. Both
|
|
745
|
+
* directions cover every reported pixel, so this is a cost decision and
|
|
746
|
+
* never a correctness one.
|
|
747
|
+
*/
|
|
748
|
+
_blitList(rects) {
|
|
749
|
+
if (rects.length < 2) return rects;
|
|
750
|
+
const box = rectsBounds(rects);
|
|
751
|
+
let sum = 0;
|
|
752
|
+
for (const r of rects) sum += rectArea(r);
|
|
753
|
+
return sum > rectArea(box) * SPLIT_SAVING ? [box] : rects;
|
|
754
|
+
}
|
|
755
|
+
|
|
602
756
|
map() {
|
|
603
757
|
this.X.MapWindow(this.id);
|
|
604
758
|
return this;
|