ntk 3.1.0 → 3.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.
package/lib/app.js CHANGED
@@ -1,3 +1,5 @@
1
+ import Clipboard from './clipboard.js';
2
+ import { CursorCache } from './cursor.js';
1
3
  import Pixmap from './pixmap.js';
2
4
  import FontManager from './text/fontmanager.js';
3
5
  import Window from './window.js';
@@ -18,6 +20,8 @@ export default class App {
18
20
  this.X = display.client;
19
21
  this.options = options;
20
22
  this._fonts = null;
23
+ this._clipboard = null;
24
+ this._cursors = null;
21
25
  // node-x11 emits X errors it cannot route to a request callback as
22
26
  // 'error' on the client — from inside its packet parser. With no
23
27
  // listener that emit throws and the parser never re-arms, silently
@@ -36,6 +40,18 @@ export default class App {
36
40
  return this._fonts;
37
41
  }
38
42
 
43
+ /** selection/clipboard transfer: write()/read() text (docs/clipboard.md) */
44
+ get clipboard() {
45
+ if (!this._clipboard) this._clipboard = new Clipboard(this);
46
+ return this._clipboard;
47
+ }
48
+
49
+ /** per-connection cache of X11 cursor-font cursors (see lib/cursor.js) */
50
+ get cursors() {
51
+ if (!this._cursors) this._cursors = new CursorCache(this);
52
+ return this._cursors;
53
+ }
54
+
39
55
  createWindow(args) {
40
56
  return new Window(this, args);
41
57
  }
@@ -50,6 +66,10 @@ export default class App {
50
66
 
51
67
  // flush pending requests and close the connection
52
68
  close() {
69
+ if (this._cursors) {
70
+ this._cursors.dispose();
71
+ this._cursors = null;
72
+ }
53
73
  return new Promise((resolve) => this.X.close(resolve));
54
74
  }
55
75
 
@@ -0,0 +1,317 @@
1
+ import x11 from 'x11';
2
+
3
+ import { safeRelease } from './cleanup.js';
4
+
5
+ // Clipboard (app.clipboard): ICCCM selection transfer for plain text.
6
+ //
7
+ // X has no clipboard buffer — "copy" means owning a selection atom
8
+ // (CLIPBOARD for explicit copy/paste, PRIMARY for middle-click paste) and
9
+ // answering conversion requests from whoever pastes; "paste" means asking
10
+ // the current owner to convert into a property on one of our windows. This
11
+ // module hides that dance behind write()/read() promises, using a hidden
12
+ // 1x1 never-mapped helper window as the selection endpoint.
13
+ //
14
+ // Supported targets when ntk owns a selection: TARGETS, UTF8_STRING and
15
+ // STRING (latin-1, best effort). Everything else — MULTIPLE, TIMESTAMP,
16
+ // images — is refused with SelectionNotify property None per ICCCM.
17
+ //
18
+ // Reads prefer UTF8_STRING and retry once with STRING when the owner
19
+ // refuses (old Xt/Motif apps). Incremental (INCR) transfers are supported
20
+ // on the read side, so pasting more than the server's transfer limit works.
21
+ //
22
+ // LIMITATION: INCR is NOT implemented on the write side — write() hands the
23
+ // whole payload to the server in a single ChangeProperty when a requestor
24
+ // converts. Texts approaching the server's maximum request length (~256KB
25
+ // on servers without BIG-REQUESTS, node-x11 does not negotiate it) may fail
26
+ // to paste into other applications.
27
+
28
+ const DEFAULT_TIMEOUT = 2000;
29
+
30
+ // SendEvent takes the raw 32-byte wire form of the event to deliver and
31
+ // node-x11 has no packer for outgoing events, so build SelectionNotify
32
+ // (code 31) by hand: CARD8 code, pad, CARD16 sequence (filled server-side),
33
+ // TIMESTAMP, requestor WINDOW, then selection/target/property ATOMs.
34
+ function encodeSelectionNotify(time, requestor, selection, target, property) {
35
+ const b = Buffer.alloc(32);
36
+ b[0] = 31;
37
+ b.writeUInt32LE(time >>> 0, 4);
38
+ b.writeUInt32LE(requestor >>> 0, 8);
39
+ b.writeUInt32LE(selection >>> 0, 12);
40
+ b.writeUInt32LE(target >>> 0, 16);
41
+ b.writeUInt32LE(property >>> 0, 20);
42
+ return b;
43
+ }
44
+
45
+ export default class Clipboard {
46
+ constructor(app) {
47
+ this.app = app;
48
+ this.X = app.X;
49
+ this._window = null; // hidden helper window, created on first use
50
+ this._owned = new Map(); // selection atom -> text we serve
51
+ this._atoms = null; // { TARGETS, UTF8_STRING, INCR }
52
+ this._transferProp = null; // property reads are converted into
53
+ this._ready = null;
54
+ this._readQueue = Promise.resolve();
55
+ this._onXEvent = this._onXEvent.bind(this);
56
+ }
57
+
58
+ /**
59
+ * Take ownership of a selection and serve `text` to anyone who pastes.
60
+ * Resolves once the server confirms the ownership; ownership (and the
61
+ * text) is held until another client copies or the app closes.
62
+ *
63
+ * @param {string} text
64
+ * @param {object} [options] { selection: 'CLIPBOARD' (default) or
65
+ * 'PRIMARY' (middle-click paste) — any selection atom name works }
66
+ * @returns {Promise<void>}
67
+ */
68
+ async write(text, { selection = 'CLIPBOARD' } = {}) {
69
+ await this._ensure();
70
+ const sel = await this._atom(selection);
71
+ this._owned.set(sel, String(text));
72
+ // time 0 = CurrentTime: best effort — ntk has no "last user input"
73
+ // timestamp to arbitrate ownership races with (ICCCM prefers one)
74
+ this.X.SetSelectionOwner(this._window.id, sel, 0);
75
+ // SetSelectionOwner is void: confirm via GetSelectionOwner that the
76
+ // server actually made us the owner
77
+ const owner = await new Promise((resolve, reject) =>
78
+ this.X.GetSelectionOwner(sel, (err, wid) => (err ? reject(err) : resolve(wid)))
79
+ );
80
+ if (owner !== this._window.id) {
81
+ this._owned.delete(sel);
82
+ throw new Error(`clipboard: failed to acquire ${selection} selection ownership`);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Read the current text of a selection from whoever owns it.
88
+ * Rejects when the selection has no owner, when the owner supports
89
+ * neither UTF8_STRING nor STRING, or when the owner stops responding
90
+ * (`timeout` ms, default 2000).
91
+ *
92
+ * @param {object} [options] { selection: 'CLIPBOARD' | 'PRIMARY' | ...,
93
+ * timeout: ms to wait for the owner at each protocol step }
94
+ * @returns {Promise<string>}
95
+ */
96
+ read({ selection = 'CLIPBOARD', timeout = DEFAULT_TIMEOUT } = {}) {
97
+ // serialize: concurrent reads would share the one transfer property on
98
+ // the helper window, so let each conversion finish before the next
99
+ const run = () => this._read(selection, timeout);
100
+ const result = this._readQueue.then(run, run);
101
+ this._readQueue = result.catch(() => {});
102
+ return result;
103
+ }
104
+
105
+ async _read(selection, timeout) {
106
+ await this._ensure();
107
+ const X = this.X;
108
+ const sel = await this._atom(selection);
109
+ const prop = this._transferProp;
110
+
111
+ let notify = await this._convert(sel, this._atoms.UTF8_STRING, prop, selection, timeout);
112
+ if (notify.property === 0) {
113
+ // property None: no owner, or the owner refused UTF8_STRING (old
114
+ // Xt/Motif apps) — ask again for latin-1 STRING before giving up
115
+ notify = await this._convert(sel, X.atoms.STRING, prop, selection, timeout);
116
+ }
117
+ if (notify.property === 0) {
118
+ const owner = await new Promise((resolve, reject) =>
119
+ X.GetSelectionOwner(sel, (err, wid) => (err ? reject(err) : resolve(wid)))
120
+ );
121
+ throw new Error(
122
+ owner
123
+ ? `clipboard: ${selection} selection owner refused both UTF8_STRING and STRING targets`
124
+ : `clipboard: nothing to paste — ${selection} selection has no owner`
125
+ );
126
+ }
127
+ const { type, data } = await this._fetchProperty(prop, selection, timeout);
128
+ return data.toString(type === X.atoms.STRING ? 'latin1' : 'utf8');
129
+ }
130
+
131
+ // lazily create the helper window, resolve atoms, hook selection events
132
+ _ensure() {
133
+ if (this._ready) return this._ready;
134
+ this._ready = (async () => {
135
+ // 1x1 and never mapped: selection ownership and transfer properties
136
+ // need a window id, not visible pixels. PropertyChange is in the
137
+ // creation mask so INCR chunk notifications arrive without a later
138
+ // ChangeWindowAttributes round-trip.
139
+ this._window = this.app.createWindow({
140
+ width: 1,
141
+ height: 1,
142
+ eventMask: x11.eventMask.PropertyChange
143
+ });
144
+ const [TARGETS, UTF8_STRING, INCR, transferProp] = await Promise.all([
145
+ this._atom('TARGETS'),
146
+ this._atom('UTF8_STRING'),
147
+ this._atom('INCR'),
148
+ this._atom('NTK_SELECTION')
149
+ ]);
150
+ this._atoms = { TARGETS, UTF8_STRING, INCR };
151
+ this._transferProp = transferProp;
152
+ // SelectionRequest/SelectionClear carry the owner in ev.owner, not
153
+ // ev.wid, so node-x11's event_consumers routing (keyed on ev.wid)
154
+ // never delivers them to the Window wrapper — listen on the raw
155
+ // client instead
156
+ this.X.on('event', this._onXEvent);
157
+ })();
158
+ return this._ready;
159
+ }
160
+
161
+ _atom(name) {
162
+ // predefined atoms (PRIMARY = 1, STRING = 31, ...) resolve without a
163
+ // round-trip; node-x11 caches interned ones after the first reply
164
+ return new Promise((resolve, reject) =>
165
+ this.X.InternAtom(false, name, (err, atom) => (err ? reject(err) : resolve(atom)))
166
+ );
167
+ }
168
+
169
+ _onXEvent(ev) {
170
+ if (!this._window || (ev.type !== 29 && ev.type !== 30)) return;
171
+ if (ev.owner !== this._window.id) return;
172
+ if (ev.type === 29) {
173
+ // SelectionClear: another client copied — we no longer answer for it
174
+ this._owned.delete(ev.selection);
175
+ } else {
176
+ this._onSelectionRequest(ev);
177
+ }
178
+ }
179
+
180
+ // we own the selection and somebody is pasting: write the converted data
181
+ // to the requestor's property and confirm (or refuse) via SelectionNotify
182
+ _onSelectionRequest(ev) {
183
+ const X = this.X;
184
+ const a = this._atoms;
185
+ const text = this._owned.get(ev.selection);
186
+ // obsolete requestors may pass property None — ICCCM says use the
187
+ // target atom as the property name then
188
+ let property = ev.property || ev.target;
189
+ // the whole answer runs from the packet parser's emit: if the
190
+ // connection is closing, drop it instead of throwing (see cleanup.js)
191
+ safeRelease(X, () => {
192
+ if (text === undefined) {
193
+ property = 0; // raced with a SelectionClear we haven't seen yet
194
+ } else if (ev.target === a.TARGETS) {
195
+ const data = Buffer.alloc(12);
196
+ data.writeUInt32LE(a.TARGETS, 0);
197
+ data.writeUInt32LE(a.UTF8_STRING, 4);
198
+ data.writeUInt32LE(X.atoms.STRING, 8);
199
+ X.ChangeProperty(0, ev.requestor, property, X.atoms.ATOM, 32, data);
200
+ } else if (ev.target === a.UTF8_STRING) {
201
+ X.ChangeProperty(0, ev.requestor, property, a.UTF8_STRING, 8, Buffer.from(text, 'utf8'));
202
+ } else if (ev.target === X.atoms.STRING) {
203
+ // latin-1 best effort: codepoints above U+00FF are lossy here;
204
+ // modern requestors ask for UTF8_STRING
205
+ X.ChangeProperty(0, ev.requestor, property, X.atoms.STRING, 8, Buffer.from(text, 'latin1'));
206
+ } else {
207
+ // unsupported target (MULTIPLE, TIMESTAMP, images, ...): refuse
208
+ // with property None per ICCCM
209
+ property = 0;
210
+ }
211
+ X.SendEvent(
212
+ ev.requestor,
213
+ 0,
214
+ 0,
215
+ encodeSelectionNotify(ev.time, ev.requestor, ev.selection, ev.target, property)
216
+ );
217
+ });
218
+ }
219
+
220
+ // ConvertSelection and wait for the owner's SelectionNotify answer
221
+ _convert(sel, target, prop, selectionName, timeout) {
222
+ return new Promise((resolve, reject) => {
223
+ const X = this.X;
224
+ const wid = this._window.id;
225
+ const onEvent = (ev) => {
226
+ if (ev.type !== 31 || ev.requestor !== wid || ev.selection !== sel) return;
227
+ cleanup();
228
+ resolve(ev);
229
+ };
230
+ const timer = setTimeout(() => {
231
+ cleanup();
232
+ reject(
233
+ new Error(
234
+ `clipboard: timed out after ${timeout}ms waiting for the ${selectionName} selection owner to convert`
235
+ )
236
+ );
237
+ }, timeout);
238
+ const cleanup = () => {
239
+ clearTimeout(timer);
240
+ X.removeListener('event', onEvent);
241
+ };
242
+ X.on('event', onEvent);
243
+ X.ConvertSelection(wid, sel, target, prop, 0);
244
+ });
245
+ }
246
+
247
+ _getProperty(prop) {
248
+ // delete=true: for plain transfers frees the property; for INCR chunks
249
+ // it doubles as the "send the next chunk" handshake
250
+ return new Promise((resolve, reject) =>
251
+ this.X.GetProperty(1, this._window.id, prop, 0, 0, 0x1fffffff, (err, res) =>
252
+ err ? reject(err) : resolve(res)
253
+ )
254
+ );
255
+ }
256
+
257
+ // read the converted property, following the INCR protocol when the
258
+ // owner chose an incremental transfer
259
+ async _fetchProperty(prop, selectionName, timeout) {
260
+ // collect NewValue notifications from here on: with INCR the owner's
261
+ // next chunk lands as soon as we delete the previous property, possibly
262
+ // before an await below resumes — a listener installed later would
263
+ // miss it. The helper window gets 'property' events through the normal
264
+ // pipeline (PropertyNotify carries ev.wid).
265
+ const queue = [];
266
+ let waiter = null;
267
+ const onProperty = (ev) => {
268
+ if (ev.atom !== prop || ev.state !== 0) return; // 0 = NewValue
269
+ if (waiter) {
270
+ const w = waiter;
271
+ waiter = null;
272
+ w(ev);
273
+ } else {
274
+ queue.push(ev);
275
+ }
276
+ };
277
+ this._window.on('property', onProperty);
278
+
279
+ const nextChunkNotify = () =>
280
+ new Promise((resolve, reject) => {
281
+ if (queue.length) return resolve(queue.shift());
282
+ const timer = setTimeout(() => {
283
+ waiter = null;
284
+ reject(
285
+ new Error(
286
+ `clipboard: INCR transfer of ${selectionName} selection stalled (no chunk within ${timeout}ms)`
287
+ )
288
+ );
289
+ }, timeout);
290
+ waiter = (ev) => {
291
+ clearTimeout(timer);
292
+ resolve(ev);
293
+ };
294
+ });
295
+
296
+ try {
297
+ const first = await this._getProperty(prop);
298
+ if (first.type !== this._atoms.INCR) return first;
299
+ // INCR (ICCCM 2.7.2): the property held a lower-bound byte count and
300
+ // our delete-on-read told the owner to start. Each chunk arrives as
301
+ // a NewValue PropertyNotify; reading it with delete=true requests
302
+ // the next one; a zero-length chunk ends the transfer.
303
+ const chunks = [];
304
+ let type = 0;
305
+ for (;;) {
306
+ await nextChunkNotify();
307
+ const part = await this._getProperty(prop);
308
+ if (part.data.length === 0) break;
309
+ type = part.type;
310
+ chunks.push(part.data);
311
+ }
312
+ return { type, data: Buffer.concat(chunks) };
313
+ } finally {
314
+ this._window.removeListener('property', onProperty);
315
+ }
316
+ }
317
+ }
package/lib/cursor.js ADDED
@@ -0,0 +1,101 @@
1
+ import { safeRelease } from './cleanup.js';
2
+
3
+ // Friendly (CSS-cursor-ish) name -> glyph index in the standard X11 'cursor'
4
+ // font (X11/cursorfont.h). Each cursor occupies two glyphs: the shape at the
5
+ // even index and its mask at index + 1, which is why every value here is even.
6
+ export const cursorShapes = {
7
+ default: 68, // XC_left_ptr
8
+ arrow: 68, // XC_left_ptr
9
+ text: 152, // XC_xterm
10
+ pointer: 60, // XC_hand2
11
+ hand: 60, // XC_hand2
12
+ wait: 150, // XC_watch
13
+ move: 52, // XC_fleur
14
+ crosshair: 34, // XC_crosshair
15
+ 'ew-resize': 108, // XC_sb_h_double_arrow
16
+ 'ns-resize': 116, // XC_sb_v_double_arrow
17
+ 'nwse-resize': 14, // XC_bottom_right_corner
18
+ 'nesw-resize': 12, // XC_bottom_left_corner
19
+ 'col-resize': 108, // XC_sb_h_double_arrow
20
+ 'row-resize': 116, // XC_sb_v_double_arrow
21
+ grab: 58, // XC_hand1
22
+ help: 92, // XC_question_arrow
23
+ 'not-allowed': 0 // XC_X_cursor
24
+ };
25
+
26
+ /**
27
+ * Resolve a friendly cursor name (see `cursorShapes`) or a raw cursor-font
28
+ * glyph index to the glyph index. Throws on unknown names so typos surface
29
+ * immediately instead of silently keeping the old cursor.
30
+ */
31
+ export function resolveCursorShape(nameOrShape) {
32
+ if (typeof nameOrShape === 'number') {
33
+ if (!Number.isInteger(nameOrShape) || nameOrShape < 0) {
34
+ throw new Error(`invalid cursor shape id: ${nameOrShape}`);
35
+ }
36
+ return nameOrShape;
37
+ }
38
+ const shape = cursorShapes[nameOrShape];
39
+ if (shape === undefined) {
40
+ throw new Error(
41
+ `unknown cursor name '${nameOrShape}' — valid names: ${Object.keys(cursorShapes).join(', ')}`
42
+ );
43
+ }
44
+ return shape;
45
+ }
46
+
47
+ /**
48
+ * Per-App cache of cursors created from the standard X11 'cursor' font.
49
+ * Cursors are server-side resources, so one is created per shape and reused
50
+ * for every window of the connection; `app.close()` disposes the cache.
51
+ * Accessed via `app.cursors` (lazily created) — user code normally never
52
+ * touches this directly, `wnd.setCursor(name)` goes through it.
53
+ */
54
+ export class CursorCache {
55
+ constructor(app) {
56
+ this.X = app.X;
57
+ this._font = 0; // the 'cursor' font, opened on first use
58
+ this._byShape = new Map(); // glyph index -> cursor xid
59
+ }
60
+
61
+ /** cursor xid for a friendly name or raw glyph index, creating it on first use */
62
+ get(nameOrShape) {
63
+ const shape = resolveCursorShape(nameOrShape);
64
+ const cached = this._byShape.get(shape);
65
+ if (cached) return cached;
66
+
67
+ const X = this.X;
68
+ if (!this._font) {
69
+ this._font = X.AllocID();
70
+ X.OpenFont(this._font, 'cursor');
71
+ }
72
+ const cid = X.AllocID();
73
+ // per X convention the mask glyph follows the shape glyph in the cursor
74
+ // font; black foreground on white background (RGB is 16 bit per channel)
75
+ const black = { R: 0, G: 0, B: 0 };
76
+ const white = { R: 0xffff, G: 0xffff, B: 0xffff };
77
+ X.CreateGlyphCursor(cid, this._font, this._font, shape, shape + 1, black, white);
78
+ this._byShape.set(shape, cid);
79
+ return cid;
80
+ }
81
+
82
+ /** free the server-side cursors (and the font); safe to call repeatedly */
83
+ dispose() {
84
+ const X = this.X;
85
+ for (const cid of this._byShape.values()) {
86
+ safeRelease(X, () => {
87
+ X.FreeCursor(cid);
88
+ X.ReleaseID(cid);
89
+ });
90
+ }
91
+ this._byShape.clear();
92
+ if (this._font) {
93
+ const fid = this._font;
94
+ this._font = 0;
95
+ safeRelease(X, () => {
96
+ X.CloseFont(fid);
97
+ X.ReleaseID(fid);
98
+ });
99
+ }
100
+ }
101
+ }
package/lib/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import x11 from 'x11';
2
2
 
3
3
  import App from './app.js';
4
+ import Clipboard from './clipboard.js';
5
+ import { CursorCache, cursorShapes, resolveCursorShape } from './cursor.js';
4
6
  import Window from './window.js';
5
7
  import Pixmap from './pixmap.js';
6
8
  import Picture from './picture.js';
@@ -98,7 +100,11 @@ export function createClient(options, callback) {
98
100
 
99
101
  export {
100
102
  App,
103
+ Clipboard,
101
104
  Window,
105
+ CursorCache,
106
+ cursorShapes,
107
+ resolveCursorShape,
102
108
  Pixmap,
103
109
  Picture,
104
110
  Image,
@@ -41,10 +41,86 @@ const GCO_TO_PICTOP = {
41
41
  lighter: 'Add'
42
42
  };
43
43
 
44
- // extrude-polyline has no round caps/joins; degrade to the closest shape
45
- const LINE_CAP = { butt: 'butt', square: 'square', round: 'square' };
44
+ // extrude-polyline has no round caps/joins: 'round' extrudes as butt/bevel
45
+ // and the missing coverage is unioned in afterwards as triangle-fan disks
46
+ // (see _strokePolys)
47
+ const LINE_CAP = { butt: 'butt', square: 'square', round: 'butt' };
46
48
  const LINE_JOIN = { miter: 'miter', bevel: 'bevel', round: 'bevel' };
47
49
 
50
+ /**
51
+ * Split a device-space polyline into dash "on" runs by arc length.
52
+ *
53
+ * `pts` is [[x, y], ...] with no consecutive duplicates; for closed subpaths
54
+ * the closing point is already appended, and the pattern continues around the
55
+ * loop as one uninterrupted walk. Returns null when the pattern cannot
56
+ * produce gaps (all-zero), otherwise { runs, closedLoop }: `runs` is a list
57
+ * of [[x, y], ...] open polylines (caps apply to each), `closedLoop` marks a
58
+ * closed subpath the pattern never split — stroke it closed, with no caps.
59
+ */
60
+ function dashPolyline(pts, closed, pattern, offset) {
61
+ let total = 0;
62
+ for (const d of pattern) total += d;
63
+ if (!(total > 0)) return null;
64
+
65
+ // starting phase: offset into the pattern, wrapped into [0, total)
66
+ let phase = offset % total;
67
+ if (phase < 0) phase += total;
68
+ let idx = 0;
69
+ while (phase > 0 && phase >= pattern[idx]) {
70
+ phase -= pattern[idx];
71
+ idx = (idx + 1) % pattern.length;
72
+ }
73
+
74
+ let on = idx % 2 === 0; // even entries are "on", odd are gaps
75
+ const startedOn = on;
76
+ let toggled = false;
77
+ let remain = pattern[idx] - phase;
78
+ const runs = [];
79
+ let cur = on ? [pts[0]] : null;
80
+ let x0 = pts[0][0];
81
+ let y0 = pts[0][1];
82
+
83
+ for (let i = 1; i < pts.length; i++) {
84
+ const x1 = pts[i][0];
85
+ const y1 = pts[i][1];
86
+ let len = Math.hypot(x1 - x0, y1 - y0);
87
+ while (len > remain) {
88
+ // cross a dash boundary inside this segment
89
+ const t = len > 0 ? remain / len : 0;
90
+ const bx = x0 + (x1 - x0) * t;
91
+ const by = y0 + (y1 - y0) * t;
92
+ if (on) {
93
+ cur.push([bx, by]);
94
+ runs.push(cur);
95
+ cur = null;
96
+ } else {
97
+ cur = [[bx, by]];
98
+ }
99
+ on = !on;
100
+ toggled = true;
101
+ x0 = bx;
102
+ y0 = by;
103
+ len = Math.hypot(x1 - x0, y1 - y0);
104
+ idx = (idx + 1) % pattern.length;
105
+ remain = pattern[idx];
106
+ }
107
+ remain -= len;
108
+ if (on) cur.push([x1, y1]);
109
+ x0 = x1;
110
+ y0 = y1;
111
+ }
112
+ if (cur) runs.push(cur);
113
+
114
+ if (closed && !toggled) return { runs, closedLoop: startedOn };
115
+ // closed subpath with dashes on both sides of the seam: merge the last
116
+ // run into the first so no caps appear at the seam
117
+ if (closed && startedOn && on && runs.length > 1) {
118
+ const last = runs.pop();
119
+ runs[0] = last.concat(runs[0].slice(1));
120
+ }
121
+ return { runs, closedLoop: false };
122
+ }
123
+
48
124
  function parseColor(value) {
49
125
  if (Array.isArray(value)) return value;
50
126
  const c = parseColorRaw(value);
@@ -106,6 +182,8 @@ class RenderingContext2d {
106
182
  this.lineCap = 'butt';
107
183
  this.lineJoin = 'miter';
108
184
  this.miterLimit = 10;
185
+ this._lineDash = [];
186
+ this._lineDashOffset = 0;
109
187
 
110
188
  this.fillStyle = 'white';
111
189
  this.strokeStyle = 'black';
@@ -226,6 +304,31 @@ class RenderingContext2d {
226
304
  return this._strokeStyle;
227
305
  }
228
306
 
307
+ /**
308
+ * Canvas-spec dash list: values are distances (user-space units) of
309
+ * alternating dashes and gaps. An empty list means solid; an odd-length
310
+ * list is doubled; any negative or non-finite value invalidates the whole
311
+ * call (it is ignored).
312
+ */
313
+ setLineDash(segments) {
314
+ const list = Array.from(segments ?? [], Number);
315
+ for (const v of list) if (!Number.isFinite(v) || v < 0) return;
316
+ this._lineDash = list.length % 2 ? list.concat(list) : list;
317
+ }
318
+
319
+ getLineDash() {
320
+ return this._lineDash.slice();
321
+ }
322
+
323
+ set lineDashOffset(value) {
324
+ const v = Number(value);
325
+ if (Number.isFinite(v)) this._lineDashOffset = v;
326
+ }
327
+
328
+ get lineDashOffset() {
329
+ return this._lineDashOffset;
330
+ }
331
+
229
332
  // ------------------------------------------------------------------
230
333
  // state
231
334
 
@@ -237,6 +340,8 @@ class RenderingContext2d {
237
340
  lineCap: this.lineCap,
238
341
  lineJoin: this.lineJoin,
239
342
  miterLimit: this.miterLimit,
343
+ lineDash: this._lineDash, // never mutated in place: safe to share
344
+ lineDashOffset: this._lineDashOffset,
240
345
  globalAlpha: this.globalAlpha,
241
346
  gco: this._gco,
242
347
  textStyle: this._textStyle,
@@ -257,6 +362,8 @@ class RenderingContext2d {
257
362
  this.lineCap = s.lineCap;
258
363
  this.lineJoin = s.lineJoin;
259
364
  this.miterLimit = s.miterLimit;
365
+ this._lineDash = s.lineDash;
366
+ this._lineDashOffset = s.lineDashOffset;
260
367
  this.globalAlpha = s.globalAlpha;
261
368
  this._gco = s.gco;
262
369
  this._textStyle = s.textStyle;
@@ -480,15 +587,88 @@ class RenderingContext2d {
480
587
  if (this.globalAlpha <= 0) return;
481
588
  // approximate transform-aware line width by the average scale factor
482
589
  const det = this._m[0] * this._m[3] - this._m[1] * this._m[2];
483
- const thickness = this.lineWidth * (Math.sqrt(Math.abs(det)) || 1);
590
+ const scale = Math.sqrt(Math.abs(det)) || 1;
591
+ const thickness = this.lineWidth * scale;
592
+ const roundCap = this.lineCap === 'round';
593
+ const roundJoin = this.lineJoin === 'round';
484
594
  const stroke = extrudePolyline({
485
595
  thickness,
486
596
  cap: LINE_CAP[this.lineCap] || 'butt',
487
597
  join: LINE_JOIN[this.lineJoin] || 'miter',
488
598
  miterLimit: this.miterLimit
489
599
  });
600
+ // dash distances are user-space lengths; scale them like the line width
601
+ const dash = this._lineDash.length ? this._lineDash.map((d) => d * scale) : null;
602
+ const dashOffset = this._lineDashOffset * scale;
490
603
 
491
604
  const tris = [];
605
+ // round caps/joins: extrude-polyline extrudes them as butt/bevel (see
606
+ // LINE_CAP/LINE_JOIN) and we union triangle-fan disks of radius
607
+ // lineWidth/2 on top — a full disk at an endpoint is exactly a round
608
+ // cap, and a disk at an interior vertex fills the bevel notch
609
+ let hasRound = false;
610
+ const r = thickness / 2;
611
+ const diskSegs = Math.max(8, Math.min(32, Math.ceil(r * 2)));
612
+ const addDisk = (x, y) => {
613
+ hasRound = true;
614
+ let ex = x + r;
615
+ let ey = y;
616
+ for (let i = 1; i <= diskSegs; i++) {
617
+ const a = (i / diskSegs) * 2 * Math.PI;
618
+ const nx = x + r * Math.cos(a);
619
+ const ny = y + r * Math.sin(a);
620
+ tris.push(x, y, ex, ey, nx, ny);
621
+ ex = nx;
622
+ ey = ny;
623
+ }
624
+ };
625
+ // join disk at b (between a->b and b->c), but only where the bevel
626
+ // notch is visible — flattened curves have many near-collinear vertices
627
+ const maybeJoinDisk = (a, b, c) => {
628
+ const l1 = Math.hypot(b[0] - a[0], b[1] - a[1]);
629
+ const l2 = Math.hypot(c[0] - b[0], c[1] - b[1]);
630
+ if (!l1 || !l2) return;
631
+ let dot = ((b[0] - a[0]) * (c[0] - b[0]) + (b[1] - a[1]) * (c[1] - b[1])) / (l1 * l2);
632
+ dot = Math.max(-1, Math.min(1, dot));
633
+ // bevel-to-arc gap depth for turn angle θ: r * (1 - cos(θ/2))
634
+ if (r * (1 - Math.sqrt((1 + dot) / 2)) > 0.05) addDisk(b[0], b[1]);
635
+ };
636
+ // one polyline through extrusion + round-geometry post-processing;
637
+ // closed loops carry the seam point at both ends and get no caps
638
+ const extrudeRun = (pts, closed) => {
639
+ if (pts.length === 1) {
640
+ // degenerate (zero-length dash): with round caps this is a dot
641
+ if (roundCap) addDisk(pts[0][0], pts[0][1]);
642
+ return;
643
+ }
644
+ if (pts.length < 2) return;
645
+ const mesh = stroke.build(pts);
646
+ for (const tri of mesh.cells) {
647
+ for (let i = 0; i < 3; ++i) {
648
+ tris.push(mesh.positions[tri[i]][0], mesh.positions[tri[i]][1]);
649
+ }
650
+ }
651
+ if (roundJoin) {
652
+ for (let i = 1; i < pts.length - 1; i++) maybeJoinDisk(pts[i - 1], pts[i], pts[i + 1]);
653
+ // the seam of a closed loop is a join too (pts[0] === pts[last])
654
+ if (closed && pts.length > 2) maybeJoinDisk(pts[pts.length - 2], pts[0], pts[1]);
655
+ }
656
+ if (roundCap && !closed) {
657
+ addDisk(pts[0][0], pts[0][1]);
658
+ addDisk(pts[pts.length - 1][0], pts[pts.length - 1][1]);
659
+ }
660
+ };
661
+ // dash boundaries can duplicate run endpoints; collapse them
662
+ const cleanRun = (run) => {
663
+ const out = [run[0]];
664
+ for (let i = 1; i < run.length; i++) {
665
+ const p = run[i];
666
+ const q = out[out.length - 1];
667
+ if (Math.abs(p[0] - q[0]) > 1e-6 || Math.abs(p[1] - q[1]) > 1e-6) out.push(p);
668
+ }
669
+ return out;
670
+ };
671
+
492
672
  for (const poly of polys) {
493
673
  // drop consecutive (near-)duplicate points — zero-length segments make
494
674
  // extrude-polyline emit NaN joins that turn into spikes at the origin
@@ -508,17 +688,27 @@ class RenderingContext2d {
508
688
  if (Math.abs(fx - lx) > 1e-6 || Math.abs(fy - ly) > 1e-6) pts.push([fx, fy]);
509
689
  }
510
690
  if (pts.length < 2) continue;
511
- const mesh = stroke.build(pts);
512
- for (const tri of mesh.cells) {
513
- for (let i = 0; i < 3; ++i) {
514
- tris.push(mesh.positions[tri[i]][0], mesh.positions[tri[i]][1]);
515
- }
691
+
692
+ if (!dash) {
693
+ extrudeRun(pts, poly.closed);
694
+ continue;
695
+ }
696
+ const dashed = dashPolyline(pts, poly.closed, dash, dashOffset);
697
+ if (!dashed) {
698
+ extrudeRun(pts, poly.closed);
699
+ } else if (dashed.closedLoop) {
700
+ extrudeRun(pts, true);
701
+ } else {
702
+ for (const run of dashed.runs) extrudeRun(cleanRun(run), false);
516
703
  }
517
704
  }
518
705
  if (!tris.length) return;
519
706
 
520
707
  const op = this._op();
521
- const direct = this.globalAlpha >= 1 && !this.clipMask && op === this.Render.PictOp.Over;
708
+ // round-cap/join disks overlap the stroke body; overlapping coverage
709
+ // must accumulate in the clamped a8 mask (single composite) or a
710
+ // semi-transparent stroke style would double-blend at the overlaps
711
+ const direct = !hasRound && this.globalAlpha >= 1 && !this.clipMask && op === this.Render.PictOp.Over;
522
712
  const chunk = 4000 * 6;
523
713
  if (direct) {
524
714
  for (let i = 0; i < tris.length; i += chunk) {
@@ -30,8 +30,13 @@ function isWsGlyph(g) {
30
30
  * - `direction` — 'ltr' | 'rtl' | 'auto' base paragraph direction
31
31
  *
32
32
  * The result is inspectable before/without drawing: `width`, `height`, and
33
- * `lines[] = { x, y, baseline, width, ascent, descent, runs }` with
34
- * `runs[] = { x, width, run, span }` in visual order.
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).
36
+ *
37
+ * `caretPosition(index)` / `indexAt(x, y)` map logical code-point indices
38
+ * to visual caret geometry and back (bidi/ligature/trailing-whitespace
39
+ * aware) — see docs/text.md.
35
40
  */
36
41
  export class TextLayout {
37
42
  constructor(fonts, content, style = {}, options = {}) {
@@ -59,6 +64,8 @@ export class TextLayout {
59
64
  });
60
65
 
61
66
  const text = spans.map((s) => s.text).join('');
67
+ this._text = text;
68
+ this._cpOffsets = null; // lazy code-point index -> code-unit offset table
62
69
  const emb = embeddingLevels(text, options.direction);
63
70
  const levels = emb.levels;
64
71
  this.baseLevel = emb.paragraphs.length ? emb.paragraphs[0].level & 1 : 0;
@@ -139,16 +146,31 @@ export class TextLayout {
139
146
  let layoutWidth = 0;
140
147
 
141
148
  for (const toks of lineTokens) {
142
- // entries carry .level so reorderRuns can order them (UAX#9 L2)
149
+ // entries carry .level so reorderRuns can order them (UAX#9 L2);
150
+ // .start/.end are absolute code-unit ranges into the full text, kept
151
+ // for caret positioning (caretPosition / indexAt)
143
152
  let entries = [];
144
153
  for (const token of toks) {
145
154
  for (const frag of token.fragments) {
146
155
  for (const run of frag.shaped.runs) {
147
- entries.push({ run, span: frag.span, level: run.level });
156
+ entries.push({
157
+ run,
158
+ span: frag.span,
159
+ level: run.level,
160
+ start: frag.start + run.start,
161
+ end: frag.start + run.end
162
+ });
148
163
  }
149
164
  }
150
165
  }
151
- stripTrailingWhitespace(entries);
166
+ const lineStart = toks[0].start;
167
+ const lineEnd = toks[toks.length - 1].end;
168
+ const trailing = stripTrailingWhitespace(entries);
169
+ const contentEnd = trailing
170
+ ? trailing.start
171
+ : entries.length
172
+ ? entries[entries.length - 1].end
173
+ : lineStart;
152
174
  entries = reorderRuns(entries);
153
175
 
154
176
  let ascent = 0;
@@ -157,7 +179,7 @@ export class TextLayout {
157
179
  const runs = [];
158
180
  let x = 0;
159
181
  for (const e of entries) {
160
- runs.push({ x, width: e.run.width, run: e.run, span: e.span });
182
+ runs.push({ x, width: e.run.width, run: e.run, span: e.span, start: e.start, end: e.end });
161
183
  x += e.run.width;
162
184
  const m = e.run.font.metrics(e.run.size);
163
185
  if (m.ascent > ascent) ascent = m.ascent;
@@ -172,7 +194,19 @@ export class TextLayout {
172
194
  natural = m.lineHeight;
173
195
  }
174
196
  if (x > layoutWidth) layoutWidth = x;
175
- this.lines.push({ x: 0, y, baseline: y + ascent, width: x, ascent, descent, runs });
197
+ this.lines.push({
198
+ x: 0,
199
+ y,
200
+ baseline: y + ascent,
201
+ width: x,
202
+ ascent,
203
+ descent,
204
+ runs,
205
+ start: lineStart,
206
+ end: lineEnd,
207
+ _contentEnd: contentEnd,
208
+ _trailing: trailing
209
+ });
176
210
  y += natural * lineHeightMul;
177
211
  }
178
212
 
@@ -204,7 +238,7 @@ export class TextLayout {
204
238
  if (fragText.length > 0) {
205
239
  const fragLevels = normalizedLevels(levels, pos, pos + fragText.length);
206
240
  const shaped = this.fonts._shapeCached(fragText, span, fragLevels);
207
- fragments.push({ text: fragText, span, shaped });
241
+ fragments.push({ text: fragText, span, shaped, start: pos });
208
242
  width += shaped.width;
209
243
  }
210
244
  pos = fragEnd;
@@ -219,7 +253,7 @@ export class TextLayout {
219
253
  wsWidth = m[0].length * spaceGlyph.advanceWidth * last.span.font.scale(last.span.size);
220
254
  }
221
255
  }
222
- return { fragments, width, wsWidth, required };
256
+ return { fragments, width, wsWidth, required, start, end };
223
257
  }
224
258
 
225
259
  // split an over-wide token at the widest cluster boundary that fits
@@ -249,7 +283,9 @@ export class TextLayout {
249
283
  hi = mid - 1;
250
284
  }
251
285
  }
252
- if (best) headFrags.push({ text: best.text, span: frag.span, shaped: best.shaped });
286
+ if (best) {
287
+ headFrags.push({ text: best.text, span: frag.span, shaped: best.shaped, start: frag.start });
288
+ }
253
289
 
254
290
  const restFrags = [];
255
291
  const restText = frag.text.slice(best ? best.len : 0);
@@ -257,26 +293,30 @@ export class TextLayout {
257
293
  restFrags.push({
258
294
  text: restText,
259
295
  span: frag.span,
260
- shaped: this.fonts._shapeCached(restText, frag.span, '0')
296
+ shaped: this.fonts._shapeCached(restText, frag.span, '0'),
297
+ start: frag.start + (best ? best.len : 0)
261
298
  });
262
299
  }
263
300
  restFrags.push(...token.fragments.slice(i + 1));
264
301
  const sum = (frags) => frags.reduce((w, f) => w + f.shaped.width, 0);
302
+ const splitAt = restFrags.length ? restFrags[0].start : token.end;
265
303
  const head = headFrags.length
266
- ? { fragments: headFrags, width: sum(headFrags), wsWidth: 0, required: false }
304
+ ? { fragments: headFrags, width: sum(headFrags), wsWidth: 0, required: false, start: token.start, end: splitAt }
267
305
  : null;
268
306
  const rest = {
269
307
  fragments: restFrags,
270
308
  width: sum(restFrags),
271
309
  wsWidth: token.wsWidth,
272
- required: token.required
310
+ required: token.required,
311
+ start: splitAt,
312
+ end: token.end
273
313
  };
274
314
  return [head, rest];
275
315
  }
276
316
  // everything fit after all (float rounding): no split needed
277
317
  return [
278
318
  { ...token, required: false },
279
- { fragments: [], width: 0, wsWidth: 0, required: token.required }
319
+ { fragments: [], width: 0, wsWidth: 0, required: token.required, start: token.end, end: token.end }
280
320
  ];
281
321
  }
282
322
 
@@ -308,11 +348,283 @@ export class TextLayout {
308
348
  ctx._markDirty();
309
349
  return this;
310
350
  }
351
+
352
+ // ---- caret positioning / hit testing ----------------------------------
353
+ //
354
+ // Both methods speak logical **code-point** indices (what you get from
355
+ // `Array.from(text)` / caret arithmetic on code points), converted
356
+ // internally to the code-unit ranges the shaped runs carry.
357
+ //
358
+ // Conventions (v1):
359
+ // - Direction boundaries: a single caret, placed at the trailing edge of
360
+ // the character logically before the index (the run containing the
361
+ // previous character wins). At a line start the leading edge of the
362
+ // run containing the index is used. Indices on both sides of a
363
+ // direction boundary may therefore map to the same visual x.
364
+ // - Ligature/cluster interior indices interpolate proportionally (by
365
+ // code-point count) across the cluster's advance.
366
+ // - An index just after a hard break belongs to the next line; the index
367
+ // of the break character itself sits at the end of its line. An index
368
+ // at a soft wrap boundary belongs to the start of the wrapped line.
369
+ // - Trailing whitespace stripped from a line end still advances the
370
+ // caret, extending past the line edge on the paragraph-direction side.
371
+
372
+ /** lazy code-point index -> code-unit offset table (n + 1 entries) */
373
+ _offsets() {
374
+ if (!this._cpOffsets) {
375
+ const offs = [];
376
+ let cu = 0;
377
+ for (const ch of this._text) {
378
+ offs.push(cu);
379
+ cu += ch.length;
380
+ }
381
+ offs.push(cu);
382
+ this._cpOffsets = offs;
383
+ }
384
+ return this._cpOffsets;
385
+ }
386
+
387
+ /** code-unit offset -> code-point index (binary search) */
388
+ _cpOf(cu) {
389
+ const offs = this._offsets();
390
+ let lo = 0;
391
+ let hi = offs.length - 1;
392
+ while (lo < hi) {
393
+ const mid = (lo + hi + 1) >> 1;
394
+ if (offs[mid] <= cu) lo = mid;
395
+ else hi = mid - 1;
396
+ }
397
+ return lo;
398
+ }
399
+
400
+ /**
401
+ * Visual caret geometry for a logical code-point index in
402
+ * `[0, codePointCount]` (out-of-range indices clamp).
403
+ *
404
+ * @returns {{ x, y, height, line }} `x` is the caret's visual x within
405
+ * the layout box (alignment included), `y` the top of the line box,
406
+ * `height` = ascent + descent, `line` the line index.
407
+ */
408
+ caretPosition(index) {
409
+ const offs = this._offsets();
410
+ const n = offs.length - 1;
411
+ const i = Math.max(0, Math.min(Math.floor(index), n));
412
+ const cu = offs[i];
413
+ let li = 0;
414
+ for (let l = this.lines.length - 1; l >= 0; l--) {
415
+ if (cu >= this.lines[l].start) {
416
+ li = l;
417
+ break;
418
+ }
419
+ }
420
+ const line = this.lines[li];
421
+ return {
422
+ x: this._caretXInLine(line, cu),
423
+ y: line.y,
424
+ height: line.ascent + line.descent,
425
+ line: li
426
+ };
427
+ }
428
+
429
+ /**
430
+ * Hit test: the logical code-point index of the caret boundary closest
431
+ * to layout-box coordinates (x, y). Picks the line by y (clamping above
432
+ * the first / below the last line), then the nearest boundary by x in
433
+ * visual order — a click past the midpoint of a cluster snaps to its far
434
+ * edge. Inverse of `caretPosition` up to bidi boundary ambiguity.
435
+ */
436
+ indexAt(x, y) {
437
+ const lines = this.lines;
438
+ if (lines.length === 0) return 0;
439
+ let li = lines.length - 1;
440
+ for (let l = 0; l < lines.length; l++) {
441
+ const bottom = l + 1 < lines.length ? lines[l + 1].y : Infinity;
442
+ if (y < bottom) {
443
+ li = l;
444
+ break;
445
+ }
446
+ }
447
+ const line = lines[li];
448
+ const lx = x - line.x;
449
+ const t = line._trailing;
450
+ const rightSide = !(this.baseLevel & 1);
451
+ if (t && (rightSide ? lx > line.width : lx < 0)) {
452
+ // in the stripped trailing-whitespace zone past the line edge
453
+ const dist = rightSide ? lx - line.width : -lx;
454
+ let pos = t.start;
455
+ let edge = 0;
456
+ for (const g of t.glyphs) {
457
+ if (dist < edge + g.ax / 2) return this._cpOf(pos);
458
+ edge += g.ax;
459
+ pos += g.len;
460
+ }
461
+ return this._cpOf(pos);
462
+ }
463
+ if (line.runs.length === 0) return this._cpOf(line.start);
464
+ const cx = Math.max(0, Math.min(lx, line.width));
465
+ let target = line.runs[line.runs.length - 1];
466
+ for (const r of line.runs) {
467
+ if (cx <= r.x + r.width) {
468
+ target = r;
469
+ break;
470
+ }
471
+ }
472
+ return this._cpOf(this._runIndexAt(target, cx));
473
+ }
474
+
475
+ /** caret x (layout-box coords) for an absolute code-unit offset on `line` */
476
+ _caretXInLine(line, cu) {
477
+ const t = line._trailing;
478
+ if (t && cu > t.start) {
479
+ // inside (or past) the stripped trailing whitespace: extend beyond
480
+ // the visual line edge on the paragraph-direction side
481
+ let adv = 0;
482
+ let pos = t.start;
483
+ for (const g of t.glyphs) {
484
+ if (cu >= pos + g.len) {
485
+ adv += g.ax;
486
+ pos += g.len;
487
+ } else {
488
+ if (cu > pos) adv += (g.ax * (cu - pos)) / g.len;
489
+ break;
490
+ }
491
+ }
492
+ return this.baseLevel & 1 ? line.x - adv : line.x + line.width + adv;
493
+ }
494
+ if (line.runs.length === 0) return line.x;
495
+ const cc = Math.min(cu, line._contentEnd);
496
+ // previous-character rule: the run containing the char before the index
497
+ let target = null;
498
+ if (cc > line.start) {
499
+ for (const r of line.runs) {
500
+ if (r.start < cc && cc <= r.end) {
501
+ target = r;
502
+ break;
503
+ }
504
+ }
505
+ }
506
+ if (!target) {
507
+ for (const r of line.runs) {
508
+ if (r.start <= cc && cc < r.end) {
509
+ target = r;
510
+ break;
511
+ }
512
+ }
513
+ }
514
+ if (!target) target = line.runs[this.baseLevel & 1 ? line.runs.length - 1 : 0];
515
+ return line.x + this._boundaryX(target, cc);
516
+ }
517
+
518
+ /** x of logical boundary `cu` within a positioned line run (run-local + run.x) */
519
+ _boundaryX(lineRun, cu) {
520
+ const { run, start, end } = lineRun;
521
+ let x = lineRun.x;
522
+ if (run.direction === 'rtl') {
523
+ // glyphs stored in visual order; leftmost glyph is logically last
524
+ let pos = end;
525
+ for (const g of run.glyphs) {
526
+ const len = cuLength(g.codePoints);
527
+ if (len === 0) {
528
+ x += g.ax;
529
+ continue;
530
+ }
531
+ if (cu >= pos) return x;
532
+ if (cu > pos - len) {
533
+ // fraction of the cluster left of the caret = code points after cu
534
+ const cps = g.codePoints;
535
+ let k = 0;
536
+ let c = pos;
537
+ for (let j = cps.length - 1; j >= 0; j--) {
538
+ const l = cps[j] > 0xffff ? 2 : 1;
539
+ if (c - l < cu) break;
540
+ c -= l;
541
+ k++;
542
+ }
543
+ return x + (g.ax * k) / cps.length;
544
+ }
545
+ pos -= len;
546
+ x += g.ax;
547
+ }
548
+ return x;
549
+ }
550
+ let pos = start;
551
+ for (const g of run.glyphs) {
552
+ const len = cuLength(g.codePoints);
553
+ if (len === 0) {
554
+ x += g.ax;
555
+ continue;
556
+ }
557
+ if (cu <= pos) return x;
558
+ if (cu < pos + len) {
559
+ const cps = g.codePoints;
560
+ let k = 0;
561
+ let c = pos;
562
+ for (const cp of cps) {
563
+ const l = cp > 0xffff ? 2 : 1;
564
+ if (c + l > cu) break;
565
+ c += l;
566
+ k++;
567
+ }
568
+ return x + (g.ax * k) / cps.length;
569
+ }
570
+ pos += len;
571
+ x += g.ax;
572
+ }
573
+ return x;
574
+ }
575
+
576
+ /** nearest caret boundary (code units) to line-local x within a line run */
577
+ _runIndexAt(lineRun, lx) {
578
+ const { run, start, end } = lineRun;
579
+ const rtl = run.direction === 'rtl';
580
+ let pos = rtl ? end : start;
581
+ let gx = lineRun.x;
582
+ for (const g of run.glyphs) {
583
+ const len = cuLength(g.codePoints);
584
+ if (len === 0 || g.ax <= 0) {
585
+ gx += g.ax;
586
+ if (g.ax <= 0) pos += rtl ? -len : len;
587
+ continue;
588
+ }
589
+ if (lx <= gx + g.ax) {
590
+ const cps = g.codePoints;
591
+ let k = Math.round(((lx - gx) / g.ax) * cps.length);
592
+ k = Math.max(0, Math.min(k, cps.length));
593
+ // move k code points into the cluster from its left edge
594
+ let c = pos;
595
+ if (rtl) {
596
+ for (let j = cps.length - 1; j >= cps.length - k; j--) {
597
+ c -= cps[j] > 0xffff ? 2 : 1;
598
+ }
599
+ } else {
600
+ for (let j = 0; j < k; j++) c += cps[j] > 0xffff ? 2 : 1;
601
+ }
602
+ return c;
603
+ }
604
+ pos += rtl ? -len : len;
605
+ gx += g.ax;
606
+ }
607
+ return pos;
608
+ }
609
+ }
610
+
611
+ // UTF-16 length of a glyph cluster's codePoints array
612
+ function cuLength(codePoints) {
613
+ let len = 0;
614
+ for (const cp of codePoints) len += cp > 0xffff ? 2 : 1;
615
+ return len;
311
616
  }
312
617
 
313
618
  // Drop trailing whitespace glyphs from the logical end of a line.
314
619
  // Shaped runs are shared via the shaping cache — clone instead of mutating.
620
+ // Returns what was stripped — `{ start, glyphs: [{ ax, len }] }` in logical
621
+ // order (absolute code-unit start, per-glyph advance and code-unit length) —
622
+ // so caret positioning can still walk through trailing spaces, or null when
623
+ // nothing was stripped. Adjusts the surviving entries' logical `end`.
315
624
  function stripTrailingWhitespace(entries) {
625
+ const stripped = [];
626
+ let strippedStart = null;
627
+ const result = () => (stripped.length ? { start: strippedStart, glyphs: stripped } : null);
316
628
  for (let i = entries.length - 1; i >= 0; i--) {
317
629
  const run = entries[i].run;
318
630
  // rtl runs store glyphs in visual order: their logical end is index 0
@@ -325,21 +637,35 @@ function stripTrailingWhitespace(entries) {
325
637
  count++;
326
638
  wsWidth += g.ax;
327
639
  }
328
- if (count === 0) return;
640
+ if (count === 0) return result();
641
+ // stripped glyphs in logical order (rtl glyph storage is reversed)
642
+ const tail = fromFront
643
+ ? run.glyphs.slice(0, count).reverse()
644
+ : run.glyphs.slice(run.glyphs.length - count);
645
+ let cuStripped = 0;
646
+ const info = tail.map((g) => {
647
+ const len = cuLength(g.codePoints);
648
+ cuStripped += len;
649
+ return { ax: g.ax, len };
650
+ });
651
+ stripped.unshift(...info);
652
+ strippedStart = entries[i].end - cuStripped;
329
653
  if (count === run.glyphs.length) {
330
654
  entries.splice(i, 1);
331
655
  continue;
332
656
  }
333
657
  entries[i] = {
334
658
  ...entries[i],
659
+ end: entries[i].end - cuStripped,
335
660
  run: {
336
661
  ...run,
337
662
  glyphs: fromFront ? run.glyphs.slice(count) : run.glyphs.slice(0, -count),
338
663
  width: run.width - wsWidth
339
664
  }
340
665
  };
341
- return;
666
+ return result();
342
667
  }
668
+ return result();
343
669
  }
344
670
 
345
671
  // compact levels key for the shaping cache: single char when uniform
package/lib/window.js CHANGED
@@ -577,25 +577,52 @@ export default class Window extends Drawable {
577
577
  const wid = this.id;
578
578
  // legacy WM_NAME is latin-1 only (node-x11 encodes plain strings as
579
579
  // latin1) — kept for old window managers
580
- X.ChangeProperty(0, wid, X.atoms.WM_NAME, X.atoms.STRING, 8, title);
580
+ safeRelease(X, () => {
581
+ X.ChangeProperty(0, wid, X.atoms.WM_NAME, X.atoms.STRING, 8, title);
582
+ });
581
583
  // modern WMs prefer the EWMH _NET_WM_NAME property, which is UTF-8;
582
584
  // interned atoms are cached by node-x11, so this round-trips only once.
583
585
  // The lookups are async: by the time they resolve the window may be
584
586
  // destroyed (ChangeProperty would BadWindow) or retitled (an older
585
587
  // write would clobber a newer one) — the serial/destroyed guards drop
586
- // the stale write in both cases.
588
+ // the stale write in both cases. Every request in the chain is issued
589
+ // via safeRelease: replies drain while the connection is closing
590
+ // (app.close() pings before terminating), and a follow-up request from
591
+ // inside a reply callback would otherwise throw 'client is in closing
592
+ // state' from the stream read handler, where no user code can catch it.
587
593
  const serial = ++this._titleSerial;
588
- X.InternAtom(false, '_NET_WM_NAME', (err, netWmName) => {
589
- X.InternAtom(false, 'UTF8_STRING', (err2, utf8String) => {
590
- if (err || err2 || this._destroyed || serial !== this._titleSerial) return;
594
+ safeRelease(X, () => {
595
+ X.InternAtom(false, '_NET_WM_NAME', (err, netWmName) => {
596
+ if (err) return;
591
597
  safeRelease(X, () => {
592
- X.ChangeProperty(0, wid, netWmName, utf8String, 8, Buffer.from(title, 'utf8'));
598
+ X.InternAtom(false, 'UTF8_STRING', (err2, utf8String) => {
599
+ if (err2 || this._destroyed || serial !== this._titleSerial) return;
600
+ safeRelease(X, () => {
601
+ X.ChangeProperty(0, wid, netWmName, utf8String, 8, Buffer.from(title, 'utf8'));
602
+ });
603
+ });
593
604
  });
594
605
  });
595
606
  });
596
607
  return this;
597
608
  }
598
609
 
610
+ /**
611
+ * Set the mouse cursor shown over this window. Accepts a friendly name
612
+ * ('text', 'pointer', 'wait', ... — see cursorShapes in lib/cursor.js and
613
+ * docs/window.md) or a raw X11 cursor-font glyph index. Cursors are
614
+ * created once per connection and cached on the app. `setCursor(null)`
615
+ * restores the parent window's cursor (X cursor = None). Throws
616
+ * synchronously on unknown names.
617
+ */
618
+ setCursor(nameOrShape) {
619
+ const cursor = nameOrShape == null ? 0 : this.app.cursors.get(nameOrShape);
620
+ safeRelease(this.X, () => {
621
+ this.X.ChangeWindowAttributes(this.id, { cursor }, () => {});
622
+ });
623
+ return this;
624
+ }
625
+
599
626
  setMouseHintOnly(isOn) {
600
627
  if (isOn && !(this.eventMask & x11.eventMask.PointerMotionHint)) {
601
628
  this.eventMask |= x11.eventMask.PointerMotionHint;
@@ -659,12 +686,22 @@ export default class Window extends Drawable {
659
686
  setActions() {
660
687
  const X = this.X;
661
688
  const wid = this.id;
662
- X.InternAtom(false, 'WM_PROTOCOLS', (err, WM_PROTOCOLS) => {
663
- X.InternAtom(false, 'WM_DELETE_WINDOW', (err2, WM_DELETE_WINDOW) => {
664
- if (err || err2) return;
665
- const data = Buffer.alloc(4);
666
- data.writeUInt32LE(WM_DELETE_WINDOW, 0);
667
- X.ChangeProperty(0, wid, WM_PROTOCOLS, X.atoms.ATOM, 32, data);
689
+ // same deferred-chain shape as setTitle: each step runs from a reply
690
+ // callback and must no-op instead of throwing if the connection
691
+ // started closing (or the window died) while the replies were in flight
692
+ safeRelease(X, () => {
693
+ X.InternAtom(false, 'WM_PROTOCOLS', (err, WM_PROTOCOLS) => {
694
+ if (err) return;
695
+ safeRelease(X, () => {
696
+ X.InternAtom(false, 'WM_DELETE_WINDOW', (err2, WM_DELETE_WINDOW) => {
697
+ if (err2 || this._destroyed) return;
698
+ const data = Buffer.alloc(4);
699
+ data.writeUInt32LE(WM_DELETE_WINDOW, 0);
700
+ safeRelease(X, () => {
701
+ X.ChangeProperty(0, wid, WM_PROTOCOLS, X.atoms.ATOM, 32, data);
702
+ });
703
+ });
704
+ });
668
705
  });
669
706
  });
670
707
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "3.1.0",
3
+ "version": "3.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",
@@ -51,7 +51,7 @@
51
51
  "parse-color": "^1.0.0",
52
52
  "pngjs": "^7.0.0",
53
53
  "postcss": "^8.5.23",
54
- "x11": "^3.1.0",
54
+ "x11": "^3.1.1",
55
55
  "yoga-layout": "^3.2.1"
56
56
  },
57
57
  "scripts": {