ntk 3.1.0 → 3.2.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) {
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.2.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": {