ntk 3.7.2 → 3.8.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
@@ -81,8 +81,41 @@ export default class App {
81
81
  return chooseGLXConfig(this, spec);
82
82
  }
83
83
 
84
- rootWindow() {
85
- return new Window(this, { id: this.display.screen[0].root });
84
+ rootWindow(screen = 0) {
85
+ return new Window(this, { id: this.display.screen[screen].root });
86
+ }
87
+
88
+ /**
89
+ * Release a pointer or keyboard frozen by a synchronous grab (X
90
+ * AllowEvents). A window manager grabs buttons on client windows with
91
+ * `pointerMode: 0` so it sees the press first; this decides what happens
92
+ * to it:
93
+ *
94
+ * 'replay' — hand the event back to the client as if we never saw it
95
+ * (click-to-focus: raise the window, let the click through)
96
+ * 'async' — thaw, keeping the event for ourselves
97
+ * 'sync' — thaw for one more event, then freeze again
98
+ *
99
+ * The keyboard and both-devices variants carry their X names.
100
+ *
101
+ * @param {string|number} [mode] one of the names above, or a raw X mode
102
+ * @param {number} [time] CurrentTime by default
103
+ */
104
+ allowEvents(mode = 'replay', time = 0) {
105
+ const MODES = {
106
+ async: 0, // AsyncPointer
107
+ sync: 1, // SyncPointer
108
+ replay: 2, // ReplayPointer
109
+ async_keyboard: 3,
110
+ sync_keyboard: 4,
111
+ replay_keyboard: 5,
112
+ async_both: 6,
113
+ sync_both: 7
114
+ };
115
+ const value = typeof mode === 'number' ? mode : MODES[mode];
116
+ if (value === undefined) throw new Error(`ntk: unknown AllowEvents mode '${mode}'`);
117
+ this.X.AllowEvents(value, time);
118
+ return this;
86
119
  }
87
120
 
88
121
  createPixmap(args) {
package/lib/events_map.js CHANGED
@@ -14,6 +14,9 @@ eventName[8] = 'mouseout';
14
14
  eventName[9] = 'focus';
15
15
  eventName[10] = 'blur';
16
16
  eventName[12] = 'expose';
17
+ // CreateNotify: a child window appeared under a parent we watch. Carries a
18
+ // `parent` field, so it arrives as a child-event like the *_request ones.
19
+ eventName[16] = 'create';
17
20
  eventName[17] = 'destroy';
18
21
  eventName[18] = 'unmap';
19
22
  eventName[19] = 'map';
@@ -21,7 +24,7 @@ eventName[20] = 'map_request';
21
24
  eventName[21] = 'reparent';
22
25
  eventName[22] = 'resize';
23
26
  eventName[23] = 'configure_request';
24
- // 25 - gravity
27
+ eventName[24] = 'gravity';
25
28
  eventName[25] = 'resize_request';
26
29
  eventName[26] = 'circulate';
27
30
  eventName[27] = 'circulate_request';
@@ -49,6 +52,15 @@ export const mask = {
49
52
  expose: x11.eventMask.Exposure,
50
53
  map_request: x11.eventMask.SubstructureRedirect,
51
54
  configure_request: x11.eventMask.SubstructureRedirect,
55
+ circulate_request: x11.eventMask.SubstructureRedirect,
56
+ // a child window appeared under a window we watch — the one
57
+ // SubstructureNotify event with its own name, because the rest
58
+ // (map/unmap/destroy/resize/reparent of a child) are the same event types
59
+ // a window gets about itself and are emitted on the child's own wrapper
60
+ create: x11.eventMask.SubstructureNotify,
61
+ gravity: x11.eventMask.StructureNotify,
62
+ circulate: x11.eventMask.StructureNotify,
63
+ resize_request: x11.eventMask.ResizeRedirect,
52
64
  destroy: x11.eventMask.StructureNotify,
53
65
  keyup: x11.eventMask.KeyRelease,
54
66
  property: x11.eventMask.PropertyChange,
@@ -85,6 +97,8 @@ export const toSnake = {
85
97
  onExpose: 'expose',
86
98
  onMapRequest: 'map_request',
87
99
  onConfigureRequest: 'configure_request',
100
+ onCirculateRequest: 'circulate_request',
101
+ onCreate: 'create',
88
102
  onDestroy: 'destroy',
89
103
  onKeyUp: 'keyup',
90
104
  onPropertyChange: 'property',
package/lib/window.js CHANGED
@@ -33,14 +33,49 @@ const forwardedXAttributes = [
33
33
  'cursor'
34
34
  ];
35
35
 
36
+ /**
37
+ * Turn a GetProperty reply into the shape the caller asked for. X property
38
+ * types are atoms, so the only distinction we can make without a round trip
39
+ * is against the predefined ones: STRING is latin-1 by definition (ICCCM),
40
+ * and everything else carrying text — UTF8_STRING above all — is UTF-8.
41
+ */
42
+ function decodeProperty(prop, as, X) {
43
+ if (as === 'string') {
44
+ const encoding = prop.type === X.atoms.STRING ? 'latin1' : 'utf8';
45
+ // these properties are conventionally NUL-terminated, sometimes twice
46
+ return prop.data.toString(encoding).replace(/\0+$/, '');
47
+ }
48
+ if (as === 'numbers') {
49
+ const out = [];
50
+ for (let i = 0; i + 4 <= prop.data.length; i += 4) out.push(prop.data.readUInt32LE(i));
51
+ return out;
52
+ }
53
+ return { type: prop.type, data: prop.data };
54
+ }
55
+
36
56
  export default class Window extends Drawable {
37
57
  // one wrapper per X window id and connection: constructing a Window for a
38
- // known id returns the cached instance
39
- static cache = new Map();
58
+ // known id returns the cached instance. Keyed by connection as well as
59
+ // id, because ids are only unique within a server, not within a process —
60
+ // two connections to the same display see the same root window, and a
61
+ // window manager sees every other client's ids.
62
+ static cache = new WeakMap();
63
+
64
+ static _cacheFor(app) {
65
+ let byId = Window.cache.get(app);
66
+ if (!byId) Window.cache.set(app, (byId = new Map()));
67
+ return byId;
68
+ }
40
69
 
41
70
  constructor(app, args = {}) {
71
+ // 0 is X None, not a window. Without this it fails the truthiness test
72
+ // below and falls through to the create path, silently making a real
73
+ // 800x800 window out of what was almost certainly a missing id.
74
+ if (args.id === 0) {
75
+ throw new Error('ntk: 0 (None) is not a window id');
76
+ }
42
77
  if (args.id) {
43
- const cached = Window.cache.get(args.id);
78
+ const cached = Window._cacheFor(app).get(args.id);
44
79
  // TODO: check event mask, if more events in args - update it
45
80
  if (cached) return cached;
46
81
  }
@@ -151,7 +186,7 @@ export default class Window extends Drawable {
151
186
  });
152
187
  }
153
188
 
154
- Window.cache.set(this.id, this);
189
+ Window._cacheFor(app).set(this.id, this);
155
190
 
156
191
  if (args.title) {
157
192
  this.setTitle(args.title);
@@ -218,14 +253,19 @@ export default class Window extends Drawable {
218
253
  if (this._dirty) this._present();
219
254
  });
220
255
 
256
+ // Events about a *child* of this window: the substructure requests a
257
+ // window manager lives on (map_request, configure_request) plus
258
+ // create. The whole X event is carried through — a ConfigureRequest
259
+ // without its geometry and value mask says only "someone wants
260
+ // something", which is not enough to answer it — with `parent` and
261
+ // `window` upgraded from raw ids to Window objects.
221
262
  this.on('child-event', (ev) => {
222
263
  const eventName = xevents.eventName[ev.type];
223
- const ntkev = {
224
- parent: this,
225
- window: new Window(app, { id: ev.wid })
226
- };
264
+ if (!eventName) return;
265
+ const child = new Window(app, { id: ev.wid });
266
+ const ntkev = { ...ev, parent: this, window: child, target: child };
227
267
  // wait until we know that we track correct x,y,w,h values
228
- ntkev.window._readyPromise.then(() => {
268
+ child._readyPromise.then(() => {
229
269
  this.emit(eventName, ntkev);
230
270
  });
231
271
  });
@@ -236,7 +276,14 @@ export default class Window extends Drawable {
236
276
  if (!eventMask) return;
237
277
  if ((eventMask & this.eventMask) === 0) {
238
278
  this.eventMask |= eventMask;
239
- X.ChangeWindowAttributes(this.id, { eventMask: this.eventMask }, () => {});
279
+ // the selection can legitimately fail — SubstructureRedirect is
280
+ // one-client-only, so `on('map_request')` is how you find out
281
+ // another window manager owns this window. Route it to the app's
282
+ // error hook rather than dropping it; selectInput() is the
283
+ // explicit form that hands the error straight back.
284
+ X.ChangeWindowAttributes(this.id, { eventMask: this.eventMask }, (err) => {
285
+ if (err) this.app.options.onXError?.(err);
286
+ });
240
287
  }
241
288
  });
242
289
 
@@ -257,7 +304,7 @@ export default class Window extends Drawable {
257
304
  }
258
305
 
259
306
  _forget() {
260
- Window.cache.delete(this.id);
307
+ Window._cacheFor(this.app).delete(this.id);
261
308
  delete this.X.event_consumers[this.id];
262
309
  }
263
310
 
@@ -997,6 +1044,275 @@ export default class Window extends Drawable {
997
1044
 
998
1045
  reparentTo(newParent, x, y) {
999
1046
  this.X.ReparentWindow(this.id, newParent.id, x, y);
1047
+ return this;
1048
+ }
1049
+
1050
+ /** Move to the top of the stacking order among its siblings. */
1051
+ raise() {
1052
+ safeRelease(this.X, () => this.X.RaiseWindow(this.id));
1053
+ return this;
1054
+ }
1055
+
1056
+ /** Move to the bottom of the stacking order among its siblings. */
1057
+ lower() {
1058
+ safeRelease(this.X, () => this.X.ConfigureWindow(this.id, { stackMode: 1 }));
1059
+ return this;
1060
+ }
1061
+
1062
+ // ---------------------------------------------------------------------
1063
+ // Reading properties
1064
+ //
1065
+ // ntk could write window properties (setTitle, setSizeHints, setClass)
1066
+ // but never read them, which is fine for a client — it knows what it
1067
+ // wrote — and useless for a window manager, whose whole job is to act on
1068
+ // what *other* clients declare about themselves.
1069
+ // ---------------------------------------------------------------------
1070
+
1071
+ /**
1072
+ * Read a property. `name` is an atom name ('WM_NAME', '_NET_WM_STATE');
1073
+ * interned atoms are cached per connection, so repeat reads cost no
1074
+ * extra round trip.
1075
+ *
1076
+ * Resolves to `null` when the property is not set, otherwise to the
1077
+ * decoded value chosen by `as`:
1078
+ * 'buffer' (default) — `{ type, data }` with the raw bytes
1079
+ * 'string' — text, UTF-8 or latin-1 by the property's type
1080
+ * 'numbers' — an array of 32-bit values (atom lists, cardinals)
1081
+ *
1082
+ * @param {string} name
1083
+ * @param {object} [options] `{ as, type, length }` — `type` restricts the
1084
+ * read to one property type (0, the default, accepts any), `length` is
1085
+ * the cap in 32-bit units.
1086
+ */
1087
+ async getProperty(name, options = {}) {
1088
+ const { as = 'buffer', type = 0, length = 0x1fffffff } = options;
1089
+ const atom = await this.atom(name);
1090
+ return new Promise((resolve, reject) => {
1091
+ safeRelease(this.X, () =>
1092
+ this.X.GetProperty(0, this.id, atom, type, 0, length, (err, prop) => {
1093
+ if (err) return reject(err);
1094
+ // an unset property answers with type None and no bytes
1095
+ if (!prop || !prop.type || !prop.data.length) return resolve(null);
1096
+ resolve(decodeProperty(prop, as, this.X));
1097
+ })
1098
+ );
1099
+ });
1100
+ }
1101
+
1102
+ /**
1103
+ * Intern an atom by name. node-x11 caches them per connection, so asking
1104
+ * again for one already interned costs no round trip.
1105
+ * @returns {Promise<number>} the atom id
1106
+ */
1107
+ atom(name) {
1108
+ return new Promise((resolve, reject) => {
1109
+ this.X.InternAtom(false, name, (err, id) => (err ? reject(err) : resolve(id)));
1110
+ });
1111
+ }
1112
+
1113
+ /**
1114
+ * The window's title as the ICCCM/EWMH pair defines it: _NET_WM_NAME
1115
+ * (UTF-8) if the client set one, else WM_NAME. Resolves to null when the
1116
+ * window has neither. The read counterpart of setTitle.
1117
+ */
1118
+ async getTitle() {
1119
+ const netName = await this.getProperty('_NET_WM_NAME', { as: 'string' }).catch(() => null);
1120
+ if (netName) return netName;
1121
+ return this.getProperty('WM_NAME', { as: 'string' }).catch(() => null);
1122
+ }
1123
+
1124
+ /**
1125
+ * WM_NORMAL_HINTS as an object shaped like setSizeHints' argument —
1126
+ * `{ minWidth, minHeight, maxWidth, maxHeight, widthInc, heightInc,
1127
+ * baseWidth, baseHeight, minAspect, maxAspect, gravity }`, each present
1128
+ * only if the client set its flag. Resolves to `{}` when the window has
1129
+ * no hints, so callers can destructure without a null check.
1130
+ */
1131
+ async getSizeHints() {
1132
+ const v = await this.getProperty('WM_NORMAL_HINTS', { as: 'numbers' }).catch(() => null);
1133
+ if (!v || v.length < 18) return {};
1134
+ const flags = v[0];
1135
+ const hints = {};
1136
+ if (flags & 16) {
1137
+ hints.minWidth = v[5];
1138
+ hints.minHeight = v[6];
1139
+ }
1140
+ if (flags & 32) {
1141
+ hints.maxWidth = v[7];
1142
+ hints.maxHeight = v[8];
1143
+ }
1144
+ if (flags & 64) {
1145
+ hints.widthInc = v[9];
1146
+ hints.heightInc = v[10];
1147
+ }
1148
+ if (flags & 128) {
1149
+ hints.minAspect = [v[11], v[12]];
1150
+ hints.maxAspect = [v[13], v[14]];
1151
+ }
1152
+ if (flags & 256) {
1153
+ hints.baseWidth = v[15];
1154
+ hints.baseHeight = v[16];
1155
+ }
1156
+ if (flags & 512) hints.gravity = v[17];
1157
+ return hints;
1158
+ }
1159
+
1160
+ /**
1161
+ * GetWindowAttributes — `{ mapState, overrideRedirect, ... }`. A window
1162
+ * manager adopting the windows that already existed when it started
1163
+ * needs both: override-redirect windows are none of its business, and
1164
+ * only mapped ones should get a frame.
1165
+ */
1166
+ getAttributes() {
1167
+ return new Promise((resolve, reject) => {
1168
+ safeRelease(this.X, () =>
1169
+ this.X.GetWindowAttributes(this.id, (err, attrs) => (err ? reject(err) : resolve(attrs)))
1170
+ );
1171
+ });
1172
+ }
1173
+
1174
+ // ---------------------------------------------------------------------
1175
+ // Managing other clients' windows
1176
+ // ---------------------------------------------------------------------
1177
+
1178
+ /**
1179
+ * Select an event mask explicitly, reporting failure. Handler-driven
1180
+ * selection (`on('map_request')`, the onXxx constructor args) covers the
1181
+ * ordinary case; this exists for the mask that can be refused —
1182
+ * SubstructureRedirect belongs to one client at a time, so
1183
+ *
1184
+ * await app.rootWindow().selectInput(SubstructureRedirect | SubstructureNotify)
1185
+ *
1186
+ * either makes you the window manager or rejects with BadAccess because
1187
+ * something else already is. The mask is OR-ed into whatever handlers
1188
+ * have already asked for.
1189
+ */
1190
+ selectInput(mask) {
1191
+ this.eventMask |= mask;
1192
+ return new Promise((resolve, reject) => {
1193
+ this.X.ChangeWindowAttributes(this.id, { eventMask: this.eventMask }, (err) =>
1194
+ err ? reject(err) : resolve(this)
1195
+ );
1196
+ });
1197
+ }
1198
+
1199
+ /**
1200
+ * Add this window to our save-set (X ChangeSaveSet). A window manager
1201
+ * reparents clients into frames it owns; without the save-set, the
1202
+ * clients would be destroyed along with those frames if the WM exits.
1203
+ * With it the server reparents them back to the root and remaps them.
1204
+ */
1205
+ addToSaveSet() {
1206
+ safeRelease(this.X, () => this.X.ChangeSaveSet(1, this.id));
1207
+ return this;
1208
+ }
1209
+
1210
+ removeFromSaveSet() {
1211
+ safeRelease(this.X, () => this.X.ChangeSaveSet(0, this.id));
1212
+ return this;
1213
+ }
1214
+
1215
+ /**
1216
+ * Tell the client where it really ended up (ICCCM 4.1.5). A reparented
1217
+ * window's own ConfigureNotify carries coordinates relative to its
1218
+ * frame, which is not what the client asked about, and a
1219
+ * ConfigureRequest the window manager decided to refuse produces no
1220
+ * ConfigureNotify at all — clients that resize themselves hang waiting
1221
+ * for one. Both cases are answered by a synthetic event carrying
1222
+ * root-relative coordinates.
1223
+ *
1224
+ * @param {object} geometry `{ x, y, width, height, borderWidth }` in
1225
+ * root coordinates; anything omitted comes from the window.
1226
+ */
1227
+ sendConfigureNotify(geometry = {}) {
1228
+ const {
1229
+ x = this.x,
1230
+ y = this.y,
1231
+ width = this.width,
1232
+ height = this.height,
1233
+ borderWidth = 0
1234
+ } = geometry;
1235
+ const ev = Buffer.alloc(32);
1236
+ ev[0] = 22; // ConfigureNotify
1237
+ ev.writeUInt32LE(this.id >>> 0, 4); // event window
1238
+ ev.writeUInt32LE(this.id >>> 0, 8); // the window itself
1239
+ ev.writeUInt32LE(0, 12); // above-sibling: None
1240
+ ev.writeInt16LE(x, 16);
1241
+ ev.writeInt16LE(y, 18);
1242
+ ev.writeUInt16LE(width, 20);
1243
+ ev.writeUInt16LE(height, 22);
1244
+ ev.writeUInt16LE(borderWidth, 24);
1245
+ ev[26] = 0; // override-redirect
1246
+ safeRelease(this.X, () =>
1247
+ this.X.SendEvent(this.id, 0, x11.eventMask.StructureNotify, ev)
1248
+ );
1249
+ return this;
1250
+ }
1251
+
1252
+ /**
1253
+ * Ask the client to close, the polite way: a WM_DELETE_WINDOW client
1254
+ * message if it advertised the protocol in WM_PROTOCOLS, so it can save
1255
+ * work or put up a confirmation. Clients that did not advertise it have
1256
+ * no such path and are killed outright (X KillClient), which is what
1257
+ * `xkill` does.
1258
+ *
1259
+ * This is the window manager's side of the protocol `setActions()` opts
1260
+ * a window into.
1261
+ *
1262
+ * @returns {Promise<boolean>} true if asked politely, false if killed
1263
+ */
1264
+ async close() {
1265
+ const protocols = await this.getProperty('WM_PROTOCOLS', { as: 'numbers' }).catch(() => null);
1266
+ const deleteAtom = await this.atom('WM_DELETE_WINDOW').catch(() => 0);
1267
+ if (!deleteAtom || !protocols || !protocols.includes(deleteAtom)) {
1268
+ safeRelease(this.X, () => this.X.KillClient(this.id));
1269
+ return false;
1270
+ }
1271
+ const wmProtocols = await this.atom('WM_PROTOCOLS');
1272
+ const ev = Buffer.alloc(32);
1273
+ ev[0] = 33; // ClientMessage
1274
+ ev[1] = 32; // format
1275
+ ev.writeUInt32LE(this.id >>> 0, 4);
1276
+ ev.writeUInt32LE(wmProtocols >>> 0, 8);
1277
+ ev.writeUInt32LE(deleteAtom >>> 0, 12);
1278
+ ev.writeUInt32LE(0, 16); // CurrentTime
1279
+ safeRelease(this.X, () => this.X.SendEvent(this.id, 0, 0, ev));
1280
+ return true;
1281
+ }
1282
+
1283
+ /**
1284
+ * Grab a mouse button on this window (X GrabButton). A window manager
1285
+ * uses this to see clicks that belong to the client: with
1286
+ * `pointerMode: 0` (synchronous) the pointer freezes on press until
1287
+ * `app.allowEvents()` decides what happens — 'replay' hands the click
1288
+ * back to the client, so click-to-focus can raise the window without
1289
+ * swallowing the click.
1290
+ *
1291
+ * @param {object} [options] `{ button (0 = any), modifiers (0x8000 =
1292
+ * any), ownerEvents, events, pointerMode, keyboardMode, confineTo,
1293
+ * cursor }`
1294
+ */
1295
+ grabButton(options = {}) {
1296
+ const {
1297
+ button = 0,
1298
+ modifiers = 0x8000,
1299
+ ownerEvents = false,
1300
+ events = x11.eventMask.ButtonPress | x11.eventMask.ButtonRelease,
1301
+ pointerMode = 1,
1302
+ keyboardMode = 1,
1303
+ confineTo = 0,
1304
+ cursor = 0
1305
+ } = options;
1306
+ this.X.GrabButton(
1307
+ this.id, ownerEvents, events, pointerMode, keyboardMode,
1308
+ confineTo, cursor, button, modifiers
1309
+ );
1310
+ return this;
1311
+ }
1312
+
1313
+ ungrabButton(button = 0, modifiers = 0x8000) {
1314
+ safeRelease(this.X, () => this.X.UngrabButton(this.id, button, modifiers));
1315
+ return this;
1000
1316
  }
1001
1317
 
1002
1318
  queryTree(callback) {
@@ -1004,12 +1320,13 @@ export default class Window extends Drawable {
1004
1320
  this.X.QueryTree(this.id, (err, tree) => {
1005
1321
  if (err) return callback(err);
1006
1322
  const children = tree.children.map((id) => new Window(app, { id }));
1007
- const parent = new Window(app, { id: tree.parent });
1323
+ // the root window has no parent — QueryTree answers None (0) for it
1324
+ const parent = tree.parent ? new Window(app, { id: tree.parent }) : null;
1008
1325
  // note that this root may be different from app.rootWindow() because
1009
1326
  // there can be multiple screens (and roots)
1010
1327
  const root = new Window(app, { id: tree.root });
1011
- const readyPromises = [...children, parent, root].map((w) => w._readyPromise);
1012
- Promise.all(readyPromises).then(() => {
1328
+ const wrappers = [...children, root, ...(parent ? [parent] : [])];
1329
+ Promise.all(wrappers.map((w) => w._readyPromise)).then(() => {
1013
1330
  callback(null, { parent, root, children });
1014
1331
  });
1015
1332
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "3.7.2",
3
+ "version": "3.8.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.2",
54
+ "x11": "^3.2.0",
55
55
  "yoga-layout": "^3.2.1"
56
56
  },
57
57
  "scripts": {