node-gtk 2.2.0 → 4.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.
Files changed (51) hide show
  1. package/README.md +45 -161
  2. package/bin/node-gtk.js +12 -1
  3. package/binding.gyp +21 -0
  4. package/lib/binding/node-v127-linux-x64/node_gtk.node +0 -0
  5. package/lib/bootstrap.js +43 -11
  6. package/lib/esm/hooks.mjs +49 -0
  7. package/lib/esm/register.mjs +17 -0
  8. package/lib/index.js +1 -2
  9. package/lib/index.mjs +25 -0
  10. package/lib/inspect.js +1 -1
  11. package/lib/loop.js +5 -0
  12. package/lib/module.js +8 -2
  13. package/lib/overrides/Gtk-4.0.js +6 -27
  14. package/lib/register-class.js +86 -3
  15. package/lib/styles.d.ts +81 -0
  16. package/lib/styles.js +428 -0
  17. package/package.json +15 -2
  18. package/src/boxed.cc +13 -5
  19. package/src/closure.cc +19 -6
  20. package/src/closure.h +8 -4
  21. package/src/function.cc +59 -5
  22. package/src/fundamental.cc +451 -0
  23. package/src/fundamental.h +71 -0
  24. package/src/gi.cc +21 -1
  25. package/src/gobject.cc +268 -9
  26. package/src/gobject.h +5 -0
  27. package/src/modules/cairo/context.cc +103 -103
  28. package/src/modules/cairo/font-extents.cc +6 -2
  29. package/src/modules/cairo/generator.js +1 -1
  30. package/src/modules/cairo/glyph.cc +6 -2
  31. package/src/modules/cairo/path.cc +6 -2
  32. package/src/modules/cairo/rectangle-int.cc +6 -2
  33. package/src/modules/cairo/rectangle.cc +6 -2
  34. package/src/modules/cairo/text-cluster.cc +6 -2
  35. package/src/modules/cairo/text-extents.cc +6 -2
  36. package/src/modules/system.cc +4 -4
  37. package/src/util.h +3 -3
  38. package/src/value.cc +44 -8
  39. package/src/value.h +2 -2
  40. package/tools/README.md +52 -2
  41. package/tools/create-app.js +246 -0
  42. package/tools/generate-types.js +80 -3
  43. package/tools/list-libraries.js +125 -0
  44. package/tools/templates/app/README.md.tmpl +97 -0
  45. package/tools/templates/app/gitignore.tmpl +10 -0
  46. package/tools/templates/app/package.json.tmpl +26 -0
  47. package/tools/templates/app/src/main.ts.tmpl +110 -0
  48. package/tools/templates/app/src/welcome.ts.tmpl +41 -0
  49. package/tools/templates/app/style.css.tmpl +19 -0
  50. package/tools/templates/app/tsconfig.json.tmpl +19 -0
  51. /package/{COPYING → LICENSE} +0 -0
@@ -11,17 +11,50 @@ const GObject = module_.require('GObject')
11
11
 
12
12
  module.exports = registerClass
13
13
 
14
+ // Make registerClass() optional: the first `new Subclass()` of an unregistered
15
+ // JS subclass lazily registers it through this hook (see GObjectConstructor in
16
+ // src/gobject.cc). registerClass() stays available for callers that need the
17
+ // GType before constructing (e.g. getGType, GtkBuilder templates).
18
+ internal.SetLazyClassRegister(registerClass)
19
+
20
+ // A registered class owns its `__gtype__` (native classes via the prototype
21
+ // template, JS classes via registerClass below); an unregistered subclass only
22
+ // inherits one. Used to make registration idempotent and to find ancestors that
23
+ // still need registering.
24
+ function isRegistered(klass) {
25
+ return Object.prototype.hasOwnProperty.call(klass.prototype, '__gtype__')
26
+ }
27
+
14
28
  /**
15
- * Create a new GObject type
29
+ * Create a new GObject type.
30
+ *
31
+ * To override a virtual function, define a method named `virtual_` + the
32
+ * camelCase vfunc name (e.g. `virtual_sizeAllocate` overrides
33
+ * `size_allocate`, `virtual_getRequestMode` overrides `get_request_mode`).
34
+ * Only `virtual_*`-prefixed methods are wired into the vtable; plain methods are
35
+ * never treated as overrides. Chain up to the parent implementation with
36
+ * `super.virtual_sizeAllocate(...)`. See doc/index.md "Inheritance".
37
+ *
16
38
  * @param {Class} klass - The class to register
17
39
  * @param {string} [klass.GTypeName] - The name of the GType (klass.name by default)
40
+ * @returns {Class} the same class (so it can be assigned or used as a decorator)
18
41
  */
19
42
  function registerClass(klass) {
43
+ // Idempotent: a class that already owns a GType is registered. This also makes
44
+ // the lazy-on-first-construct path a no-op for explicitly-registered classes.
45
+ if (isRegistered(klass))
46
+ return klass
47
+
20
48
  const parent = Object.getPrototypeOf(klass.prototype).constructor
21
49
 
22
50
  if (!(klass.prototype instanceof GObject.Object))
23
51
  throw new Error(`Invalid base class (${parent.name})`)
24
52
 
53
+ // Register any unregistered ancestor first, so a subclass can be constructed
54
+ // (or registered) without its superclass having been registered by hand.
55
+ if (!isRegistered(parent))
56
+ registerClass(parent)
57
+
25
58
  const name = createGTypeName(klass)
26
59
  const gtype = GObject.typeFromName(name)
27
60
  const parentName = getGTypeName(parent)
@@ -41,26 +74,51 @@ function registerClass(klass) {
41
74
 
42
75
  // Setup virtual functions
43
76
  setupVirtualFunctions(klass, klassGtype, parentGtype)
77
+
78
+ return klass
44
79
  }
45
80
 
46
81
  // Helpers
47
82
 
83
+ /* Methods whose name starts with `virtual_` are treated as virtual-function
84
+ * overrides — and *only* those. This makes overriding explicit and opt-in: a
85
+ * plain method named `dispose`, `getProperty`, `sizeAllocate`, … can no longer
86
+ * silently hijack the matching GObject vfunc (issue #457). The prefix also keeps
87
+ * the override name distinct from the public invoker method of the same vfunc
88
+ * (e.g. `widget.sizeAllocate(...)` the method vs. the `virtual_sizeAllocate`
89
+ * override), so the two no longer collide. */
90
+ const VIRTUAL_PREFIX = /^virtual_/
91
+
92
+ /* `virtual_getRequestMode` -> `get_request_mode` (drop the prefix, snake_case the
93
+ * rest). snakeCase('virtual_getRequestMode') === 'virtual_get_request_mode'. */
94
+ function vfuncNativeName(key) {
95
+ return snakeCase(key).replace(/^virtual_/, '')
96
+ }
97
+
48
98
  function setupVirtualFunctions(klass, klassGtype, parentGtype) {
49
99
  const parentInfo = findInfoByGtype(parentGtype)
50
100
  if (!parentInfo)
51
101
  throw new Error(`Could not find GIR data in inheritance chain`)
52
102
 
103
+ const parentPrototype = Object.getPrototypeOf(klass.prototype)
104
+
53
105
  Object.getOwnPropertyNames(klass.prototype).forEach(key => {
54
106
  if (key === 'constructor')
55
107
  return
108
+ if (!VIRTUAL_PREFIX.test(key))
109
+ return
56
110
  if (typeof klass.prototype[key] !== 'function')
57
111
  return
58
112
 
59
- const nativeName = snakeCase(key)
113
+ const nativeName = vfuncNativeName(key)
60
114
  const vfuncInfo = findVFunc(klassGtype, parentInfo, nativeName)
61
115
 
62
116
  if (!vfuncInfo)
63
- return
117
+ throw new Error(
118
+ `${klass.name}.${key}: no virtual function '${nativeName}' on ` +
119
+ `'${GObject.typeName(parentGtype)}' or its interfaces. A 'virtual_*' ` +
120
+ `method must name an existing vfunc (e.g. 'virtual_sizeAllocate' for ` +
121
+ `'size_allocate'); rename it if it is a plain method.`)
64
122
 
65
123
  internal.RegisterVFunc(
66
124
  vfuncInfo,
@@ -68,6 +126,31 @@ function setupVirtualFunctions(klass, klassGtype, parentGtype) {
68
126
  nativeName,
69
127
  klass.prototype[key]
70
128
  )
129
+
130
+ installParentVFunc(parentPrototype, parentGtype, key, vfuncInfo)
131
+ })
132
+ }
133
+
134
+ /* Make `super.<vfunc>(...)` reachable from an override. The override replaces
135
+ * the parent's implementation in the class vtable, so a JS subclass otherwise
136
+ * has no way to call the implementation it overrode. We install, on the parent
137
+ * GI class's prototype, a method that invokes the *parent's* native vfunc impl
138
+ * (resolved through `parentGtype`'s vtable, not the overriding subclass's).
139
+ *
140
+ * Only the native boundary needs bridging: if the parent prototype already owns
141
+ * `key` — i.e. the parent is itself a registered JS class that overrode this
142
+ * vfunc — then `super.<vfunc>()` resolves to that JS method on its own. */
143
+ function installParentVFunc(parentPrototype, parentGtype, key, vfuncInfo) {
144
+ if (Object.prototype.hasOwnProperty.call(parentPrototype, key))
145
+ return
146
+
147
+ Object.defineProperty(parentPrototype, key, {
148
+ value: function (...args) {
149
+ return internal.CallVFunc(vfuncInfo, parentGtype, this, args)
150
+ },
151
+ writable: true,
152
+ configurable: true,
153
+ enumerable: false,
71
154
  })
72
155
  }
73
156
 
@@ -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.2.0",
3
+ "version": "4.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
  },
@@ -50,7 +64,6 @@
50
64
  "assert": "^1.5.0",
51
65
  "aws-sdk": "^2.452.0",
52
66
  "chalk": "^2.4.2",
53
- "deasync": "^0.1.30",
54
67
  "mocha": "^7.1.0",
55
68
  "nid-parser": "0.0.5",
56
69
  "node-pre-gyp-github": "^1.4.5"