node-gtk 2.1.0 → 3.0.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.
@@ -0,0 +1,81 @@
1
+ /*
2
+ * Type declarations for node-gtk/styles (see lib/styles.js).
3
+ *
4
+ * The public surface deals only in strings, URLs, and plain handles, so these
5
+ * types are self-contained — they don't reference the GTK typings.
6
+ */
7
+
8
+ /** Options shared by the style sheets. */
9
+ export interface StyleOptions {
10
+ /** Provider priority; defaults to `Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION`. */
11
+ priority?: number
12
+ }
13
+
14
+ /** Options for {@link StyleManager.add}. */
15
+ export interface StyleAddOptions extends StyleOptions {
16
+ /**
17
+ * Watch the calling module for hot-reload (defaults to on in development).
18
+ * Pass `false` for a programmatic sheet whose source can't be re-imported
19
+ * safely (built inside a method, or owned by a stateful module); the handle
20
+ * still supports `update`/`refresh`/`remove`.
21
+ */
22
+ watch?: boolean
23
+ }
24
+
25
+ /** Options for {@link StyleManager.addFile}. */
26
+ export interface StyleFileOptions extends StyleOptions {
27
+ /** Watch the file for hot-reload (defaults to on in development). */
28
+ watch?: boolean
29
+ }
30
+
31
+ /** A handle to an installed (or queued) stylesheet. */
32
+ export interface StyleSheet {
33
+ /**
34
+ * Replace the CSS (or, for a file sheet, re-read the path) in place. For an
35
+ * inline sheet a string replaces any render function — it becomes fixed CSS.
36
+ */
37
+ update(next: string): void
38
+ /**
39
+ * Re-apply the sheet from its current source: re-run the render function
40
+ * (inline) or re-read the path (file). Call it when the state a render reads
41
+ * changes. A no-op for a queued sheet (not yet installed).
42
+ */
43
+ refresh(): void
44
+ /** Remove the sheet from the display. */
45
+ remove(): void
46
+ }
47
+
48
+ /**
49
+ * Applies CSS to a GTK app and, in development, hot-reloads it. The module
50
+ * exports a shared {@link styles} instance; constructing your own is rarely
51
+ * needed.
52
+ */
53
+ export declare class StyleManager {
54
+ /**
55
+ * Queue (or, once the display exists, install) inline CSS. The source file it
56
+ * is called from is watched for hot-reload (unless `watch` is false).
57
+ *
58
+ * `css` may be a `() => string` *render* function instead of a string, for a
59
+ * dynamic stylesheet built from live state (theme, fonts, …). The render runs
60
+ * now and again on every hot-reload of its module, and on demand via the
61
+ * handle's {@link StyleSheet.refresh}. Keep such a module side-effect-free at
62
+ * its top level (a re-import re-runs it).
63
+ */
64
+ add(css: string | (() => string), options?: StyleAddOptions): StyleSheet
65
+
66
+ /**
67
+ * Queue (or install) a `.css` file. Unless `watch` is false, the file is
68
+ * watched and re-read into its provider on every edit. Idempotent per path.
69
+ * @param path A path, a `file://` URL string, or a `URL`.
70
+ */
71
+ addFile(path: string | URL, options?: StyleFileOptions): StyleSheet
72
+
73
+ /**
74
+ * Install everything queued before the display existed, and start the file
75
+ * watcher. Call once from your `activate` handler. Safe to call repeatedly.
76
+ */
77
+ install(): void
78
+ }
79
+
80
+ /** The application's shared StyleManager. */
81
+ export declare const styles: StyleManager
package/lib/styles.js ADDED
@@ -0,0 +1,428 @@
1
+ /*
2
+ * styles.js — a small StyleManager for node-gtk apps. Applies CSS (inline via
3
+ * styles.add, a `.css` file via styles.addFile) and hot-reloads it in
4
+ * development. Adapted from zym's style-manager; see doc/styles.md.
5
+ */
6
+
7
+ const Path = require('node:path')
8
+ const { pathToFileURL, fileURLToPath } = require('node:url')
9
+
10
+ const internal = require('./native.js')
11
+ const Module = require('./module.js')
12
+
13
+ // Lazily resolved: the app loads Gtk/Gdk/GLib/Gio, not this module. GLib/Gio
14
+ // drive hot-reload (watchDir) and arrive with Gtk.
15
+ const moduleCache = internal.GetModuleCache()
16
+ const gtk = () => Module.require('Gtk')
17
+ const gdk = () => Module.require('Gdk')
18
+ const glib = () => Module.require('GLib')
19
+ const gio = () => Module.require('Gio')
20
+
21
+ // Dev-only; opt out with a falsy NODE_GTK_STYLE_HOT_RELOAD (0/false/no/off).
22
+ const HOT_RELOAD =
23
+ process.env.NODE_ENV === 'development' &&
24
+ !/^(0|false|no|off)$/i.test(process.env.NODE_GTK_STYLE_HOT_RELOAD ?? '')
25
+
26
+ const OWN_FILE = __filename // skipped during caller detection
27
+
28
+ const DEBOUNCE_MS = 40 // coalesce the burst an editor's atomic save emits
29
+
30
+ function defaultPriority() {
31
+ return gtk().STYLE_PROVIDER_PRIORITY_APPLICATION ?? 600
32
+ }
33
+
34
+ /** The default display, or null before it exists (e.g. before app activation). */
35
+ function defaultDisplay() {
36
+ if (!moduleCache['Gdk']) return null // don't force-load Gdk just to ask
37
+ return gdk().Display.getDefault()
38
+ }
39
+
40
+ /** Normalise a path / `file:` URL string / URL to an absolute path. */
41
+ function toPath(p) {
42
+ if (p instanceof URL) return fileURLToPath(p)
43
+ if (typeof p === 'string' && p.startsWith('file:')) return fileURLToPath(p)
44
+ return Path.resolve(p)
45
+ }
46
+
47
+ function loadCss(provider, css) {
48
+ // loadFromString is GTK 4.12+; fall back on older 4.x.
49
+ if (typeof provider.loadFromString === 'function') provider.loadFromString(css)
50
+ else provider.loadFromData(css)
51
+ }
52
+
53
+ /** Local path of a GFile from a monitor event, or null. GFile is a GInterface,
54
+ * so its methods live on the prototype, not the instance. */
55
+ function gioPath(file) {
56
+ if (!file) return null
57
+ try { return gio().File.prototype.getPath.call(file) } catch { return null }
58
+ }
59
+
60
+ /** Absolute path of the first source file above this module on the stack. */
61
+ function callerFile() {
62
+ const stack = new Error().stack
63
+ if (!stack) return null
64
+ for (const line of stack.split('\n').slice(1)) {
65
+ const file = frameFile(line)
66
+ if (file && file !== OWN_FILE) return file
67
+ }
68
+ return null
69
+ }
70
+
71
+ /** Pull the absolute file path out of one V8 stack frame, or null. */
72
+ function frameFile(frame) {
73
+ // Matches "(file:line:col)", bare "at file:line:col", and the "at async
74
+ // file:..." form V8 uses for top-level ESM code.
75
+ const m = frame.match(/\(([^()]+):\d+:\d+\)\s*$/) || frame.match(/\bat\s+(?:async\s+)?([^()\s]+):\d+:\d+\s*$/)
76
+ if (!m) return null
77
+ let loc = m[1]
78
+ const q = loc.indexOf('?') // drop the cache-buster query, if any
79
+ if (q !== -1) loc = loc.slice(0, q)
80
+ if (loc.startsWith('file://')) {
81
+ try { return Path.resolve(fileURLToPath(loc)) } catch { return null }
82
+ }
83
+ if (loc.startsWith('node:') || loc.includes('node_modules')) return null
84
+ return Path.resolve(loc)
85
+ }
86
+
87
+ /**
88
+ * A handle to an installed (or queued) stylesheet.
89
+ * @typedef {object} StyleSheet
90
+ * @property {(next: string) => void} update Replace with fixed CSS (drops any render fn).
91
+ * @property {() => void} refresh Re-apply from source (re-run render / re-read file).
92
+ * @property {() => void} remove Remove the sheet from the display.
93
+ */
94
+
95
+ class StyleManager {
96
+ constructor() {
97
+ this.ready = false
98
+ this.queued = [] // entries awaiting the display
99
+
100
+ // File sheets by path, for per-path dedup and reload. path -> Set<entry>.
101
+ this.cssEntries = new Map()
102
+
103
+ // ---- Hot-reload state (only used when HOT_RELOAD is on) ----
104
+ this.fileProviders = new Map() // srcFile -> Set<provider>, so a reload drops the old
105
+ this.watchedFiles = new Set()
106
+ this.dirWatchers = new Map() // dir -> Gio.FileMonitor
107
+ this.reloadSeq = 0
108
+ this.reloadTimers = new Map()
109
+ this.reloading = new Set()
110
+ this.reloadPending = new Set()
111
+ }
112
+
113
+ /**
114
+ * Inline CSS, hot-reloaded by re-importing its source module. Queued until the
115
+ * display exists.
116
+ *
117
+ * `css` may be a `() => string` *render* function instead of a string, for
118
+ * dynamic stylesheets built from live state (theme, fonts, …). The render runs
119
+ * now and again on every hot-reload of its module (so editing the
120
+ * CSS-generating code re-applies it), and on demand via the handle's
121
+ * `refresh()` — call it whenever the state it reads changes. Keep such a module
122
+ * side-effect-free at its top level (a re-import re-runs it); a stateful
123
+ * singleton it owns must survive re-import (e.g. guarded on `globalThis`).
124
+ *
125
+ * Pass `watch: false` to install without watching the caller for hot-reload —
126
+ * for programmatic sheets whose source can't be re-imported safely (created
127
+ * inside a method, owned by a stateful module). The handle still supports
128
+ * `update`/`refresh`/`remove`.
129
+ * @param {string | (() => string)} css Inline CSS, or a render function.
130
+ * @param {{ priority?: number, watch?: boolean }} [options]
131
+ * @returns {StyleSheet}
132
+ */
133
+ add(css, options = {}) {
134
+ const render = typeof css === 'function' ? css : null
135
+ const watch = HOT_RELOAD && (options.watch ?? true)
136
+ const file = watch ? callerFile() : null
137
+ const entry = {
138
+ kind: 'inline',
139
+ render,
140
+ css: render ? null : css,
141
+ file,
142
+ priority: options.priority,
143
+ provider: null,
144
+ cancelled: false,
145
+ }
146
+ this.place(entry)
147
+ if (file) this.watch(file)
148
+ return this.handle(entry)
149
+ }
150
+
151
+ /**
152
+ * A `.css` file, hot-reloaded by re-reading it (unless `watch` is false).
153
+ * Idempotent per path.
154
+ * @param {string|URL} path A path, a `file://` URL string, or a URL.
155
+ * @param {{ priority?: number, watch?: boolean }} [options]
156
+ * @returns {StyleSheet}
157
+ */
158
+ addFile(path, options = {}) {
159
+ const file = toPath(path)
160
+ const existing = this.cssEntries.get(file)
161
+ if (existing && existing.size) {
162
+ // Refresh the existing sheet rather than stacking a second provider.
163
+ const entry = existing.values().next().value
164
+ if (entry.provider) entry.provider.loadFromPath(file)
165
+ return this.handle(entry)
166
+ }
167
+ const watch = options.watch ?? HOT_RELOAD
168
+ const entry = { kind: 'file', path: file, priority: options.priority, provider: null, cancelled: false, watch }
169
+ this.trackCssEntry(entry) // register by path now, so re-adds dedup even in production
170
+ this.place(entry)
171
+ if (watch) this.watch(file)
172
+ return this.handle(entry)
173
+ }
174
+
175
+ /**
176
+ * Install everything queued before the display existed, and start watching.
177
+ * Call once from your `activate` handler. Safe to call more than once.
178
+ */
179
+ install() {
180
+ if (this.ready) return
181
+ // Before the display exists, providers would never be shown — fail loudly
182
+ // rather than silently drop them. (place()'s auto-flush only runs once a
183
+ // display exists, so it never trips this.)
184
+ if (!defaultDisplay())
185
+ throw new Error('styles.install() called before the display exists — call it from your app\'s "activate" handler')
186
+ this.ready = true
187
+ const pending = this.queued
188
+ this.queued = []
189
+ for (const entry of pending) this.installEntry(entry)
190
+ if (HOT_RELOAD) this.startWatcher()
191
+ }
192
+
193
+ // -------------------------------------------------------------------------
194
+ // installation
195
+ // -------------------------------------------------------------------------
196
+
197
+ place(entry) {
198
+ if (!this.ready && defaultDisplay()) this.install() // auto-flush on first post-display call
199
+ if (this.ready) this.installEntry(entry)
200
+ else this.queued.push(entry)
201
+ }
202
+
203
+ installEntry(entry) {
204
+ if (entry.cancelled) return
205
+ if (entry.kind === 'file') {
206
+ const provider = this.newProvider()
207
+ provider.loadFromPath(entry.path)
208
+ this.addProvider(provider, entry.priority)
209
+ entry.provider = provider
210
+ } else {
211
+ const provider = this.newProvider()
212
+ loadCss(provider, entry.render ? entry.render() : entry.css)
213
+ this.addProvider(provider, entry.priority)
214
+ entry.provider = provider
215
+ if (entry.file) this.trackFileProvider(entry.file, provider)
216
+ }
217
+ }
218
+
219
+ newProvider() {
220
+ const provider = new (gtk().CssProvider)()
221
+ // Surface parse errors with a friendly line instead of a bare GTK critical.
222
+ try {
223
+ provider.on('parsing-error', (section, error) => {
224
+ let message = 'CSS parse error'
225
+ try { if (error && error.message) message += `: ${error.message}` } catch {}
226
+ console.warn(`[node-gtk:styles] ${message}`)
227
+ })
228
+ } catch {}
229
+ return provider
230
+ }
231
+
232
+ addProvider(provider, priority) {
233
+ const display = defaultDisplay()
234
+ if (display) gtk().StyleContext.addProviderForDisplay(display, provider, priority ?? defaultPriority())
235
+ }
236
+
237
+ removeProvider(provider) {
238
+ const display = defaultDisplay()
239
+ if (display) gtk().StyleContext.removeProviderForDisplay(display, provider)
240
+ }
241
+
242
+ // -------------------------------------------------------------------------
243
+ // handles
244
+ // -------------------------------------------------------------------------
245
+
246
+ handle(entry) {
247
+ return {
248
+ update: (next) => {
249
+ if (entry.kind === 'file') {
250
+ const nextPath = toPath(next)
251
+ if (nextPath !== entry.path) {
252
+ // Re-key tracking + watching to the new path.
253
+ this.untrackCssEntry(entry)
254
+ entry.path = nextPath
255
+ this.trackCssEntry(entry)
256
+ if (entry.watch) this.watch(nextPath)
257
+ }
258
+ if (entry.provider) entry.provider.loadFromPath(entry.path)
259
+ } else {
260
+ // A literal replaces a render function: from now on this is fixed CSS.
261
+ entry.render = null
262
+ entry.css = next
263
+ if (entry.provider) loadCss(entry.provider, next)
264
+ }
265
+ },
266
+ // Re-apply the sheet from its current source: re-run the render function
267
+ // (inline) or re-read the path (file). For a fixed-string inline sheet this
268
+ // just re-loads the same CSS. Use it when the state a render reads changes.
269
+ refresh: () => {
270
+ if (!entry.provider) return
271
+ if (entry.kind === 'file') entry.provider.loadFromPath(entry.path)
272
+ else loadCss(entry.provider, entry.render ? entry.render() : entry.css)
273
+ },
274
+ remove: () => {
275
+ if (entry.cancelled) return
276
+ entry.cancelled = true
277
+ if (entry.provider) this.removeProvider(entry.provider)
278
+ if (entry.kind === 'file') this.untrackCssEntry(entry)
279
+ else this.untrackFileProvider(entry)
280
+ },
281
+ }
282
+ }
283
+
284
+ // -------------------------------------------------------------------------
285
+ // hot-reload (dev only)
286
+ // -------------------------------------------------------------------------
287
+
288
+ trackFileProvider(file, provider) {
289
+ let set = this.fileProviders.get(file)
290
+ if (!set) this.fileProviders.set(file, (set = new Set()))
291
+ set.add(provider)
292
+ }
293
+
294
+ trackCssEntry(entry) {
295
+ let set = this.cssEntries.get(entry.path)
296
+ if (!set) this.cssEntries.set(entry.path, (set = new Set()))
297
+ set.add(entry)
298
+ }
299
+
300
+ // When the last sheet for a path is removed, stop watching it — otherwise a
301
+ // later edit would be misrouted through reloadModule and import() the .css.
302
+ untrackCssEntry(entry) {
303
+ const set = this.cssEntries.get(entry.path)
304
+ if (!set) return
305
+ set.delete(entry)
306
+ if (set.size === 0) {
307
+ this.cssEntries.delete(entry.path)
308
+ this.unwatch(entry.path)
309
+ }
310
+ }
311
+
312
+ // Drop the provider so the next module reload doesn't re-remove it. Keep
313
+ // watching: re-running the module re-installs its add() calls.
314
+ untrackFileProvider(entry) {
315
+ if (!entry.file || !entry.provider) return
316
+ this.fileProviders.get(entry.file)?.delete(entry.provider)
317
+ }
318
+
319
+ watch(file) {
320
+ if (this.watchedFiles.has(file)) return
321
+ this.watchedFiles.add(file)
322
+ if (this.ready) this.watchDir(Path.dirname(file)) // else startWatcher picks it up
323
+ }
324
+
325
+ // Stop watching a file; cancel the directory monitor once its last file goes.
326
+ unwatch(file) {
327
+ if (!this.watchedFiles.delete(file)) return
328
+ const timer = this.reloadTimers.get(file)
329
+ if (timer) { glib().sourceRemove(timer); this.reloadTimers.delete(file) }
330
+ const dir = Path.dirname(file)
331
+ for (const f of this.watchedFiles) if (Path.dirname(f) === dir) return
332
+ const monitor = this.dirWatchers.get(dir)
333
+ if (monitor) { try { monitor.cancel() } catch {} ; this.dirWatchers.delete(dir) }
334
+ }
335
+
336
+ startWatcher() {
337
+ for (const file of this.watchedFiles) this.watchDir(Path.dirname(file))
338
+ }
339
+
340
+ // Watch with a GLib GFileMonitor, not node's fs.watch: it is driven by the
341
+ // GLib loop the app already runs (an fs.watch, a libuv handle, silently stops
342
+ // firing once that loop is the only one running) and it does not keep Node
343
+ // alive on exit. We watch the directory, not the file, so atomic-save renames
344
+ // survive — WATCH_MOVES surfaces them.
345
+ watchDir(dir) {
346
+ if (this.dirWatchers.has(dir)) return
347
+ let monitor
348
+ try {
349
+ const Gio = gio()
350
+ const gfile = Gio.File.newForPath(dir)
351
+ // GFile is a GInterface: its methods live on the prototype, not the instance.
352
+ monitor = Gio.File.prototype.monitorDirectory.call(gfile, Gio.FileMonitorFlags.WATCH_MOVES, null)
353
+ } catch {
354
+ return // Gio unavailable / monitor failed — hot-reload just won't fire
355
+ }
356
+ monitor.on('changed', (file, otherFile) => {
357
+ // Map the event back to a watched file (a rename's target is in otherFile);
358
+ // if neither matches, re-check the dir's files — the debounce coalesces.
359
+ const a = gioPath(file)
360
+ const b = gioPath(otherFile)
361
+ if (a && this.watchedFiles.has(a)) this.onFileChanged(a)
362
+ else if (b && this.watchedFiles.has(b)) this.onFileChanged(b)
363
+ else for (const f of this.watchedFiles) if (Path.dirname(f) === dir) this.onFileChanged(f)
364
+ })
365
+ this.dirWatchers.set(dir, monitor)
366
+ }
367
+
368
+ // GLib timeout, not setTimeout, so the debounce is loop-driven too.
369
+ onFileChanged(file) {
370
+ const pending = this.reloadTimers.get(file)
371
+ if (pending) glib().sourceRemove(pending)
372
+ this.reloadTimers.set(file, glib().timeoutAdd(0, DEBOUNCE_MS, () => {
373
+ this.reloadTimers.delete(file)
374
+ void this.reloadFile(file)
375
+ return false // GLib.SOURCE_REMOVE
376
+ }))
377
+ }
378
+
379
+ async reloadFile(file) {
380
+ // Dispatch by which registry the file is in; one in neither (e.g. its sheets
381
+ // were removed) is ignored, so a stray .css edit is never import()ed.
382
+ const cssEntries = this.cssEntries.get(file)
383
+ if (cssEntries && cssEntries.size) {
384
+ for (const entry of cssEntries) {
385
+ if (entry.provider) {
386
+ try { entry.provider.loadFromPath(file) } catch {}
387
+ }
388
+ }
389
+ console.info(`[node-gtk:styles] reloaded ${Path.relative(process.cwd(), file)}`)
390
+ return
391
+ }
392
+ if (this.fileProviders.has(file)) await this.reloadModule(file)
393
+ }
394
+
395
+ /**
396
+ * Re-import a source module (cache-busted) so its add() calls reinstall the
397
+ * new CSS, then drop the previous run's providers — new sheets up before old
398
+ * come down. A load error rolls back to the previously working sheets.
399
+ */
400
+ async reloadModule(file) {
401
+ if (this.reloading.has(file)) { this.reloadPending.add(file); return }
402
+ this.reloading.add(file)
403
+
404
+ const previous = this.fileProviders.get(file) ?? new Set()
405
+ const fresh = new Set()
406
+ this.fileProviders.set(file, fresh) // the re-run's tracking collects into here
407
+ try {
408
+ await import(`${pathToFileURL(file).href}?node-gtk-style=${++this.reloadSeq}`)
409
+ for (const provider of previous) this.removeProvider(provider)
410
+ console.info(`[node-gtk:styles] reloaded ${Path.relative(process.cwd(), file)}`)
411
+ } catch (error) {
412
+ for (const provider of fresh) this.removeProvider(provider)
413
+ this.fileProviders.set(file, previous) // keep the working sheets installed
414
+ const message = error instanceof Error ? error.message : String(error)
415
+ console.warn(`[node-gtk:styles] hot-reload failed for ${Path.relative(process.cwd(), file)}: ${message}`)
416
+ } finally {
417
+ this.reloading.delete(file)
418
+ if (this.reloadPending.delete(file)) void this.reloadModule(file)
419
+ }
420
+ }
421
+ }
422
+
423
+ const styles = new StyleManager()
424
+
425
+ module.exports = {
426
+ StyleManager,
427
+ styles,
428
+ }
package/package.json CHANGED
@@ -1,8 +1,22 @@
1
1
  {
2
2
  "name": "node-gtk",
3
- "version": "2.1.0",
3
+ "version": "3.0.0",
4
4
  "description": "GNOME Gtk+ bindings for NodeJS",
5
5
  "main": "lib/index.js",
6
+ "exports": {
7
+ ".": {
8
+ "import": "./lib/index.mjs",
9
+ "require": "./lib/index.js"
10
+ },
11
+ "./register": "./lib/esm/register.mjs",
12
+ "./hooks": "./lib/esm/hooks.mjs",
13
+ "./styles": {
14
+ "types": "./lib/styles.d.ts",
15
+ "default": "./lib/styles.js"
16
+ },
17
+ "./package.json": "./package.json",
18
+ "./*": "./*"
19
+ },
6
20
  "bin": {
7
21
  "node-gtk": "bin/node-gtk.js"
8
22
  },