@gtkx/react 1.3.0 → 1.4.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 (73) hide show
  1. package/README.md +5 -4
  2. package/dist/adw/dialog.js +2 -2
  3. package/dist/adw/dialog.js.map +1 -1
  4. package/dist/components/application.d.ts.map +1 -1
  5. package/dist/components/application.js +3 -3
  6. package/dist/components/application.js.map +1 -1
  7. package/dist/components/window.js +3 -3
  8. package/dist/components/window.js.map +1 -1
  9. package/dist/element-behaviors.js +93 -3
  10. package/dist/element-behaviors.js.map +1 -1
  11. package/dist/element-config.js +1 -1
  12. package/dist/element-config.js.map +1 -1
  13. package/dist/hooks/use-bind-setting.d.ts.map +1 -1
  14. package/dist/hooks/use-bind-setting.js +3 -2
  15. package/dist/hooks/use-bind-setting.js.map +1 -1
  16. package/dist/hooks/use-property.d.ts +1 -1
  17. package/dist/hooks/use-property.d.ts.map +1 -1
  18. package/dist/hooks/use-property.js +5 -1
  19. package/dist/hooks/use-property.js.map +1 -1
  20. package/dist/prop-types.d.ts +9 -1
  21. package/dist/prop-types.d.ts.map +1 -1
  22. package/dist/prop-types.js.map +1 -1
  23. package/dist/reconciler/apply-props.d.ts +2 -1
  24. package/dist/reconciler/apply-props.d.ts.map +1 -1
  25. package/dist/reconciler/apply-props.js +59 -24
  26. package/dist/reconciler/apply-props.js.map +1 -1
  27. package/dist/reconciler/behaviors.d.ts.map +1 -1
  28. package/dist/reconciler/behaviors.js +40 -9
  29. package/dist/reconciler/behaviors.js.map +1 -1
  30. package/dist/reconciler/host-config.d.ts.map +1 -1
  31. package/dist/reconciler/host-config.js +2 -1
  32. package/dist/reconciler/host-config.js.map +1 -1
  33. package/dist/reconciler/metadata.d.ts +6 -1
  34. package/dist/reconciler/metadata.d.ts.map +1 -1
  35. package/dist/reconciler/metadata.js +39 -6
  36. package/dist/reconciler/metadata.js.map +1 -1
  37. package/dist/reconciler/placement.js +4 -4
  38. package/dist/reconciler/placement.js.map +1 -1
  39. package/dist/reconciler/registry.d.ts +2 -0
  40. package/dist/reconciler/registry.d.ts.map +1 -1
  41. package/dist/reconciler/registry.js.map +1 -1
  42. package/dist/reconciler/root.d.ts.map +1 -1
  43. package/dist/reconciler/root.js.map +1 -1
  44. package/dist/reconciler/signals.d.ts +3 -2
  45. package/dist/reconciler/signals.d.ts.map +1 -1
  46. package/dist/reconciler/signals.js +49 -16
  47. package/dist/reconciler/signals.js.map +1 -1
  48. package/dist/reconciler/style.d.ts +2 -1
  49. package/dist/reconciler/style.d.ts.map +1 -1
  50. package/dist/reconciler/style.js +4 -3
  51. package/dist/reconciler/style.js.map +1 -1
  52. package/dist/reconciler/text.js +3 -3
  53. package/dist/reconciler/text.js.map +1 -1
  54. package/env.d.ts +10 -7
  55. package/package.json +6 -6
  56. package/src/adw/dialog.tsx +2 -2
  57. package/src/components/application.tsx +18 -3
  58. package/src/components/window.tsx +3 -3
  59. package/src/element-behaviors.ts +122 -3
  60. package/src/element-config.ts +1 -1
  61. package/src/hooks/use-bind-setting.ts +3 -2
  62. package/src/hooks/use-property.ts +6 -2
  63. package/src/prop-types.ts +10 -0
  64. package/src/reconciler/apply-props.ts +73 -26
  65. package/src/reconciler/behaviors.ts +60 -12
  66. package/src/reconciler/host-config.ts +3 -1
  67. package/src/reconciler/metadata.ts +62 -6
  68. package/src/reconciler/placement.ts +4 -4
  69. package/src/reconciler/registry.ts +2 -0
  70. package/src/reconciler/root.ts +1 -1
  71. package/src/reconciler/signals.ts +62 -17
  72. package/src/reconciler/style.ts +4 -3
  73. package/src/reconciler/text.ts +3 -3
@@ -34,7 +34,13 @@ import {
34
34
  registerElements,
35
35
  } from "./reconciler/registry.js";
36
36
  import { applyWrite } from "./reconciler/signals.js";
37
- import { applyStyle, styleClass } from "./reconciler/style.js";
37
+ import { applyStyle, CSS_CLASSES_PROP, styleClass } from "./reconciler/style.js";
38
+
39
+ type SelectedIndexState = { desired: number | undefined; isScheduled: boolean; disconnect: (() => void) | null };
40
+
41
+ const SELECTED_INDEX_PROP = "selectedIndex";
42
+ const SELECTION_SIGNAL = "selected-rows-changed";
43
+ const NO_SELECTION = -1;
38
44
 
39
45
  const BUILTIN_BEHAVIORS: Record<string, ElementConfig<never>> = {
40
46
  ...forTypes(SINGLE_CHILD_TYPES, {
@@ -114,6 +120,7 @@ const BUILTIN_BEHAVIORS: Record<string, ElementConfig<never>> = {
114
120
  wrappingIndexedSlot(Gtk.ListBoxRow, (row, inner) => {
115
121
  row.setChild(inner);
116
122
  }),
123
+ selectedIndexBehavior(),
117
124
  ],
118
125
  },
119
126
  GtkFlowBox: {
@@ -430,8 +437,8 @@ const didWriteCssClasses = (widget: Gtk.Widget, prev: Props, next: Props, classN
430
437
  return false;
431
438
  }
432
439
 
433
- applyWrite(() => {
434
- Reflect.set(widget, "cssClasses", withStyleClass(next.cssClasses, className));
440
+ applyWrite(CSS_CLASSES_PROP, () => {
441
+ Reflect.set(widget, CSS_CLASSES_PROP, withStyleClass(next.cssClasses, className));
435
442
  });
436
443
 
437
444
  return true;
@@ -460,6 +467,118 @@ function styleBehavior(): ElementBehavior<Gtk.Widget> {
460
467
  return { update: updateStyle };
461
468
  }
462
469
 
470
+ function selectedRowIndex(box: Gtk.ListBox): number {
471
+ return box.getSelectedRow()?.getIndex() ?? NO_SELECTION;
472
+ }
473
+
474
+ function selectedIndexError(value: number): Error {
475
+ return new Error(
476
+ "The 'selectedIndex' of a <GtkListBox> must be a whole number, or -1 to select no row; " +
477
+ `received ${String(value)}.`,
478
+ );
479
+ }
480
+
481
+ function desiredIndex(value: unknown): number | undefined {
482
+ if (value === null) {
483
+ return NO_SELECTION;
484
+ }
485
+
486
+ if (typeof value !== "number") {
487
+ return undefined;
488
+ }
489
+
490
+ if (!Number.isSafeInteger(value)) {
491
+ throw selectedIndexError(value);
492
+ }
493
+
494
+ return value;
495
+ }
496
+
497
+ function selectRowAt(box: Gtk.ListBox, row: Gtk.ListBoxRow): void {
498
+ applyWrite(SELECTED_INDEX_PROP, () => {
499
+ box.selectRow(row);
500
+ });
501
+ }
502
+
503
+ function clearSelection(box: Gtk.ListBox): void {
504
+ applyWrite(SELECTED_INDEX_PROP, () => {
505
+ box.unselectAll();
506
+ });
507
+ }
508
+
509
+ function applySelectedIndex(box: Gtk.ListBox, state: SelectedIndexState): void {
510
+ const { desired } = state;
511
+
512
+ if (desired === undefined || selectedRowIndex(box) === desired) {
513
+ return;
514
+ }
515
+
516
+ if (desired < 0) {
517
+ clearSelection(box);
518
+
519
+ return;
520
+ }
521
+
522
+ const row = box.getRowAtIndex(desired);
523
+
524
+ if (row !== null) {
525
+ selectRowAt(box, row);
526
+ }
527
+ }
528
+
529
+ function scheduleSelectedIndex(box: Gtk.ListBox, state: SelectedIndexState): void {
530
+ if (state.isScheduled) {
531
+ return;
532
+ }
533
+
534
+ state.isScheduled = true;
535
+
536
+ queueMicrotask(() => {
537
+ state.isScheduled = false;
538
+
539
+ if (state.disconnect !== null) {
540
+ applySelectedIndex(box, state);
541
+ }
542
+ });
543
+ }
544
+
545
+ function watchSelectionDrift(box: Gtk.ListBox, state: SelectedIndexState): void {
546
+ if (state.disconnect !== null) {
547
+ return;
548
+ }
549
+
550
+ const handler = (): undefined => {
551
+ scheduleSelectedIndex(box, state);
552
+ };
553
+
554
+ box.on(SELECTION_SIGNAL, handler);
555
+
556
+ state.disconnect = (): void => {
557
+ box.off(SELECTION_SIGNAL, handler);
558
+ };
559
+ }
560
+
561
+ function selectedIndexBehavior(): ElementBehavior<Gtk.ListBox> {
562
+ return {
563
+ deferred: [SELECTED_INDEX_PROP],
564
+ initialize: (): SelectedIndexState => ({ desired: undefined, isScheduled: false, disconnect: null }),
565
+ update: (_box, _prev, next, context) => {
566
+ (context as SelectedIndexState).desired = desiredIndex(next[SELECTED_INDEX_PROP]);
567
+
568
+ return [SELECTED_INDEX_PROP];
569
+ },
570
+ flush: (box, context) => {
571
+ applySelectedIndex(box, context as SelectedIndexState);
572
+ watchSelectionDrift(box, context as SelectedIndexState);
573
+ },
574
+ teardown: (_box, context) => {
575
+ const state = context as SelectedIndexState;
576
+ state.disconnect?.();
577
+ state.disconnect = null;
578
+ },
579
+ };
580
+ }
581
+
463
582
  const addMainOption = (application: Gtk.Application, option: MainOption): void => {
464
583
  application.addMainOption(
465
584
  option.longName,
@@ -82,7 +82,7 @@ const BUILTIN_ELEMENTS: Record<string, ElementConfig> = {
82
82
  props: internal("ChildrenProps"),
83
83
  },
84
84
  GtkListBox: {
85
- props: internal("ChildrenProps"),
85
+ props: internal("GtkListBoxProps"),
86
86
  },
87
87
  GtkFlowBox: {
88
88
  props: internal("ChildrenProps"),
@@ -3,6 +3,7 @@ import * as Gio from "@gtkx/gi/gio";
3
3
  import { kebabCase } from "@gtkx/utils";
4
4
  import { useLayoutEffect } from "react";
5
5
  import type { SettingsSchema, SettingsSchemaKeys } from "../utils/settings.js";
6
+ import { getPropertyName } from "../reconciler/metadata.js";
6
7
  import { type RefProp, resolveRefProp } from "../utils/ref-prop.js";
7
8
  import { useSettings } from "./use-setting.js";
8
9
 
@@ -32,7 +33,6 @@ function useBindSetting<K extends SettingsSchemaKeys>({
32
33
  flags = Gio.SettingsBindFlags.DEFAULT,
33
34
  }: UseBindSettingOptions<K>): void {
34
35
  const settings = useSettings(schema);
35
- const propertyName = kebabCase(property);
36
36
 
37
37
  useLayoutEffect(() => {
38
38
  const resolved = resolveRefProp(object);
@@ -41,12 +41,13 @@ function useBindSetting<K extends SettingsSchemaKeys>({
41
41
  return;
42
42
  }
43
43
 
44
+ const propertyName = getPropertyName(resolved, property) ?? kebabCase(property);
44
45
  settings.bind(key, resolved, propertyName, flags);
45
46
 
46
47
  return () => {
47
48
  Gio.Settings.unbind(resolved, propertyName);
48
49
  };
49
- }, [settings, key, object, propertyName, flags]);
50
+ }, [settings, key, object, property, flags]);
50
51
  }
51
52
 
52
53
  export { useBindSetting };
@@ -1,6 +1,7 @@
1
1
  import type * as GObject from "@gtkx/gi/gobject";
2
2
  import { kebabCase } from "@gtkx/utils";
3
- import type { RefProp } from "../utils/ref-prop.js";
3
+ import { getPropertyName } from "../reconciler/metadata.js";
4
+ import { type RefProp, resolveRefProp } from "../utils/ref-prop.js";
4
5
  import { useObjectValue } from "./use-object-value.js";
5
6
 
6
7
  /** The property map `T` declares, from camelCase property name to value type. */
@@ -18,7 +19,10 @@ function useProperty<T extends GObject.Object, P extends PropertyName<T>>(
18
19
  object: RefProp<T>,
19
20
  propertyName: P & string,
20
21
  ): T[P] | undefined {
21
- return useObjectValue(object, `notify::${kebabCase(propertyName)}`, (obj) => obj?.[propertyName]);
22
+ const resolved = resolveRefProp(object);
23
+ const name = (resolved === null ? undefined : getPropertyName(resolved, propertyName)) ?? kebabCase(propertyName);
24
+
25
+ return useObjectValue(object, `notify::${name}`, (obj) => obj?.[propertyName]);
22
26
  }
23
27
 
24
28
  export { useProperty };
package/src/prop-types.ts CHANGED
@@ -246,6 +246,15 @@ type GtkScaleProps = {
246
246
  marks?: ScaleMark[] | null | undefined;
247
247
  };
248
248
 
249
+ /** Props of a `Gtk.ListBox` element. */
250
+ type GtkListBoxProps = {
251
+ /**
252
+ * Index of the row to select; `-1` or `null` selects none. Applied once the row exists, and
253
+ * re-applied whenever the box's own selection drifts from it. Left alone while the prop is absent.
254
+ */
255
+ selectedIndex?: number | null | undefined;
256
+ } & ChildrenProps;
257
+
249
258
  /** Props of a `Gtk.Calendar` element. */
250
259
  type GtkCalendarProps = {
251
260
  /** Days of the shown month drawn as marked, cleared and re-marked whenever the list changes. */
@@ -324,6 +333,7 @@ export {
324
333
  type GtkConstraintLayoutProps,
325
334
  type GtkHeaderBarProps,
326
335
  type GtkScaleProps,
336
+ type GtkListBoxProps,
327
337
  type GtkCalendarProps,
328
338
  type GtkLevelBarProps,
329
339
  type GtkSizeGroupProps,
@@ -1,11 +1,11 @@
1
- import type * as GObject from "@gtkx/gi/gobject";
2
1
  import type { SignalHandler } from "@gtkx/runtime";
2
+ import * as GObject from "@gtkx/gi/gobject";
3
3
  import * as Gtk from "@gtkx/gi/gtk";
4
- import { coerceObjectProperty } from "@gtkx/runtime";
5
- import { drain, isDeepEqual, kebabCase } from "@gtkx/utils";
4
+ import { coerceObjectProperty, getInstanceType, signalForHandlerName, TYPE_INVALID } from "@gtkx/runtime";
5
+ import { drain, isDeepEqual, kebabCase, lowerFirst, unsanitizeIdentifier } from "@gtkx/utils";
6
6
  import type { ElementBehavior, Props } from "./registry.js";
7
7
  import { applyAccessibleProps, isAccessibleProp } from "../utils/accessible-props.js";
8
- import { type TypeInfo, typeInfoFor } from "./metadata.js";
8
+ import { getPropertyName, hasProperty, type TypeInfo, typeInfoFor } from "./metadata.js";
9
9
  import { type ElementNode, getOrCreateContext, type SignalTarget } from "./node.js";
10
10
  import { applyWrite, connectHandler, disconnectHandler } from "./signals.js";
11
11
  import { bufferText, hasSameText, isContentPaintableProp, markTextDirty, TEXT_PROP } from "./text.js";
@@ -16,29 +16,53 @@ type PropChange = { prev: Props; next: Props };
16
16
 
17
17
  const REACT_RESERVED_PROPS = new Set(["children", "ref", "key"]);
18
18
  const NOTIFY_PREFIX = "onNotify";
19
- const HANDLER_PREFIX = "on";
20
19
  const HANDLER_NAME = /^on[A-Z]/;
21
20
  const flushDirty: Set<ElementNode> = new Set();
22
21
  const accessibleDirty: Map<ElementNode, Props> = new Map();
23
- const mapWatched: WeakSet<ElementNode> = new WeakSet();
22
+ const mapWatched: WeakMap<ElementNode, () => void> = new WeakMap();
24
23
  const pendingMap: Set<ElementNode> = new Set();
25
24
 
26
25
  const isHandlerName = (name: string): boolean => HANDLER_NAME.test(name);
26
+ const notifiedAccessor = (name: string): string => lowerFirst(name.slice(NOTIFY_PREFIX.length));
27
27
 
28
- const signalForProp = (info: TypeInfo, name: string): string => {
28
+ const unknownSignalError = (typeName: string, name: string): Error =>
29
+ new Error(
30
+ `The handler prop '${name}' of <${typeName}> names no signal ${typeName} carries. Name a ` +
31
+ `signal the element carries, or declare the one '${name}' stands for on ${typeName} when ` +
32
+ "its class is registered.",
33
+ );
34
+
35
+ const lookedUpSignal = (target: SignalTarget, name: string): string => {
36
+ const type = getInstanceType(target.object);
37
+ const signal = type === TYPE_INVALID ? undefined : signalForHandlerName(type, name);
38
+
39
+ if (signal === undefined) {
40
+ throw unknownSignalError(target.typeName, name);
41
+ }
42
+
43
+ return signal;
44
+ };
45
+
46
+ const signalForProp = (target: SignalTarget, info: TypeInfo, name: string): string => {
29
47
  if (name === NOTIFY_PREFIX) {
30
48
  return "notify";
31
49
  }
32
50
 
33
51
  if (name.startsWith(NOTIFY_PREFIX) && name.length > NOTIFY_PREFIX.length) {
34
- return `notify::${kebabCase(name.slice(NOTIFY_PREFIX.length))}`;
52
+ const accessor = notifiedAccessor(name);
53
+ const property = getPropertyName(target.object, accessor) ?? unsanitizeIdentifier(kebabCase(accessor));
54
+
55
+ return `notify::${property}`;
35
56
  }
36
57
 
37
- return info.signals[name] ?? kebabCase(name.slice(HANDLER_PREFIX.length));
58
+ return info.signals[name] ?? lookedUpSignal(target, name);
38
59
  };
39
60
 
40
61
  const isReservedName = (name: string, info: TypeInfo): boolean =>
41
- REACT_RESERVED_PROPS.has(name) || isHandlerName(name) || isAccessibleProp(name) || info.constructOnly.has(name);
62
+ REACT_RESERVED_PROPS.has(name) ||
63
+ (isHandlerName(name) && !hasProperty(info, name)) ||
64
+ (isAccessibleProp(name) && !hasProperty(info, name)) ||
65
+ info.constructOnly.has(name);
42
66
 
43
67
  const isSkippedValueName = (name: string, info: TypeInfo, consumed: Set<string>): boolean =>
44
68
  isReservedName(name, info) || consumed.has(name);
@@ -48,7 +72,7 @@ const writeValue = (object: GObject.Object, name: string, value: unknown): void
48
72
  return;
49
73
  }
50
74
 
51
- applyWrite(() => {
75
+ applyWrite(name, () => {
52
76
  Reflect.set(object, name, coerceObjectProperty(object, name, value));
53
77
  });
54
78
  };
@@ -72,7 +96,7 @@ const applyBufferText = (buffer: Gtk.TextBuffer, text: string): void => {
72
96
  return;
73
97
  }
74
98
 
75
- applyWrite(() => {
99
+ applyWrite(TEXT_PROP, () => {
76
100
  buffer.beginIrreversibleAction();
77
101
  buffer.setText(text, -1);
78
102
  buffer.endIrreversibleAction();
@@ -191,24 +215,26 @@ const restoreActionableSensitivity = (node: ElementNode, info: TypeInfo, prev: P
191
215
  const desired = "sensitive" in next ? next.sensitive : info.defaults.sensitive;
192
216
 
193
217
  if (typeof desired === "boolean") {
194
- applyWrite(() => {
218
+ applyWrite("sensitive", () => {
195
219
  Reflect.set(node.object, "sensitive", desired);
196
220
  });
197
221
  }
198
222
  };
199
223
 
200
- const applyHandlers = (target: SignalTarget, info: TypeInfo, prev: Props, next: Props): void => {
201
- eachChangedName(prev, next, (name) => {
202
- if (!isHandlerName(name)) {
203
- return;
204
- }
224
+ const applyHandler = (target: SignalTarget, info: TypeInfo, name: string, next: Props): void => {
225
+ const value = next[name];
205
226
 
206
- const value = next[name];
227
+ if (typeof value === "function") {
228
+ connectHandler(target, name, signalForProp(target, info, name), value as SignalHandler);
229
+ } else {
230
+ disconnectHandler(target, name);
231
+ }
232
+ };
207
233
 
208
- if (typeof value === "function") {
209
- connectHandler(target, name, signalForProp(info, name), value as SignalHandler);
210
- } else {
211
- disconnectHandler(target, name);
234
+ const applyHandlers = (target: SignalTarget, info: TypeInfo, prev: Props, next: Props): void => {
235
+ eachChangedName(prev, next, (name) => {
236
+ if (isHandlerName(name) && !hasProperty(info, name)) {
237
+ applyHandler(target, info, name, next);
212
238
  }
213
239
  });
214
240
  };
@@ -231,6 +257,15 @@ const flushBehaviors = (): void => {
231
257
  });
232
258
  };
233
259
 
260
+ const teardownBehaviors = (node: ElementNode): void => {
261
+ flushDirty.delete(node);
262
+ unwatchMap(node);
263
+
264
+ for (const [behavior, context] of node.contexts) {
265
+ behavior.teardown?.(node.object, context);
266
+ }
267
+ };
268
+
234
269
  const applyAccessible = (object: GObject.Object, prev: Props | null, next: Props): void => {
235
270
  if (object instanceof Gtk.Accessible) {
236
271
  applyAccessibleProps(object, prev, next);
@@ -260,14 +295,25 @@ const watchMap = (node: ElementNode): void => {
260
295
  return;
261
296
  }
262
297
 
263
- mapWatched.add(node);
264
-
265
- object.connect("map", () => {
298
+ const onMapped = (): undefined => {
266
299
  pendingMap.add(node);
267
300
  setTimeout(settleAccessible, 0);
301
+ };
302
+
303
+ object.on("map", onMapped);
304
+
305
+ mapWatched.set(node, () => {
306
+ object.off("map", onMapped);
268
307
  });
269
308
  };
270
309
 
310
+ const unwatchMap = (node: ElementNode): void => {
311
+ mapWatched.get(node)?.();
312
+ mapWatched.delete(node);
313
+ pendingMap.delete(node);
314
+ accessibleDirty.delete(node);
315
+ };
316
+
271
317
  const settleAccessible = (): void => {
272
318
  drain(pendingMap, (node) => {
273
319
  if (node.object instanceof Gtk.Widget && node.object.getMapped()) {
@@ -319,6 +365,7 @@ export {
319
365
  flushAccessible,
320
366
  settleAccessible,
321
367
  flushBehaviors,
368
+ teardownBehaviors,
322
369
  applyElementProps,
323
370
  applyAdoptedProps,
324
371
  assertPropsCanChange,
@@ -10,8 +10,9 @@ import {
10
10
  typeFromName,
11
11
  typeIsA,
12
12
  } from "@gtkx/runtime";
13
- import { getOrInsert, isDeepEqual, structuredClone } from "@gtkx/utils";
13
+ import { getOrInsert, isDeepEqual, kebabCase, structuredClone, unsanitizeIdentifier } from "@gtkx/utils";
14
14
  import type { DetachInfo, ElementBehavior, PlaceInfo, Props } from "./registry.js";
15
+ import { getPropertyName } from "./metadata.js";
15
16
  import { applyWrite } from "./signals.js";
16
17
  import { hasSameText } from "./text.js";
17
18
 
@@ -32,7 +33,7 @@ type ListHooks<P extends GObject.Object, I, H> = {
32
33
 
33
34
  type ListEntry = { item: unknown; handle: unknown };
34
35
  type ListState = { snapshot: unknown[]; entries: ListEntry[] };
35
- type DeferredState = { desired: unknown; isPresent: boolean; applied: unknown };
36
+ type DeferredState = { desired: unknown; isPresent: boolean; isScheduled: boolean; disconnect: (() => void) | null };
36
37
  type CanApply<P extends GObject.Object, V> = (object: P, value: V) => boolean;
37
38
  type ChildSetter = GObject.Object & { setChild: (child: Gtk.Widget | null) => void };
38
39
  type ContentSetter<C extends Gtk.Widget> = GObject.Object & { setContent: (content: C | null) => void };
@@ -170,15 +171,13 @@ const list = <P extends GObject.Object, I, H = void>(
170
171
  return behavior;
171
172
  };
172
173
 
173
- const flushDeferred = <P extends GObject.Object, V>(
174
+ const settleDeferred = <P extends GObject.Object, V>(
174
175
  object: GObject.Object,
175
- context: unknown,
176
+ state: DeferredState,
176
177
  prop: string,
177
178
  canApply: CanApply<P, V> | undefined,
178
179
  ): void => {
179
- const state = context as DeferredState;
180
-
181
- if (!state.isPresent || Object.is(state.applied, state.desired)) {
180
+ if (!state.isPresent || Object.is(Reflect.get(object, prop), state.desired)) {
182
181
  return;
183
182
  }
184
183
 
@@ -186,11 +185,53 @@ const flushDeferred = <P extends GObject.Object, V>(
186
185
  return;
187
186
  }
188
187
 
189
- applyWrite(() => {
188
+ applyWrite(prop, () => {
190
189
  Reflect.set(object, prop, state.desired);
191
190
  });
191
+ };
192
+
193
+ const scheduleSettle = <P extends GObject.Object, V>(
194
+ object: GObject.Object,
195
+ state: DeferredState,
196
+ prop: string,
197
+ canApply: CanApply<P, V> | undefined,
198
+ ): void => {
199
+ if (state.isScheduled) {
200
+ return;
201
+ }
202
+
203
+ state.isScheduled = true;
204
+
205
+ queueMicrotask(() => {
206
+ state.isScheduled = false;
207
+
208
+ if (state.disconnect !== null) {
209
+ settleDeferred(object, state, prop, canApply);
210
+ }
211
+ });
212
+ };
192
213
 
193
- state.applied = state.desired;
214
+ const watchDrift = <P extends GObject.Object, V>(
215
+ object: GObject.Object,
216
+ state: DeferredState,
217
+ prop: string,
218
+ canApply: CanApply<P, V> | undefined,
219
+ ): void => {
220
+ if (state.disconnect !== null) {
221
+ return;
222
+ }
223
+
224
+ const signal = `notify::${getPropertyName(object, prop) ?? unsanitizeIdentifier(kebabCase(prop))}`;
225
+
226
+ const handler = (): undefined => {
227
+ scheduleSettle(object, state, prop, canApply);
228
+ };
229
+
230
+ object.on(signal, handler);
231
+
232
+ state.disconnect = (): void => {
233
+ object.off(signal, handler);
234
+ };
194
235
  };
195
236
 
196
237
  const deferred = <P extends GObject.Object, V>(
@@ -198,7 +239,7 @@ const deferred = <P extends GObject.Object, V>(
198
239
  canApply?: CanApply<P, V>,
199
240
  ): ElementBehavior<P> => ({
200
241
  deferred: [prop],
201
- initialize: (): DeferredState => ({ desired: undefined, isPresent: false, applied: undefined }),
242
+ initialize: (): DeferredState => ({ desired: undefined, isPresent: false, isScheduled: false, disconnect: null }),
202
243
  update: (_object, _prev, next, context) => {
203
244
  const state = context as DeferredState;
204
245
  state.desired = next[prop];
@@ -207,14 +248,21 @@ const deferred = <P extends GObject.Object, V>(
207
248
  return [prop];
208
249
  },
209
250
  flush: (object, context) => {
210
- flushDeferred(object, context, prop, canApply);
251
+ const state = context as DeferredState;
252
+ settleDeferred(object, state, prop, canApply);
253
+ watchDrift(object, state, prop, canApply);
254
+ },
255
+ teardown: (_object, context) => {
256
+ const state = context as DeferredState;
257
+ state.disconnect?.();
258
+ state.disconnect = null;
211
259
  },
212
260
  });
213
261
 
214
262
  const controlledText = (prop: string): ElementBehavior =>
215
263
  value(prop, (object, next) => {
216
264
  if (!hasSameText(object, prop, next)) {
217
- applyWrite(() => {
265
+ applyWrite(prop, () => {
218
266
  Reflect.set(object, prop, next);
219
267
  });
220
268
  }
@@ -14,6 +14,7 @@ import {
14
14
  assertPropsCanChange,
15
15
  flushAccessible,
16
16
  flushBehaviors,
17
+ teardownBehaviors,
17
18
  } from "./apply-props.js";
18
19
  import { attachChild, detachChild } from "./child-routing.js";
19
20
  import { resolveElementNode } from "./instance.js";
@@ -161,7 +162,7 @@ const hostConfig = {
161
162
  suspendInstance: (): void => undefined,
162
163
  waitForCommitToBeReady: (): null => null,
163
164
  NotPendingTransition: null,
164
- HostTransitionContext: createContext(null) as unknown as ReactReconciler.ReactContext<null>,
165
+ HostTransitionContext: createContext(null),
165
166
  };
166
167
 
167
168
  const reconciler: ReactReconciler.Reconciler<Container, Instance, TextNode, unknown, unknown, object> =
@@ -190,6 +191,7 @@ function createPriorityTracker(): PriorityTracker {
190
191
 
191
192
  const detachElement = (instance: ElementNode): void => {
192
193
  disconnectAllHandlers(instance);
194
+ teardownBehaviors(instance);
193
195
 
194
196
  if (instance.object instanceof Gtk.Widget) {
195
197
  releaseStyle(instance.object);
@@ -1,10 +1,21 @@
1
- import { TYPE_INVALID, typeFromName, typeInterfaces, typeName, typeParent } from "@gtkx/runtime";
1
+ import type * as GObject from "@gtkx/gi/gobject";
2
+ import {
3
+ type AnyClass,
4
+ getClassType,
5
+ getDeclaredPropertyName,
6
+ TYPE_INVALID,
7
+ typeFromName,
8
+ typeInterfaces,
9
+ typeName,
10
+ typeParent,
11
+ } from "@gtkx/runtime";
2
12
  import { getOrInsert } from "@gtkx/utils";
3
- import { constructOnlyProps, constructProps, defaultProps, signals, userEventSignals } from "virtual:gtkx-config";
13
+ import { properties, type PropertyEntry, signals, userEventSignals } from "virtual:gtkx-config";
4
14
  import { deferredProps, type ElementBehavior, ELEMENTS } from "./registry.js";
5
15
 
6
16
  type TypeInfo = {
7
17
  typeName: string;
18
+ properties: Record<string, PropertyEntry>;
8
19
  signals: Record<string, string>;
9
20
  userEventSignals: Set<string>;
10
21
  behaviors: ElementBehavior[];
@@ -17,6 +28,12 @@ type TypeInfo = {
17
28
  defaults: Record<string, unknown>;
18
29
  };
19
30
 
31
+ const WRITABLE = 2;
32
+ const CONSTRUCT = 4;
33
+ const CONSTRUCT_ONLY = 8;
34
+ const NAME = 0;
35
+ const FLAGS = 1;
36
+ const DEFAULT_VALUE = 2;
20
37
  const ancestryCache: Map<string, string[]> = new Map();
21
38
  const typeInfoCache: Map<string, TypeInfo> = new Map();
22
39
 
@@ -60,11 +77,29 @@ const addAll = <T>(target: Set<T>, source: Iterable<T> | undefined): void => {
60
77
  const accumulateAncestor = (info: TypeInfo, ancestor: string): void => {
61
78
  Object.assign(info.signals, signals[ancestor] ?? {});
62
79
  addAll(info.userEventSignals, userEventSignals[ancestor]);
63
- addAll(info.constructOnly, constructOnlyProps[ancestor]);
64
- addAll(info.construct, constructProps[ancestor]);
65
80
  info.behaviors.push(...(ELEMENTS[ancestor]?.behaviors ?? []));
66
81
  };
67
82
 
83
+ const resolveProperty = (info: TypeInfo, name: string, entry: PropertyEntry): void => {
84
+ if ((entry[FLAGS] & CONSTRUCT_ONLY) !== 0) {
85
+ info.constructOnly.add(name);
86
+ }
87
+
88
+ if ((entry[FLAGS] & (WRITABLE | CONSTRUCT | CONSTRUCT_ONLY)) !== 0) {
89
+ info.construct.add(name);
90
+ }
91
+
92
+ if (entry.length > DEFAULT_VALUE) {
93
+ info.defaults[name] = entry[DEFAULT_VALUE];
94
+ }
95
+ };
96
+
97
+ const resolveProperties = (info: TypeInfo): void => {
98
+ for (const [name, entry] of Object.entries(info.properties)) {
99
+ resolveProperty(info, name, entry);
100
+ }
101
+ };
102
+
68
103
  const applyBehaviorFlags = (info: TypeInfo, behavior: ElementBehavior): void => {
69
104
  if (behavior.flush !== undefined) {
70
105
  info.hasFlush = true;
@@ -85,6 +120,7 @@ const buildTypeInfo = (name: string): TypeInfo => {
85
120
 
86
121
  const info: TypeInfo = {
87
122
  typeName: name,
123
+ properties: {},
88
124
  signals: {},
89
125
  userEventSignals: new Set(),
90
126
  behaviors: [],
@@ -105,12 +141,32 @@ const buildTypeInfo = (name: string): TypeInfo => {
105
141
  info.isLazy = chain.some((ancestor) => ELEMENTS[ancestor]?.isLazy === true);
106
142
 
107
143
  for (const ancestor of chain.toReversed()) {
108
- Object.assign(info.defaults, defaultProps[ancestor] ?? {});
144
+ Object.assign(info.properties, properties[ancestor] ?? {});
109
145
  }
110
146
 
147
+ resolveProperties(info);
148
+
111
149
  return info;
112
150
  };
113
151
 
114
152
  const typeInfoFor = (name: string): TypeInfo => getOrInsert(typeInfoCache, name, buildTypeInfo);
115
153
 
116
- export { typeInfoFor, type TypeInfo };
154
+ const getTypeInfo = (object: GObject.Object): TypeInfo | undefined => {
155
+ const name = typeName(getClassType(object.constructor as AnyClass));
156
+
157
+ return name === null ? undefined : typeInfoFor(name);
158
+ };
159
+
160
+ const hasProperty = (info: TypeInfo, accessor: string): boolean => Object.hasOwn(info.properties, accessor);
161
+
162
+ const propertyNameFor = (info: TypeInfo, accessor: string): string | undefined =>
163
+ info.properties[accessor]?.[NAME];
164
+
165
+ const getPropertyName = (object: GObject.Object, accessor: string): string | undefined => {
166
+ const info = getTypeInfo(object);
167
+
168
+ return (info === undefined ? undefined : propertyNameFor(info, accessor)) ??
169
+ getDeclaredPropertyName(object, accessor);
170
+ };
171
+
172
+ export { getPropertyName, hasProperty, typeInfoFor, type TypeInfo };