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 +20 -0
- package/lib/clipboard.js +317 -0
- package/lib/cursor.js +101 -0
- package/lib/index.js +6 -0
- package/lib/renderingcontext_2d.js +199 -9
- package/lib/text/layout.js +342 -16
- package/lib/window.js +49 -12
- package/package.json +2 -2
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
|
|
package/lib/clipboard.js
ADDED
|
@@ -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
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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/text/layout.js
CHANGED
|
@@ -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 }`
|
|
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({
|
|
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
|
-
|
|
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({
|
|
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)
|
|
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
|
-
|
|
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
|
|
589
|
-
X.InternAtom(false, '
|
|
590
|
-
if (err
|
|
594
|
+
safeRelease(X, () => {
|
|
595
|
+
X.InternAtom(false, '_NET_WM_NAME', (err, netWmName) => {
|
|
596
|
+
if (err) return;
|
|
591
597
|
safeRelease(X, () => {
|
|
592
|
-
X.
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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.
|
|
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.
|
|
54
|
+
"x11": "^3.1.1",
|
|
55
55
|
"yoga-layout": "^3.2.1"
|
|
56
56
|
},
|
|
57
57
|
"scripts": {
|