@gtkx/testing 1.0.0 → 1.2.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 (128) hide show
  1. package/README.md +1 -1
  2. package/dist/act.d.ts +1 -3
  3. package/dist/act.d.ts.map +1 -1
  4. package/dist/act.js +1 -1
  5. package/dist/act.js.map +1 -1
  6. package/dist/build-queries.d.ts +1 -1
  7. package/dist/build-queries.js +1 -1
  8. package/dist/build-queries.js.map +1 -1
  9. package/dist/config.d.ts +8 -1
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +1 -0
  12. package/dist/config.js.map +1 -1
  13. package/dist/frame-sync.d.ts +2 -2
  14. package/dist/frame-sync.d.ts.map +1 -1
  15. package/dist/frame-sync.js +10 -10
  16. package/dist/frame-sync.js.map +1 -1
  17. package/dist/harness-window.d.ts +2 -1
  18. package/dist/harness-window.d.ts.map +1 -1
  19. package/dist/harness-window.js +12 -1
  20. package/dist/harness-window.js.map +1 -1
  21. package/dist/internal.d.ts +1 -0
  22. package/dist/internal.d.ts.map +1 -1
  23. package/dist/internal.js +1 -0
  24. package/dist/internal.js.map +1 -1
  25. package/dist/matchers.d.ts.map +1 -1
  26. package/dist/matchers.js +22 -2
  27. package/dist/matchers.js.map +1 -1
  28. package/dist/pretty-widget.d.ts.map +1 -1
  29. package/dist/pretty-widget.js +2 -1
  30. package/dist/pretty-widget.js.map +1 -1
  31. package/dist/production.d.ts.map +1 -1
  32. package/dist/production.js +2 -2
  33. package/dist/production.js.map +1 -1
  34. package/dist/render.d.ts +12 -1
  35. package/dist/render.d.ts.map +1 -1
  36. package/dist/render.js +45 -11
  37. package/dist/render.js.map +1 -1
  38. package/dist/role-helpers.d.ts.map +1 -1
  39. package/dist/role-helpers.js +2 -1
  40. package/dist/role-helpers.js.map +1 -1
  41. package/dist/screenshot.d.ts.map +1 -1
  42. package/dist/screenshot.js +8 -14
  43. package/dist/screenshot.js.map +1 -1
  44. package/dist/timers.d.ts +9 -0
  45. package/dist/timers.d.ts.map +1 -0
  46. package/dist/timers.js +21 -0
  47. package/dist/timers.js.map +1 -0
  48. package/dist/traversal.d.ts +4 -1
  49. package/dist/traversal.d.ts.map +1 -1
  50. package/dist/traversal.js +16 -1
  51. package/dist/traversal.js.map +1 -1
  52. package/dist/types.d.ts +6 -1
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/types.js.map +1 -1
  55. package/dist/user-event/adjustment.d.ts.map +1 -1
  56. package/dist/user-event/adjustment.js +2 -1
  57. package/dist/user-event/adjustment.js.map +1 -1
  58. package/dist/user-event/click.d.ts +5 -4
  59. package/dist/user-event/click.d.ts.map +1 -1
  60. package/dist/user-event/click.js +5 -4
  61. package/dist/user-event/click.js.map +1 -1
  62. package/dist/user-event/column-header-click.d.ts.map +1 -1
  63. package/dist/user-event/column-header-click.js +2 -12
  64. package/dist/user-event/column-header-click.js.map +1 -1
  65. package/dist/user-event/event-wrapper.d.ts.map +1 -1
  66. package/dist/user-event/event-wrapper.js +34 -21
  67. package/dist/user-event/event-wrapper.js.map +1 -1
  68. package/dist/user-event/index.d.ts +36 -10
  69. package/dist/user-event/index.d.ts.map +1 -1
  70. package/dist/user-event/index.js +4 -3
  71. package/dist/user-event/index.js.map +1 -1
  72. package/dist/user-event/keyboard.d.ts.map +1 -1
  73. package/dist/user-event/keyboard.js +93 -38
  74. package/dist/user-event/keyboard.js.map +1 -1
  75. package/dist/user-event/list-row-click.d.ts.map +1 -1
  76. package/dist/user-event/list-row-click.js +4 -5
  77. package/dist/user-event/list-row-click.js.map +1 -1
  78. package/dist/user-event/native-click.d.ts.map +1 -1
  79. package/dist/user-event/native-click.js +4 -0
  80. package/dist/user-event/native-click.js.map +1 -1
  81. package/dist/user-event/notebook-tab-click.d.ts +5 -0
  82. package/dist/user-event/notebook-tab-click.d.ts.map +1 -0
  83. package/dist/user-event/notebook-tab-click.js +39 -0
  84. package/dist/user-event/notebook-tab-click.js.map +1 -0
  85. package/dist/user-event/text.d.ts +6 -3
  86. package/dist/user-event/text.d.ts.map +1 -1
  87. package/dist/user-event/text.js +88 -33
  88. package/dist/user-event/text.js.map +1 -1
  89. package/dist/wait-for.d.ts.map +1 -1
  90. package/dist/wait-for.js +4 -3
  91. package/dist/wait-for.js.map +1 -1
  92. package/dist/widget-getters.d.ts +4 -1
  93. package/dist/widget-getters.d.ts.map +1 -1
  94. package/dist/widget-getters.js +29 -2
  95. package/dist/widget-getters.js.map +1 -1
  96. package/dist/window-state.d.ts +10 -0
  97. package/dist/window-state.d.ts.map +1 -0
  98. package/dist/window-state.js +41 -0
  99. package/dist/window-state.js.map +1 -0
  100. package/package.json +15 -7
  101. package/src/act.ts +1 -1
  102. package/src/build-queries.ts +1 -1
  103. package/src/config.ts +10 -1
  104. package/src/frame-sync.ts +15 -10
  105. package/src/harness-window.ts +15 -1
  106. package/src/internal.ts +1 -0
  107. package/src/matchers.ts +29 -2
  108. package/src/pretty-widget.ts +2 -1
  109. package/src/production.ts +2 -3
  110. package/src/render.tsx +67 -12
  111. package/src/role-helpers.ts +2 -1
  112. package/src/screenshot.ts +10 -17
  113. package/src/timers.ts +31 -0
  114. package/src/traversal.ts +22 -0
  115. package/src/types.ts +6 -1
  116. package/src/user-event/adjustment.ts +2 -1
  117. package/src/user-event/click.ts +5 -4
  118. package/src/user-event/column-header-click.ts +2 -16
  119. package/src/user-event/event-wrapper.ts +41 -25
  120. package/src/user-event/index.ts +40 -13
  121. package/src/user-event/keyboard.ts +124 -54
  122. package/src/user-event/list-row-click.ts +4 -7
  123. package/src/user-event/native-click.ts +5 -0
  124. package/src/user-event/notebook-tab-click.ts +57 -0
  125. package/src/user-event/text.ts +113 -38
  126. package/src/wait-for.ts +4 -4
  127. package/src/widget-getters.ts +55 -2
  128. package/src/window-state.ts +70 -0
@@ -2,35 +2,38 @@ import * as Gtk from "@gtkx/gi/gtk";
2
2
  import { runInAct } from "../act.js";
3
3
  import { getConfig } from "../config.js";
4
4
  import { formatRole } from "../role-helpers.js";
5
+ import { delay, now } from "../timers.js";
6
+ import { getWidgetAccessibleName } from "../widget-accessible-properties.js";
7
+ import { getTypeTag, isDefaultWidgetName } from "../widget-getters.js";
8
+ import { isWindowAllocated, isWindowBlockedByModal } from "../window-state.js";
5
9
 
6
10
  const NOT_SENSITIVE = "it is not sensitive (the widget or one of its ancestors is disabled)";
11
+ const NOT_ROOTED = "it is not inside a toplevel (it was removed from the widget tree, or was never added to one)";
12
+ const WINDOW_NOT_VISIBLE = "its window is not visible (it was hidden, or it was never shown)";
7
13
  const WINDOW_NOT_ALLOCATED = "its window has not been allocated a size";
8
14
  const NOT_MAPPED = "it is not mapped (it is not shown on screen, e.g. it is hidden or on a non-visible page)";
9
- const WINDOW_NOT_ACTIVE = "its window never became active";
15
+ const BLOCKED_BY_MODAL = "its window is blocked by a modal window holding the grab";
16
+ const ACTIONABLE_HOP_MS = 1;
10
17
 
11
- const actionableHop = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 1));
12
-
13
- const canDisplayDeliverActivation = (window: Gtk.Window): boolean =>
14
- window.getDisplay().getDefaultSeat() !== null;
15
-
16
- const findWindowActionabilityFailure = (widget: Gtk.Widget, root: Gtk.Window): string | null => {
17
- const [isComputed, allocation] = root.computeBounds(root);
18
-
19
- if (!isComputed || allocation.getWidth() === 0) {
20
- return WINDOW_NOT_ALLOCATED;
18
+ const findWindowActionabilityFailure = (window: Gtk.Window): string | null => {
19
+ if (!window.getVisible()) {
20
+ return WINDOW_NOT_VISIBLE;
21
21
  }
22
22
 
23
- if (!widget.getMapped()) {
24
- return NOT_MAPPED;
23
+ if (!isWindowAllocated(window)) {
24
+ return WINDOW_NOT_ALLOCATED;
25
25
  }
26
26
 
27
- if (canDisplayDeliverActivation(root) && !root.isActive()) {
28
- return WINDOW_NOT_ACTIVE;
27
+ if (isWindowBlockedByModal(window)) {
28
+ return BLOCKED_BY_MODAL;
29
29
  }
30
30
 
31
31
  return null;
32
32
  };
33
33
 
34
+ const findRootActionabilityFailure = (root: Gtk.Root): string | null =>
35
+ root instanceof Gtk.Window ? findWindowActionabilityFailure(root) : null;
36
+
34
37
  const findActionabilityFailure = (widget: Gtk.Widget): string | null => {
35
38
  if (!widget.isSensitive()) {
36
39
  return NOT_SENSITIVE;
@@ -38,28 +41,41 @@ const findActionabilityFailure = (widget: Gtk.Widget): string | null => {
38
41
 
39
42
  const root = widget.getRoot();
40
43
 
41
- if (!(root instanceof Gtk.Window) || !root.getVisible()) {
42
- return null;
44
+ if (root === null) {
45
+ return NOT_ROOTED;
43
46
  }
44
47
 
45
- return findWindowActionabilityFailure(widget, root);
48
+ const rootFailure = findRootActionabilityFailure(root);
49
+
50
+ if (rootFailure !== null) {
51
+ return rootFailure;
52
+ }
53
+
54
+ return widget.getMapped() ? null : NOT_MAPPED;
46
55
  };
47
56
 
48
- const describeWidget = (widget: Gtk.Widget): string => {
49
- const tag = widget.constructor.name;
57
+ const attribute = (key: string, value: string | null): string => (value === null ? "" : ` ${key}="${value}"`);
58
+
59
+ const widgetNameAttribute = (widget: Gtk.Widget): string => {
50
60
  const name = widget.getName();
51
- const nameAttribute = name && !name.endsWith(tag) ? ` name="${name}"` : "";
52
61
 
53
- return `<${tag}${nameAttribute} role="${formatRole(widget.getAccessibleRole())}">`;
62
+ return attribute("name", isDefaultWidgetName(widget, name) ? null : name);
63
+ };
64
+
65
+ const describeWidget = (widget: Gtk.Widget): string => {
66
+ const accessibleName = attribute("accessible-name", getWidgetAccessibleName(widget));
67
+ const role = attribute("role", formatRole(widget.getAccessibleRole()));
68
+
69
+ return `<${getTypeTag(widget)}${accessibleName}${widgetNameAttribute(widget)}${role}>`;
54
70
  };
55
71
 
56
72
  const waitForActionable = async (widget: Gtk.Widget): Promise<void> => {
57
73
  const timeout = getConfig().actionabilityTimeout;
58
- const deadline = Date.now() + timeout;
74
+ const deadline = now() + timeout;
59
75
  let failure = findActionabilityFailure(widget);
60
76
 
61
- while (failure !== null && Date.now() < deadline) {
62
- await actionableHop();
77
+ while (failure !== null && now() < deadline) {
78
+ await delay(ACTIONABLE_HOP_MS);
63
79
  failure = findActionabilityFailure(widget);
64
80
  }
65
81
 
@@ -1,4 +1,5 @@
1
1
  import type * as Gtk from "@gtkx/gi/gtk";
2
+ import { delay } from "../timers.js";
2
3
  import { scroll, slide } from "./adjustment.js";
3
4
  import { click, dblClick, tripleClick } from "./click.js";
4
5
  import { drag, dragAndDrop, drop, hover, longPress, rotate, swipe, unhover, zoom } from "./gesture.js";
@@ -34,10 +35,11 @@ type UserEvent = {
34
35
  * itself, where the gestures that widget carries of its own still take the press and the same
35
36
  * outcome is then applied through GTK's own public action: a list, grid, or column-view row is
36
37
  * focused and selected, and activated as well when its view activates on a single click; an
37
- * expandable tree expander that is itself the clicked widget toggles its expansion; a column
38
- * header sorts by its column. A column-view cell and the row carrying the column headers stand
39
- * in the way of the click rather than taking it, so a click on either reaches the row or the
40
- * view behind it.
38
+ * expandable tree expander that is itself the clicked widget toggles its expansion; a notebook
39
+ * tab, clicked directly or through its label, focuses its notebook and switches to its page; a
40
+ * column header sorts by its column. A column-view cell and the row carrying the column headers
41
+ * stand in the way of the click rather than taking it, so a click on either reaches the row or
42
+ * the view behind it.
41
43
  */
42
44
  click: typeof click;
43
45
  /**
@@ -45,19 +47,23 @@ type UserEvent = {
45
47
  * activation first, activating a list box row or flow box child on that path and replacing its
46
48
  * container's selection whether or not that container activates on a single click. A widget
47
49
  * whose click GTK4 implements itself receives that outcome once per press, so a row is selected
48
- * and activated by the second press, a tree expander ends back where it started, and a column
49
- * header inverts its order after sorting.
50
+ * and activated by the second press, a tree expander ends back where it started, a notebook tab
51
+ * stays on the page the first press opened, and a column header inverts its order after sorting.
50
52
  */
51
53
  dblClick: typeof dblClick;
52
54
  /**
53
55
  * Delivers a three-press click gesture the same way a double click is delivered, applying the
54
56
  * same outcome to a list box row or flow box child, and applying the outcome GTK4 implements
55
- * itself once per press to a row, tree expander, or column header.
57
+ * itself once per press to a row, tree expander, notebook tab, or column header.
56
58
  */
57
59
  tripleClick: typeof tripleClick;
58
60
  /** Moves focus within the widget's root, forward by default and backward with `isShiftHeld`. */
59
61
  tab: typeof tab;
60
- /** Focuses an editable widget, applies any initial selection, and inserts the text at the cursor. */
62
+ /**
63
+ * Focuses an editable widget the way clicking into it does, leaving its text unselected, applies
64
+ * any initial selection, and inserts the text at the cursor, deleting the text the widget has
65
+ * selected first, the way GTK4 does.
66
+ */
61
67
  type: typeof type;
62
68
  /** Focuses an editable widget and deletes its whole text, leaving nothing selected. */
63
69
  clear: typeof clear;
@@ -65,7 +71,10 @@ type UserEvent = {
65
71
  copy: typeof copy;
66
72
  /** Writes an editable widget's current selection to the clipboard and deletes it. */
67
73
  cut: typeof cut;
68
- /** Inserts the given text, or the clipboard's text, at an editable widget's cursor. */
74
+ /**
75
+ * Inserts the given text, or the clipboard's text, at an editable widget's cursor, deleting the
76
+ * text the widget has selected first, the way GTK4 does.
77
+ */
69
78
  paste: typeof paste;
70
79
  /**
71
80
  * Selects the items at those positions in a view or drop-down, or selects the indexed children of
@@ -96,7 +105,25 @@ type UserEvent = {
96
105
  slide: typeof slide;
97
106
  /** Adds the delta to the adjustments of the widget itself, or of its nearest scrollable ancestor. */
98
107
  scroll: typeof scroll;
99
- /** Sends a key sequence, dispatching matching shortcuts and tracking held modifiers across calls. */
108
+ /**
109
+ * Sends a key sequence along the chain GTK4 propagates a key event through: the application's
110
+ * accelerators first, then the capture-phase controllers from the root down to the event widget,
111
+ * the event widget's target-phase controllers, and the bubble-phase controllers from it back up
112
+ * to the root, so a key controller or shortcut controller a container or window above the widget
113
+ * carries receives the key too, each in the phase it declares. The event widget is the editable
114
+ * delegate GTK4 focuses when the widget has one, the `Gtk.Text` inside a `Gtk.Entry` for
115
+ * instance, so the delegate stands ahead of the widget in the chain. A shortcut controller whose
116
+ * scope is not local runs where it registered instead, the root for a global one and the nearest
117
+ * `Gtk.ShortcutManager` for a managed one, so it fires wherever focus sits inside that subtree
118
+ * and never at its own widget, while a shortcut whose widget is insensitive or unmapped stays
119
+ * inert wherever it sits. The first controller that handles a press ends the chain, and a
120
+ * `Gdk.KEY_Return` press none of them handled reaches an editable widget's activate handler.
121
+ * Built-in key bindings take part, since GTK4 carries them on a shortcut controller: a
122
+ * `Gtk.Text`'s arrow keys or a `Gtk.TextView`'s undo can consume a press before an ancestor
123
+ * sees it. Key controllers GTK4 attaches itself are the exception, since they read a GDK event
124
+ * that off-screen synthesis cannot produce, so only the key controllers the tree connected a
125
+ * `key-pressed` or `key-released` handler to take part. Held modifiers carry across calls.
126
+ */
100
127
  keyboard: (widget: Gtk.Widget, input: string) => Promise<void>;
101
128
  /**
102
129
  * Applies a pointer token to the click gestures the widget already carries, tracking whether the
@@ -117,12 +144,12 @@ const userEvent: UserEvent = {
117
144
  setup: (options?: UserEventOptions): UserEvent => createInstance(createInitialState(), options ?? {}),
118
145
  };
119
146
 
120
- const settle = (delay: number | null | undefined): Promise<void> => {
121
- if (delay === null || delay === undefined) {
147
+ const settle = (ms: number | null | undefined): Promise<void> => {
148
+ if (ms === null || ms === undefined) {
122
149
  return Promise.resolve();
123
150
  }
124
151
 
125
- return new Promise((resolve) => setTimeout(resolve, delay));
152
+ return delay(ms);
126
153
  };
127
154
 
128
155
  function createInstance(state: UserEventState, options: UserEventOptions): UserEvent {
@@ -1,10 +1,11 @@
1
1
  import * as Gdk from "@gtkx/gi/gdk";
2
2
  import * as Gio from "@gtkx/gi/gio";
3
3
  import * as Gtk from "@gtkx/gi/gtk";
4
+ import { hasSignalListener } from "@gtkx/runtime/internal";
4
5
  import type { UserEventState } from "./state.js";
5
6
  import { getEditableDelegate } from "../editable.js";
6
7
  import { fireEvent } from "../fire-event.js";
7
- import { getOrCreateControllers } from "./controller.js";
8
+ import { ancestors, descendants } from "../traversal.js";
8
9
  import { wrapEvent } from "./event-wrapper.js";
9
10
 
10
11
  /** Options for `userEvent.tab`. */
@@ -15,6 +16,8 @@ type TabOptions = {
15
16
 
16
17
  type KeyAction = { keyval: number; isPress: boolean };
17
18
  type ParseStep = { actions: KeyAction[]; next: number };
19
+ type KeyStopController = Gtk.EventControllerKey | Gtk.ShortcutController;
20
+ type KeyStop = { widget: Gtk.Widget; controller: KeyStopController };
18
21
 
19
22
  const KEY_MAP: Record<string, number> = {
20
23
  Enter: Gdk.KEY_Return,
@@ -49,6 +52,8 @@ const KEY_MAP: Record<string, number> = {
49
52
  F12: Gdk.KEY_F12,
50
53
  };
51
54
 
55
+ const KEY_CONTROLLER_SIGNALS = ["key-pressed", "key-released"];
56
+
52
57
  const MODIFIER_KEYVAL_TO_MASK: Record<number, number> = {
53
58
  [Gdk.KEY_Shift_L]: Gdk.ModifierType.SHIFT_MASK,
54
59
  [Gdk.KEY_Shift_R]: Gdk.ModifierType.SHIFT_MASK,
@@ -180,6 +185,61 @@ const isTriggerMatch = (
180
185
  return false;
181
186
  };
182
187
 
188
+ const controllersOn = function* (widget: Gtk.Widget): Generator<Gtk.EventController> {
189
+ const controllers = widget.observeControllers();
190
+ const count = controllers.getNItems();
191
+
192
+ for (let i = 0; i < count; i++) {
193
+ const controller = controllers.getItem(i);
194
+
195
+ if (controller instanceof Gtk.EventController) {
196
+ yield controller;
197
+ }
198
+ }
199
+ };
200
+
201
+ const hasShortcut = (controller: Gtk.ShortcutController, shortcut: Gtk.Shortcut): boolean => {
202
+ const count = controller.getNItems();
203
+
204
+ for (let i = 0; i < count; i++) {
205
+ if (controller.getItem(i) === shortcut) {
206
+ return true;
207
+ }
208
+ }
209
+
210
+ return false;
211
+ };
212
+
213
+ const isManagingShortcut = (controller: Gtk.EventController, shortcut: Gtk.Shortcut): boolean =>
214
+ controller instanceof Gtk.ShortcutController &&
215
+ controller.getScope() !== Gtk.ShortcutScope.LOCAL &&
216
+ hasShortcut(controller, shortcut);
217
+
218
+ const managedShortcutHost = (manager: Gtk.Widget, shortcut: Gtk.Shortcut): Gtk.Widget | null => {
219
+ for (const current of [manager, ...descendants(manager)]) {
220
+ if (controllersOn(current).some((controller) => isManagingShortcut(controller, shortcut))) {
221
+ return current;
222
+ }
223
+ }
224
+
225
+ return null;
226
+ };
227
+
228
+ const isMovingFocus = (action: Gtk.ShortcutAction): boolean =>
229
+ action instanceof Gtk.SignalAction && action.getSignalName() === "move-focus";
230
+
231
+ const isShortcutLive = (host: Gtk.Widget): boolean => host.isSensitive() && host.getMapped();
232
+
233
+ const didRunShortcutAction = (shortcut: Gtk.Shortcut, host: Gtk.Widget): boolean => {
234
+ const action = shortcut.getAction();
235
+
236
+ if (action === null || isMovingFocus(action) || !isShortcutLive(host)) {
237
+ return false;
238
+ }
239
+
240
+ return action.activate(0 as Gtk.ShortcutActionFlags, host, shortcut.getArguments());
241
+ };
242
+
183
243
  const didActivateShortcut = (
184
244
  shortcut: Gtk.Shortcut,
185
245
  widget: Gtk.Widget,
@@ -190,13 +250,7 @@ const didActivateShortcut = (
190
250
  return false;
191
251
  }
192
252
 
193
- const action = shortcut.getAction();
194
-
195
- if (action instanceof Gtk.SignalAction && action.getSignalName() === "move-focus") {
196
- return false;
197
- }
198
-
199
- return action?.activate(0 as Gtk.ShortcutActionFlags, widget, shortcut.getArguments()) ?? false;
253
+ return didRunShortcutAction(shortcut, managedShortcutHost(widget, shortcut) ?? widget);
200
254
  };
201
255
 
202
256
  const didActivateMatchingShortcut = (
@@ -222,23 +276,6 @@ const didActivateMatchingShortcut = (
222
276
  return false;
223
277
  };
224
278
 
225
- const didDispatchShortcutsOnWidget = (widget: Gtk.Widget, keyval: number, modifiers: number): boolean => {
226
- const controllers = widget.observeControllers();
227
-
228
- for (let i = 0; i < controllers.getNItems(); i++) {
229
- const controller = controllers.getItem(i);
230
-
231
- if (
232
- controller instanceof Gtk.ShortcutController &&
233
- didActivateMatchingShortcut(controller, widget, keyval, modifiers)
234
- ) {
235
- return true;
236
- }
237
- }
238
-
239
- return false;
240
- };
241
-
242
279
  const isAccelMatch = (accel: string, keyval: number, modifiers: Gdk.ModifierType): boolean => {
243
280
  const [isParsed, accelKeyval, accelModifiers] = Gtk.acceleratorParse(accel);
244
281
 
@@ -281,59 +318,92 @@ const didDispatchApplicationAccels = (widget: Gtk.Widget, keyval: number, modifi
281
318
  return root instanceof Gtk.Window && didDispatchAccelsOnWindow(root, keyval, modifiers);
282
319
  };
283
320
 
284
- const didDispatchDelegateShortcuts = (widget: Gtk.Widget, keyval: number, modifiers: number): boolean => {
285
- const delegate = getEditableDelegate(widget);
321
+ const isKeyStopController = (controller: Gtk.EventController): controller is KeyStopController => {
322
+ if (controller instanceof Gtk.ShortcutController) {
323
+ return controller.getScope() === Gtk.ShortcutScope.LOCAL;
324
+ }
286
325
 
287
- return delegate !== null && didDispatchShortcutsOnWidget(delegate, keyval, modifiers);
326
+ return controller instanceof Gtk.EventControllerKey && hasSignalListener(controller, KEY_CONTROLLER_SIGNALS);
288
327
  };
289
328
 
290
- const didDispatchShortcutsOnAncestors = (widget: Gtk.Widget, keyval: number, modifiers: number): boolean => {
291
- for (let current: Gtk.Widget | null = widget; current; current = current.getParent()) {
292
- if (didDispatchShortcutsOnWidget(current, keyval, modifiers)) {
293
- return true;
329
+ const keyStopsInPhase = (widget: Gtk.Widget, phase: Gtk.PropagationPhase): KeyStop[] => {
330
+ const stops: KeyStop[] = [];
331
+
332
+ for (const controller of controllersOn(widget)) {
333
+ if (controller.getPropagationPhase() === phase && isKeyStopController(controller)) {
334
+ stops.push({ widget, controller });
294
335
  }
295
336
  }
296
337
 
297
- return false;
338
+ return stops;
339
+ };
340
+
341
+ const propagationChain = (widget: Gtk.Widget): Gtk.Widget[] => {
342
+ const delegate = getEditableDelegate(widget);
343
+ const below = delegate === null ? [] : [delegate];
344
+
345
+ return [...below, widget, ...ancestors(widget)];
298
346
  };
299
347
 
300
- const didDispatchShortcuts = (widget: Gtk.Widget, keyval: number, modifiers: number): boolean =>
348
+ const keyPropagationChain = (widget: Gtk.Widget): KeyStop[] => {
349
+ const chain = propagationChain(widget);
350
+ const [eventWidget = widget] = chain;
351
+
352
+ return [
353
+ ...chain.toReversed().flatMap((current) => keyStopsInPhase(current, Gtk.PropagationPhase.CAPTURE)),
354
+ ...keyStopsInPhase(eventWidget, Gtk.PropagationPhase.TARGET),
355
+ ...chain.flatMap((current) => keyStopsInPhase(current, Gtk.PropagationPhase.BUBBLE)),
356
+ ];
357
+ };
358
+
359
+ const didStopHandlePress = (stop: KeyStop, keyval: number, modifiers: Gdk.ModifierType): boolean => {
360
+ const { controller, widget } = stop;
361
+
362
+ if (controller instanceof Gtk.ShortcutController) {
363
+ return didActivateMatchingShortcut(controller, widget, keyval, modifiers);
364
+ }
365
+
366
+ return controller.emit("key-pressed", keyval, 0, modifiers);
367
+ };
368
+
369
+ const didHandlePress = (widget: Gtk.Widget, keyval: number, modifiers: Gdk.ModifierType): boolean =>
301
370
  didDispatchApplicationAccels(widget, keyval, modifiers) ||
302
- didDispatchDelegateShortcuts(widget, keyval, modifiers) ||
303
- didDispatchShortcutsOnAncestors(widget, keyval, modifiers);
371
+ keyPropagationChain(widget).some((stop) => didStopHandlePress(stop, keyval, modifiers));
304
372
 
305
- const handleKeyPress = async (widget: Gtk.Widget, keyval: number, modifiers: number): Promise<void> => {
306
- const isHandled = didDispatchShortcuts(widget, keyval, modifiers);
373
+ const emitKeyReleased = (widget: Gtk.Widget, keyval: number, modifiers: Gdk.ModifierType): void => {
374
+ for (const stop of keyPropagationChain(widget)) {
375
+ if (stop.controller instanceof Gtk.EventControllerKey) {
376
+ stop.controller.emit("key-released", keyval, 0, modifiers);
377
+ }
378
+ }
379
+ };
307
380
 
308
- if (!isHandled && keyval === Gdk.KEY_Return && widget instanceof Gtk.Editable) {
381
+ const applyKeyPress = async (widget: Gtk.Widget, keyval: number, modifiers: Gdk.ModifierType): Promise<void> => {
382
+ if (didHandlePress(widget, keyval, modifiers)) {
383
+ return;
384
+ }
385
+
386
+ if (keyval === Gdk.KEY_Return && widget instanceof Gtk.Editable) {
309
387
  await fireEvent(widget, "activate");
310
388
  }
311
389
  };
312
390
 
313
- const applyKeyAction = async (
314
- widget: Gtk.Widget,
315
- controllers: Gtk.EventControllerKey[],
316
- state: UserEventState,
317
- action: KeyAction,
318
- ): Promise<void> => {
391
+ const applyKeyAction = async (widget: Gtk.Widget, state: UserEventState, action: KeyAction): Promise<void> => {
319
392
  updateModifierState(state, action);
320
- const signalName = action.isPress ? "key-pressed" : "key-released";
321
-
322
- for (const controller of controllers) {
323
- controller.emit(signalName, action.keyval, 0, state.modifierState);
324
- }
325
393
 
326
394
  if (action.isPress) {
327
- await handleKeyPress(widget, action.keyval, state.modifierState);
395
+ await applyKeyPress(widget, action.keyval, state.modifierState);
396
+
397
+ return;
328
398
  }
399
+
400
+ emitKeyReleased(widget, action.keyval, state.modifierState);
329
401
  };
330
402
 
331
403
  const keyboard = (state: UserEventState, widget: Gtk.Widget, input: string): Promise<void> =>
332
404
  wrapEvent(widget, async () => {
333
- const controllers = getOrCreateControllers(widget, Gtk.EventControllerKey);
334
-
335
405
  for (const action of parseKeyboardInput(input)) {
336
- await applyKeyAction(widget, controllers, state, action);
406
+ await applyKeyAction(widget, state, action);
337
407
  }
338
408
  });
339
409
 
@@ -1,5 +1,6 @@
1
1
  import type * as Gtk from "@gtkx/gi/gtk";
2
2
  import * as GLib from "@gtkx/gi/glib";
3
+ import { ancestors } from "../traversal.js";
3
4
  import { callBooleanGetter, hasWidgetMethod } from "../widget-getters.js";
4
5
 
5
6
  const SELECT_ACTION = "listitem.select";
@@ -7,14 +8,10 @@ const SINGLE_CLICK_ACTIVATE_GETTER = "getSingleClickActivate";
7
8
  const DOUBLE_CLICK_PRESS = 2;
8
9
 
9
10
  const viewFor = (row: Gtk.Widget): Gtk.Widget | null => {
10
- let current: Gtk.Widget | null = row.getParent();
11
-
12
- while (current !== null) {
13
- if (hasWidgetMethod(current, SINGLE_CLICK_ACTIVATE_GETTER)) {
14
- return current;
11
+ for (const ancestor of ancestors(row)) {
12
+ if (hasWidgetMethod(ancestor, SINGLE_CLICK_ACTIVATE_GETTER)) {
13
+ return ancestor;
15
14
  }
16
-
17
- current = current.getParent();
18
15
  }
19
16
 
20
17
  return null;
@@ -2,6 +2,7 @@ import * as Gtk from "@gtkx/gi/gtk";
2
2
  import { getWidgetTypeName } from "../widget-getters.js";
3
3
  import { applyHeaderClick } from "./column-header-click.js";
4
4
  import { applyRowClick } from "./list-row-click.js";
5
+ import { applyTabClick, isNotebookTab } from "./notebook-tab-click.js";
5
6
 
6
7
  type NativeClick = (widget: Gtk.Widget, nPress: number) => void;
7
8
 
@@ -40,6 +41,10 @@ const nativeClickFor = (widget: Gtk.Widget, isClicked: boolean): NativeClick | n
40
41
  return applyHeaderClick;
41
42
  }
42
43
 
44
+ if (isNotebookTab(widget)) {
45
+ return applyTabClick;
46
+ }
47
+
43
48
  return isFactoryRow(widget) ? applyRowClick : null;
44
49
  };
45
50
 
@@ -0,0 +1,57 @@
1
+ import * as Gtk from "@gtkx/gi/gtk";
2
+ import { ancestorFor } from "../traversal.js";
3
+
4
+ type NotebookTab = { notebook: Gtk.Notebook; index: number };
5
+
6
+ const tabLabelsFor = (notebook: Gtk.Notebook): (Gtk.Widget | null)[] => {
7
+ const labels: (Gtk.Widget | null)[] = [];
8
+
9
+ for (let index = 0; index < notebook.getNPages(); index++) {
10
+ const child = notebook.getNthPage(index);
11
+ labels.push(child === null ? null : notebook.getPage(child).tab);
12
+ }
13
+
14
+ return labels;
15
+ };
16
+
17
+ const isNested = (outer: Gtk.Widget, inner: Gtk.Widget): boolean => outer === inner || inner.isAncestor(outer);
18
+
19
+ const isSameTab = (tab: Gtk.Widget, label: Gtk.Widget | null): boolean =>
20
+ label !== null && (isNested(tab, label) || isNested(label, tab));
21
+
22
+ const tabIndexFor = (notebook: Gtk.Notebook, tab: Gtk.Widget): number => {
23
+ const matches = tabLabelsFor(notebook)
24
+ .map((label, index) => (isSameTab(tab, label) ? index : -1))
25
+ .filter((index) => index >= 0);
26
+
27
+ return matches.length === 1 ? matches[0] ?? -1 : -1;
28
+ };
29
+
30
+ const notebookTabFor = (widget: Gtk.Widget): NotebookTab | null => {
31
+ if (widget.getAccessibleRole() !== Gtk.AccessibleRole.TAB) {
32
+ return null;
33
+ }
34
+
35
+ const notebook = ancestorFor(widget, Gtk.Notebook);
36
+ const index = notebook === null ? -1 : tabIndexFor(notebook, widget);
37
+
38
+ return notebook === null || index < 0 ? null : { notebook, index };
39
+ };
40
+
41
+ const isNotebookTab = (widget: Gtk.Widget): boolean => notebookTabFor(widget) !== null;
42
+
43
+ const applyTabClick = (widget: Gtk.Widget): void => {
44
+ const tab = notebookTabFor(widget);
45
+
46
+ if (tab === null) {
47
+ return;
48
+ }
49
+
50
+ if (tab.notebook.getFocusOnClick()) {
51
+ tab.notebook.grabFocus();
52
+ }
53
+
54
+ tab.notebook.setCurrentPage(tab.index);
55
+ };
56
+
57
+ export { applyTabClick, isNotebookTab };