@gtkx/testing 1.0.0-rc.4 → 1.1.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 (212) hide show
  1. package/README.md +5 -5
  2. package/dist/accessible-native.d.ts +16 -0
  3. package/dist/accessible-native.d.ts.map +1 -0
  4. package/dist/accessible-native.js +206 -0
  5. package/dist/accessible-native.js.map +1 -0
  6. package/dist/act.d.ts +1 -3
  7. package/dist/act.d.ts.map +1 -1
  8. package/dist/act.js +1 -1
  9. package/dist/act.js.map +1 -1
  10. package/dist/build-queries.d.ts +27 -10
  11. package/dist/build-queries.d.ts.map +1 -1
  12. package/dist/build-queries.js +22 -24
  13. package/dist/build-queries.js.map +1 -1
  14. package/dist/cleanup-registry.d.ts +1 -1
  15. package/dist/cleanup-registry.d.ts.map +1 -1
  16. package/dist/cleanup-registry.js.map +1 -1
  17. package/dist/config.d.ts +8 -1
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +1 -0
  20. package/dist/config.js.map +1 -1
  21. package/dist/errors.d.ts +10 -1
  22. package/dist/errors.d.ts.map +1 -1
  23. package/dist/errors.js +10 -1
  24. package/dist/errors.js.map +1 -1
  25. package/dist/fire-event.d.ts +35 -6
  26. package/dist/fire-event.d.ts.map +1 -1
  27. package/dist/fire-event.js +23 -7
  28. package/dist/fire-event.js.map +1 -1
  29. package/dist/frame-sync.d.ts +3 -2
  30. package/dist/frame-sync.d.ts.map +1 -1
  31. package/dist/frame-sync.js +19 -9
  32. package/dist/frame-sync.js.map +1 -1
  33. package/dist/harness-window.d.ts +5 -0
  34. package/dist/harness-window.d.ts.map +1 -0
  35. package/dist/harness-window.js +17 -0
  36. package/dist/harness-window.js.map +1 -0
  37. package/dist/index.d.ts +14 -7
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +9 -5
  40. package/dist/index.js.map +1 -1
  41. package/dist/internal.d.ts +5 -0
  42. package/dist/internal.d.ts.map +1 -0
  43. package/dist/internal.js +5 -0
  44. package/dist/internal.js.map +1 -0
  45. package/dist/matchers.d.ts +88 -46
  46. package/dist/matchers.d.ts.map +1 -1
  47. package/dist/matchers.js +178 -20
  48. package/dist/matchers.js.map +1 -1
  49. package/dist/pretty-format.d.ts +2 -0
  50. package/dist/pretty-format.d.ts.map +1 -0
  51. package/dist/pretty-format.js +2 -0
  52. package/dist/pretty-format.js.map +1 -0
  53. package/dist/pretty-widget.d.ts +5 -2
  54. package/dist/pretty-widget.d.ts.map +1 -1
  55. package/dist/pretty-widget.js +75 -57
  56. package/dist/pretty-widget.js.map +1 -1
  57. package/dist/production.d.ts +8 -0
  58. package/dist/production.d.ts.map +1 -0
  59. package/dist/production.js +34 -0
  60. package/dist/production.js.map +1 -0
  61. package/dist/queries.d.ts +98 -56
  62. package/dist/queries.d.ts.map +1 -1
  63. package/dist/queries.js +123 -83
  64. package/dist/queries.js.map +1 -1
  65. package/dist/query-helpers.d.ts +44 -0
  66. package/dist/query-helpers.d.ts.map +1 -0
  67. package/dist/query-helpers.js +57 -0
  68. package/dist/query-helpers.js.map +1 -0
  69. package/dist/render-children.d.ts +9 -0
  70. package/dist/render-children.d.ts.map +1 -0
  71. package/dist/render-children.js +11 -0
  72. package/dist/render-children.js.map +1 -0
  73. package/dist/render.d.ts +12 -1
  74. package/dist/render.d.ts.map +1 -1
  75. package/dist/render.js +52 -16
  76. package/dist/render.js.map +1 -1
  77. package/dist/role-helpers.d.ts +12 -3
  78. package/dist/role-helpers.d.ts.map +1 -1
  79. package/dist/role-helpers.js +15 -5
  80. package/dist/role-helpers.js.map +1 -1
  81. package/dist/role-naming.d.ts +5 -0
  82. package/dist/role-naming.d.ts.map +1 -0
  83. package/dist/role-naming.js +96 -0
  84. package/dist/role-naming.js.map +1 -0
  85. package/dist/screenshot.d.ts +12 -20
  86. package/dist/screenshot.d.ts.map +1 -1
  87. package/dist/screenshot.js +179 -93
  88. package/dist/screenshot.js.map +1 -1
  89. package/dist/timers.d.ts +9 -0
  90. package/dist/timers.d.ts.map +1 -0
  91. package/dist/timers.js +21 -0
  92. package/dist/timers.js.map +1 -0
  93. package/dist/traversal.d.ts +7 -1
  94. package/dist/traversal.d.ts.map +1 -1
  95. package/dist/traversal.js +40 -6
  96. package/dist/traversal.js.map +1 -1
  97. package/dist/types.d.ts +32 -15
  98. package/dist/types.d.ts.map +1 -1
  99. package/dist/types.js.map +1 -1
  100. package/dist/user-event/adjustment.d.ts +3 -0
  101. package/dist/user-event/adjustment.d.ts.map +1 -1
  102. package/dist/user-event/adjustment.js +25 -3
  103. package/dist/user-event/adjustment.js.map +1 -1
  104. package/dist/user-event/click.d.ts +44 -8
  105. package/dist/user-event/click.d.ts.map +1 -1
  106. package/dist/user-event/click.js +176 -44
  107. package/dist/user-event/click.js.map +1 -1
  108. package/dist/user-event/column-header-click.d.ts +4 -0
  109. package/dist/user-event/column-header-click.d.ts.map +1 -0
  110. package/dist/user-event/column-header-click.js +55 -0
  111. package/dist/user-event/column-header-click.js.map +1 -0
  112. package/dist/user-event/event-wrapper.d.ts.map +1 -1
  113. package/dist/user-event/event-wrapper.js +34 -20
  114. package/dist/user-event/event-wrapper.js.map +1 -1
  115. package/dist/user-event/index.d.ts +80 -13
  116. package/dist/user-event/index.d.ts.map +1 -1
  117. package/dist/user-event/index.js +44 -26
  118. package/dist/user-event/index.js.map +1 -1
  119. package/dist/user-event/indexed-children.d.ts +10 -0
  120. package/dist/user-event/indexed-children.d.ts.map +1 -0
  121. package/dist/user-event/indexed-children.js +50 -0
  122. package/dist/user-event/indexed-children.js.map +1 -0
  123. package/dist/user-event/keyboard.d.ts.map +1 -1
  124. package/dist/user-event/keyboard.js +115 -31
  125. package/dist/user-event/keyboard.js.map +1 -1
  126. package/dist/user-event/list-row-click.d.ts +4 -0
  127. package/dist/user-event/list-row-click.d.ts.map +1 -0
  128. package/dist/user-event/list-row-click.js +38 -0
  129. package/dist/user-event/list-row-click.js.map +1 -0
  130. package/dist/user-event/native-click.d.ts +6 -0
  131. package/dist/user-event/native-click.d.ts.map +1 -0
  132. package/dist/user-event/native-click.js +36 -0
  133. package/dist/user-event/native-click.js.map +1 -0
  134. package/dist/user-event/notebook-tab-click.d.ts +5 -0
  135. package/dist/user-event/notebook-tab-click.d.ts.map +1 -0
  136. package/dist/user-event/notebook-tab-click.js +39 -0
  137. package/dist/user-event/notebook-tab-click.js.map +1 -0
  138. package/dist/user-event/pointer.d.ts +1 -1
  139. package/dist/user-event/pointer.d.ts.map +1 -1
  140. package/dist/user-event/pointer.js +33 -9
  141. package/dist/user-event/pointer.js.map +1 -1
  142. package/dist/user-event/selection.d.ts +11 -8
  143. package/dist/user-event/selection.d.ts.map +1 -1
  144. package/dist/user-event/selection.js +49 -38
  145. package/dist/user-event/selection.js.map +1 -1
  146. package/dist/user-event/text.d.ts +12 -5
  147. package/dist/user-event/text.d.ts.map +1 -1
  148. package/dist/user-event/text.js +151 -46
  149. package/dist/user-event/text.js.map +1 -1
  150. package/dist/wait-for.d.ts.map +1 -1
  151. package/dist/wait-for.js +4 -3
  152. package/dist/wait-for.js.map +1 -1
  153. package/dist/widget-accessible-properties.d.ts +14 -26
  154. package/dist/widget-accessible-properties.d.ts.map +1 -1
  155. package/dist/widget-accessible-properties.js +135 -190
  156. package/dist/widget-accessible-properties.js.map +1 -1
  157. package/dist/widget-getters.d.ts +11 -0
  158. package/dist/widget-getters.d.ts.map +1 -0
  159. package/dist/widget-getters.js +46 -0
  160. package/dist/widget-getters.js.map +1 -0
  161. package/dist/window-state.d.ts +10 -0
  162. package/dist/window-state.d.ts.map +1 -0
  163. package/dist/window-state.js +41 -0
  164. package/dist/window-state.js.map +1 -0
  165. package/dist/within.d.ts +3 -2
  166. package/dist/within.d.ts.map +1 -1
  167. package/dist/within.js +3 -2
  168. package/dist/within.js.map +1 -1
  169. package/package.json +21 -8
  170. package/src/accessible-native.ts +333 -0
  171. package/src/act.ts +1 -1
  172. package/src/build-queries.ts +47 -41
  173. package/src/cleanup-registry.ts +1 -1
  174. package/src/config.ts +10 -1
  175. package/src/errors.ts +12 -1
  176. package/src/fire-event.ts +61 -8
  177. package/src/frame-sync.ts +30 -9
  178. package/src/harness-window.ts +23 -0
  179. package/src/index.ts +18 -11
  180. package/src/internal.ts +12 -0
  181. package/src/matchers.ts +457 -72
  182. package/src/pretty-format.ts +1 -0
  183. package/src/pretty-widget.ts +98 -70
  184. package/src/production.ts +51 -0
  185. package/src/queries.ts +127 -89
  186. package/src/query-helpers.ts +97 -0
  187. package/src/render-children.ts +25 -0
  188. package/src/render.tsx +77 -17
  189. package/src/role-helpers.ts +16 -5
  190. package/src/role-naming.ts +99 -0
  191. package/src/screenshot.ts +266 -104
  192. package/src/timers.ts +31 -0
  193. package/src/traversal.ts +66 -6
  194. package/src/types.ts +34 -17
  195. package/src/user-event/adjustment.ts +31 -3
  196. package/src/user-event/click.ts +248 -52
  197. package/src/user-event/column-header-click.ts +76 -0
  198. package/src/user-event/event-wrapper.ts +41 -23
  199. package/src/user-event/index.ts +130 -40
  200. package/src/user-event/indexed-children.ts +75 -0
  201. package/src/user-event/keyboard.ts +161 -43
  202. package/src/user-event/list-row-click.ts +54 -0
  203. package/src/user-event/native-click.ts +51 -0
  204. package/src/user-event/notebook-tab-click.ts +57 -0
  205. package/src/user-event/pointer.ts +48 -9
  206. package/src/user-event/selection.ts +67 -41
  207. package/src/user-event/text.ts +192 -46
  208. package/src/wait-for.ts +4 -4
  209. package/src/widget-accessible-properties.ts +183 -230
  210. package/src/widget-getters.ts +85 -0
  211. package/src/window-state.ts +70 -0
  212. package/src/within.ts +3 -2
package/src/types.ts CHANGED
@@ -55,13 +55,19 @@ type MatcherOptions<T extends Gtk.Widget = Gtk.Widget> = {
55
55
  as?: WidgetType<T> | undefined;
56
56
  } & WaitForOptions;
57
57
 
58
- /** Constraints on a widget's numeric range value used by role queries. */
58
+ /**
59
+ * Constraints on a widget's numeric range value used by role queries. The numbers come from the
60
+ * accessibility tree and match within 0.001, the resolution GTK keeps them to.
61
+ */
59
62
  type ByRoleValue = {
60
63
  /** The current value. */
61
64
  now?: number | undefined;
62
65
  /** The lower bound of the value's range. */
63
66
  min?: number | undefined;
64
- /** The upper bound of the value's range. */
67
+ /**
68
+ * The highest value the widget can reach, which on a paged widget such as a scrollbar is its
69
+ * adjustment's upper bound less one page.
70
+ */
65
71
  max?: number | undefined;
66
72
  /** Matcher for the value's textual representation. */
67
73
  text?: Matcher | undefined;
@@ -87,7 +93,7 @@ type ByRoleOptions<T extends Gtk.Widget = Gtk.Widget> = MatcherOptions<T> & {
87
93
  description?: Matcher | undefined;
88
94
  /** Constraints on the widget's range value, each checked only when given. */
89
95
  value?: ByRoleValue | undefined;
90
- /** When true, include widgets excluded from the accessibility tree. */
96
+ /** When true, include widgets excluded from the accessibility tree. Widgets that are not mapped stay excluded. */
91
97
  hidden?: boolean | undefined;
92
98
  };
93
99
 
@@ -163,7 +169,12 @@ type QueryFamilies<Head extends unknown[]> = QueryFamily<"Role", "role", Head> &
163
169
  * React behavior toggles, error callbacks, and custom queries to bind.
164
170
  */
165
171
  type RenderOptions<Q extends QueryMap = Record<never, never>> = {
166
- /** Widget or root element to mount into; an undecorated harness window is created when omitted. */
172
+ /**
173
+ * Widget or root element to mount into; an undecorated harness window is created when omitted.
174
+ * A widget container that sits outside every toplevel, such as a bare `Gtk.Box`, keeps the tree
175
+ * out of reach of a pointer and a keyboard, so every `userEvent` helper aimed at it rejects
176
+ * after `actionabilityTimeout`; `fireEvent` drives such a tree instead.
177
+ */
167
178
  container?: Gtk.Widget | RootElement | undefined;
168
179
  /** Root of the subtree that bound queries search. */
169
180
  baseElement?: Container | undefined;
@@ -187,8 +198,11 @@ type DebugUtilities = {
187
198
  debug: (element?: Container | Container[], options?: PrettyWidgetOptions) => void;
188
199
  /** Prints the accessible roles found in the scope, with the widgets carrying each one. */
189
200
  logRoles: () => void;
190
- /** Captures a window, writes the image to a temporary file, and logs its path. */
191
- screenshot: (selector?: WindowSelector, options?: ScreenshotOptions) => Promise<ScreenshotResult>;
201
+ /**
202
+ * Captures what is on screen, which is the active toplevel window: GTK4 exposes no position for
203
+ * a toplevel, so windows cannot be composited into a single image.
204
+ */
205
+ screenshot: (options?: ScreenshotOptions) => Promise<ScreenshotResult>;
192
206
  };
193
207
 
194
208
  /** A captured screenshot: base64-encoded image data, its MIME type, and pixel dimensions. */
@@ -203,26 +217,27 @@ type ScreenshotResult = {
203
217
  height: number;
204
218
  };
205
219
 
206
- /** Options for capturing a screenshot: the poll timeout and interval, plus a rendering scale factor. */
220
+ /**
221
+ * Options for capturing a screenshot: the poll timeout and interval, a rendering scale factor, and
222
+ * a file to write the PNG to.
223
+ */
207
224
  type ScreenshotOptions = Pick<WaitForOptions, "timeout" | "interval"> & {
208
225
  /** Device scale factor applied when rendering. */
209
- scale?: number;
226
+ scale?: number | undefined;
227
+ /** File the PNG is written to, with its parent directories created as needed. */
228
+ path?: string | undefined;
210
229
  };
211
230
 
212
- /**
213
- * Selects the window to screenshot by index, or by title (exact string or regular expression);
214
- * undefined targets the default window.
215
- */
216
- type WindowSelector = number | string | RegExp | undefined;
217
-
218
231
  /**
219
232
  * Options for {@link renderHook}: an optional wrapper and the initial props (required unless the
220
233
  * props type permits undefined).
221
234
  */
222
235
  type RenderHookOptions<Props> = {
236
+ /** Component wrapped around the one calling the hook, such as a context provider. */
223
237
  wrapper?: WrapperComponent;
224
238
  } & (undefined extends Props
225
239
  ? {
240
+ /** Props the hook is invoked with until `rerender` is given new ones. */
226
241
  initialProps?: Props;
227
242
  }
228
243
  : {
@@ -234,8 +249,11 @@ type RenderHookOptions<Props> = {
234
249
  * props and to unmount.
235
250
  */
236
251
  type RenderHookResult<Result, Props> = {
237
- /** Holds the most recent value returned by the hook under `current`. */
238
- result: { current: Result };
252
+ /** Stable object the hook's return value is written to on every render. */
253
+ result: {
254
+ /** The hook's latest return value. */
255
+ current: Result;
256
+ };
239
257
  /** Re-invokes the hook, keeping the previous props when none are given. */
240
258
  rerender: (newProps?: Props) => Promise<void>;
241
259
  /** Unmounts the component that calls the hook. */
@@ -260,7 +278,6 @@ export {
260
278
  type DebugUtilities,
261
279
  type ScreenshotResult,
262
280
  type ScreenshotOptions,
263
- type WindowSelector,
264
281
  type RenderHookOptions,
265
282
  type RenderHookResult,
266
283
  };
@@ -1,4 +1,6 @@
1
1
  import * as Gtk from "@gtkx/gi/gtk";
2
+ import { scheduleNextFrame } from "../frame-sync.js";
3
+ import { getTypeTag } from "../widget-getters.js";
2
4
  import { wrapEvent } from "./event-wrapper.js";
3
5
 
4
6
  /** A scroll distance in pixels along each axis. */
@@ -23,7 +25,7 @@ const slide = (widget: Gtk.Widget, value: number): Promise<void> =>
23
25
  wrapEvent(widget, () => {
24
26
  if (!(widget instanceof Gtk.Range)) {
25
27
  throw new TypeError(
26
- `userEvent.slide requires a Gtk.Range (e.g. Gtk.Scale), got ${widget.constructor.name}`,
28
+ `userEvent.slide requires a Gtk.Range (e.g. Gtk.Scale), got ${getTypeTag(widget)}`,
27
29
  );
28
30
  }
29
31
 
@@ -44,22 +46,47 @@ const resolveScrollAdjustments = (widget: Gtk.Widget): ScrollAdjustments | null
44
46
  return null;
45
47
  };
46
48
 
49
+ const stepToward = (adjustment: Gtk.Adjustment, target: number, step: number): number => {
50
+ const value = adjustment.getValue();
51
+ adjustment.setValue(value < target ? Math.min(value + step, target) : Math.max(value - step, target));
52
+
53
+ return adjustment.getValue();
54
+ };
55
+
56
+ const rampTo = (adjustment: Gtk.Adjustment, target: number, step: number): void => {
57
+ let value = adjustment.getValue();
58
+
59
+ while (value !== target) {
60
+ const moved = stepToward(adjustment, target, step);
61
+
62
+ if (moved === value) {
63
+ return;
64
+ }
65
+
66
+ value = moved;
67
+ }
68
+ };
69
+
47
70
  const applyScrollDelta = (adjustment: Gtk.Adjustment | null, delta: number): void => {
48
71
  if (!adjustment || delta === 0) {
49
72
  return;
50
73
  }
51
74
 
52
- adjustment.setValue(adjustment.getValue() + delta);
75
+ const pageSize = adjustment.getPageSize();
76
+ rampTo(adjustment, adjustment.getValue() + delta, pageSize > 0 ? pageSize : Math.abs(delta));
53
77
  };
54
78
 
55
79
  /**
56
80
  * Adds the delta to the adjustments of the widget itself, or of its nearest Gtk.ScrolledWindow or
57
81
  * Gtk.Scrollable ancestor.
58
82
  *
83
+ * Each adjustment advances in viewport-sized steps rather than one jump, so virtualized views such
84
+ * as Gtk.ListView, Gtk.GridView and Gtk.ColumnView re-anchor onto the rows the new offset shows.
85
+ *
59
86
  * @throws When neither the widget nor any of its ancestors is scrollable.
60
87
  */
61
88
  const scroll = (widget: Gtk.Widget, delta: ScrollDelta): Promise<void> =>
62
- wrapEvent(widget, () => {
89
+ wrapEvent(widget, async () => {
63
90
  const adjustments = resolveScrollAdjustments(widget);
64
91
 
65
92
  if (!adjustments) {
@@ -70,6 +97,7 @@ const scroll = (widget: Gtk.Widget, delta: ScrollDelta): Promise<void> =>
70
97
 
71
98
  applyScrollDelta(adjustments.horizontal, delta.x ?? 0);
72
99
  applyScrollDelta(adjustments.vertical, delta.y ?? 0);
100
+ await scheduleNextFrame(widget);
73
101
  });
74
102
 
75
103
  export { slide, scroll, type ScrollDelta };
@@ -1,8 +1,29 @@
1
+ import * as Gdk from "@gtkx/gi/gdk";
1
2
  import * as Gtk from "@gtkx/gi/gtk";
2
- import { getOrCreateControllers, queryController } from "./controller.js";
3
+ import { hasSignalListener } from "@gtkx/runtime/internal";
4
+ import { callBooleanGetter, getCallableMethod, hasWidgetMethod } from "../widget-getters.js";
5
+ import { queryAllControllers } from "./controller.js";
3
6
  import { wrapEvent } from "./event-wrapper.js";
7
+ import { hasIndexedChildren, selectContainerChild, SELECTED_PROBE, unselectOtherChildren } from "./indexed-children.js";
8
+ import { isClickTransparent, type NativeClick, nativeClickFor } from "./native-click.js";
4
9
 
5
- const getPressPoint = (widget: Gtk.Widget): { x: number; y: number } => {
10
+ type PressPoint = { x: number; y: number };
11
+ type ClickPhase = "pressed" | "released";
12
+ type ClickSite = { gestures: Gtk.GestureClick[]; point: PressPoint };
13
+ type ClickScope = { isClicked: boolean; isInternalAllowed: boolean };
14
+
15
+ type ClickTarget = {
16
+ widget: Gtk.Widget;
17
+ container: Gtk.Widget | null;
18
+ gestures: Gtk.GestureClick[];
19
+ isClaiming: boolean;
20
+ native: NativeClick | null;
21
+ };
22
+
23
+ const CLICK_SIGNALS = ["pressed", "released"];
24
+ const PRIMARY_BUTTONS: Set<number> = new Set([0, Gdk.BUTTON_PRIMARY]);
25
+
26
+ const getCenterPoint = (widget: Gtk.Widget): PressPoint => {
6
27
  const width = widget.getWidth();
7
28
  const height = widget.getHeight();
8
29
 
@@ -13,46 +34,205 @@ const getPressPoint = (widget: Gtk.Widget): { x: number; y: number } => {
13
34
  return { x: 0, y: 0 };
14
35
  };
15
36
 
16
- const emitGesture = (widget: Gtk.Widget, controllers: Gtk.GestureClick[], nPress: number, signal: string): void => {
17
- const { x, y } = getPressPoint(widget);
37
+ const getClickPoint = (clicked: Gtk.Widget, carrier: Gtk.Widget): PressPoint => {
38
+ if (clicked === carrier) {
39
+ return getCenterPoint(clicked);
40
+ }
41
+
42
+ const [isComputed, bounds] = clicked.computeBounds(carrier);
18
43
 
44
+ if (!isComputed) {
45
+ return getCenterPoint(carrier);
46
+ }
47
+
48
+ return { x: bounds.getX() + bounds.getWidth() / 2, y: bounds.getY() + bounds.getHeight() / 2 };
49
+ };
50
+
51
+ const emitGesture = (point: PressPoint, controllers: Gtk.GestureClick[], nPress: number, signal: string): void => {
19
52
  for (const controller of controllers) {
20
- controller.emit(signal, nPress, x, y);
53
+ controller.emit(signal, nPress, point.x, point.y);
21
54
  }
22
55
  };
23
56
 
24
- const emitPress = (widget: Gtk.Widget, controllers: Gtk.GestureClick[], nPress: number): void => {
25
- emitGesture(widget, controllers, nPress, "pressed");
57
+ const emitClickPhase = (
58
+ widget: Gtk.Widget,
59
+ controllers: Gtk.GestureClick[],
60
+ nPress: number,
61
+ phase: ClickPhase,
62
+ ): void => {
63
+ emitGesture(getCenterPoint(widget), controllers, nPress, phase);
26
64
  };
27
65
 
28
- const emitRelease = (widget: Gtk.Widget, controllers: Gtk.GestureClick[], nPress: number): void => {
29
- emitGesture(widget, controllers, nPress, "released");
66
+ const emitSitePhase = (sites: ClickSite[], nPress: number, phase: ClickPhase): void => {
67
+ for (const site of sites) {
68
+ emitGesture(site.point, site.gestures, nPress, phase);
69
+ }
30
70
  };
31
71
 
32
- const emitClickSequence = (widget: Gtk.Widget, target: Gtk.Widget, nPress: number): Promise<void> =>
33
- wrapEvent(widget, () => {
34
- const controllers = getOrCreateControllers(target, Gtk.GestureClick);
72
+ const emitClickSequence = (sites: ClickSite[], nPress: number): void => {
73
+ for (let i = 1; i <= nPress; i++) {
74
+ emitSitePhase(sites, i, "pressed");
75
+ emitSitePhase(sites, i, "released");
76
+ }
77
+ };
35
78
 
36
- for (let i = 1; i <= nPress; i++) {
37
- emitPress(target, controllers, i);
38
- emitRelease(target, controllers, i);
39
- }
40
- });
79
+ const containerFor = (widget: Gtk.Widget): Gtk.Widget | null => {
80
+ const parent = widget.getParent();
41
81
 
42
- const findClickableAncestor = (widget: Gtk.Widget): Gtk.Widget | null => {
43
- let current = widget.getParent();
82
+ return parent !== null && hasWidgetMethod(widget, SELECTED_PROBE) && hasIndexedChildren(parent) ? parent : null;
83
+ };
44
84
 
45
- while (current) {
46
- if (current instanceof Gtk.Button || queryController(current, Gtk.GestureClick) !== null) {
47
- return current;
48
- }
85
+ const hasClickHandlers = (gesture: Gtk.GestureClick): boolean => hasSignalListener(gesture, CLICK_SIGNALS);
86
+ const isInternalGesture = (gesture: Gtk.GestureClick): boolean => !hasSignalListener(gesture);
87
+ const isPrimaryGesture = (gesture: Gtk.GestureClick): boolean => PRIMARY_BUTTONS.has(gesture.getButton());
88
+
89
+ const clickGestures = (widget: Gtk.Widget): Gtk.GestureClick[] =>
90
+ queryAllControllers(widget, Gtk.GestureClick).filter((gesture) => isPrimaryGesture(gesture));
91
+
92
+ const getAuthoredClickGestures = (widget: Gtk.Widget): Gtk.GestureClick[] =>
93
+ clickGestures(widget).filter((gesture) => hasClickHandlers(gesture));
94
+
95
+ const isClaimingTarget = (widget: Gtk.Widget): boolean =>
96
+ widget instanceof Gtk.Button || containerFor(widget) !== null;
97
+
98
+ const gestureTargetFor = (widget: Gtk.Widget, isInternalAllowed: boolean): ClickTarget | null => {
99
+ const gestures = clickGestures(widget);
100
+
101
+ if (isClaimingTarget(widget)) {
102
+ return { widget, container: containerFor(widget), gestures, isClaiming: true, native: null };
103
+ }
49
104
 
50
- current = current.getParent();
105
+ const authored = gestures.filter((gesture) => hasClickHandlers(gesture));
106
+
107
+ if (authored.length > 0) {
108
+ return { widget, container: null, gestures: authored, isClaiming: false, native: null };
109
+ }
110
+
111
+ if (isInternalAllowed && gestures.some((gesture) => isInternalGesture(gesture))) {
112
+ return { widget, container: null, gestures, isClaiming: true, native: null };
51
113
  }
52
114
 
53
115
  return null;
54
116
  };
55
117
 
118
+ const clickTargetFor = (widget: Gtk.Widget, scope: ClickScope): ClickTarget | null => {
119
+ if (isClickTransparent(widget)) {
120
+ return null;
121
+ }
122
+
123
+ const native = nativeClickFor(widget, scope.isClicked);
124
+
125
+ if (native !== null) {
126
+ return { widget, container: null, gestures: getAuthoredClickGestures(widget), isClaiming: true, native };
127
+ }
128
+
129
+ return gestureTargetFor(widget, scope.isInternalAllowed);
130
+ };
131
+
132
+ const isSelfClickTarget = (widget: Gtk.Widget): boolean =>
133
+ isClickTransparent(widget) || clickTargetFor(widget, { isClicked: true, isInternalAllowed: false }) !== null;
134
+
135
+ const nextClickWidget = (current: Gtk.Widget, target: ClickTarget | null): Gtk.Widget | null =>
136
+ target?.isClaiming === true ? null : current.getParent();
137
+
138
+ const collectClickTargets = (widget: Gtk.Widget): ClickTarget[] => {
139
+ const targets: ClickTarget[] = [];
140
+ let current: Gtk.Widget | null = widget;
141
+
142
+ while (current !== null) {
143
+ const isClicked = current === widget;
144
+ const scope = { isClicked, isInternalAllowed: !isClicked && targets.length === 0 };
145
+ const target = clickTargetFor(current, scope);
146
+ const next = nextClickWidget(current, target);
147
+
148
+ if (target !== null) {
149
+ targets.push(target);
150
+ }
151
+
152
+ current = next;
153
+ }
154
+
155
+ return targets;
156
+ };
157
+
158
+ const targetSites = (clicked: Gtk.Widget, target: ClickTarget): ClickSite[] => {
159
+ const { container } = target;
160
+ const site = { gestures: target.gestures, point: getClickPoint(clicked, target.widget) };
161
+
162
+ if (container === null) {
163
+ return [site];
164
+ }
165
+
166
+ return [site, { gestures: getAuthoredClickGestures(container), point: getClickPoint(clicked, container) }];
167
+ };
168
+
169
+ const isSingleClickActivating = (container: Gtk.Widget): boolean =>
170
+ callBooleanGetter(container, "getActivateOnSingleClick") ?? true;
171
+
172
+ const isMultipleSelection = (container: Gtk.Widget): boolean =>
173
+ getCallableMethod<[], Gtk.SelectionMode>(container, "getSelectionMode")?.() === Gtk.SelectionMode.MULTIPLE;
174
+
175
+ const isChildSelectable = (child: Gtk.Widget): boolean => callBooleanGetter(child, "getSelectable") ?? true;
176
+
177
+ const replaceContainerSelection = (container: Gtk.Widget, child: Gtk.Widget): void => {
178
+ if (!isChildSelectable(child)) {
179
+ return;
180
+ }
181
+
182
+ selectContainerChild(container, child);
183
+
184
+ if (isMultipleSelection(container)) {
185
+ unselectOtherChildren(container, child);
186
+ }
187
+ };
188
+
189
+ const isActivatedByClick = (container: Gtk.Widget, nPress: number): boolean =>
190
+ isSingleClickActivating(container) || nPress > 1;
191
+
192
+ const isSelectionReplacedByClick = (container: Gtk.Widget, nPress: number): boolean =>
193
+ nPress > 1 || !isSingleClickActivating(container);
194
+
195
+ const applyContainerOutcome = (target: ClickTarget, nPress: number): void => {
196
+ const { container, widget } = target;
197
+
198
+ if (container === null || widget.getParent() !== container) {
199
+ return;
200
+ }
201
+
202
+ if (isActivatedByClick(container, nPress)) {
203
+ widget.activate();
204
+ }
205
+
206
+ if (isSelectionReplacedByClick(container, nPress)) {
207
+ replaceContainerSelection(container, widget);
208
+ }
209
+ };
210
+
211
+ const applyClickOutcome = (target: ClickTarget, nPress: number): void => {
212
+ if (target.native === null) {
213
+ applyContainerOutcome(target, nPress);
214
+
215
+ return;
216
+ }
217
+
218
+ target.native(target.widget, nPress);
219
+ };
220
+
221
+ const applyClickOutcomes = (targets: ClickTarget[], nPress: number): void => {
222
+ for (const target of targets) {
223
+ applyClickOutcome(target, nPress);
224
+ }
225
+ };
226
+
227
+ const deliverClick = (widget: Gtk.Widget, nPress: number): Promise<void> =>
228
+ wrapEvent(widget, () => {
229
+ const targets = collectClickTargets(widget);
230
+ const sites = targets.flatMap((target) => targetSites(widget, target));
231
+ emitClickSequence(sites, nPress);
232
+ applyClickOutcomes(targets, nPress);
233
+ });
234
+
235
+ /* eslint-disable-next-line unicorn/consistent-boolean-name -- the boolean reports whether activation succeeded */
56
236
  const tryActivate = async (widget: Gtk.Widget): Promise<boolean> => {
57
237
  if (widget.getAccessibleRole() === Gtk.AccessibleRole.LABEL) {
58
238
  return false;
@@ -68,39 +248,55 @@ const tryActivate = async (widget: Gtk.Widget): Promise<boolean> => {
68
248
  };
69
249
 
70
250
  /**
71
- * Presses and releases a Gtk.Button, flips a Gtk.Switch, and otherwise activates the widget,
72
- * falling back to a click gesture on the nearest Gtk.Button or click-gesture ancestor when
73
- * activation does nothing. A widget with the label role is never activated.
251
+ * Activates a widget that neither claims the click nor carries a click gesture of its own, and
252
+ * otherwise delivers a press and release. The press travels outwards from the clicked widget
253
+ * through every widget carrying a click gesture with a pressed or released handler, the way GTK
254
+ * hands the same press to each of them, and stops at the first Gtk.Button or indexed child of a
255
+ * list box or flow box, which claims it. A gesture GTK attached itself takes the press only when no
256
+ * widget below it handles one, and stops it there, so an expander or a notebook tab nested in a row
257
+ * opens instead of activating the row. Coordinates are the clicked widget's position in each
258
+ * carrier, so a gesture the container of an indexed child carries reads the child's position rather
259
+ * than the container's center. An indexed child that the press reaches is then activated, or
260
+ * exclusively selected when its container does not activate on a single click. A widget with the
261
+ * label role is never activated, but does consume the click when it carries a gesture of its own.
262
+ *
263
+ * The press also stops at a widget whose click GTK4 implements in C on a gesture it attached
264
+ * itself, reading a GdkEvent that off-screen synthesis cannot produce. Gestures that widget carries
265
+ * of its own still take the press and release, the way GTK hands them the press before its own
266
+ * gesture claims it, and the same outcome is then applied through the public action GTK's own
267
+ * handler invokes: a list, grid, or column-view row is focused and selected, and activated as well
268
+ * on a second press or when its view activates on a single click; an expandable tree expander
269
+ * toggles its expansion, once per press, and only when it is the widget clicked, so a click on its
270
+ * child falls through to the enclosing row; a notebook tab, clicked directly or through its label,
271
+ * focuses its notebook and switches to its page; a column header sorts by its column. Sorting goes
272
+ * through Gtk.ColumnView.sortByColumn, so the primary sort column and order match what a pointer
273
+ * produces while previously sorted columns are dropped rather than kept as secondary keys. A
274
+ * column-view cell and the row that carries the column headers stand in the way of the click rather
275
+ * than taking it, so a click on either reaches the row or the view behind it.
74
276
  */
75
277
  const click = async (widget: Gtk.Widget): Promise<void> => {
76
- if (widget instanceof Gtk.Button) {
77
- await emitClickSequence(widget, widget, 1);
78
-
79
- return;
80
- }
81
-
82
- if (widget instanceof Gtk.Switch) {
83
- await wrapEvent(widget, () => {
84
- widget.setActive(!widget.getActive());
85
- });
86
-
87
- return;
88
- }
89
-
90
- if (await tryActivate(widget)) {
278
+ if (!isSelfClickTarget(widget) && (await tryActivate(widget))) {
91
279
  return;
92
280
  }
93
281
 
94
- const target = findClickableAncestor(widget);
95
-
96
- if (target) {
97
- await emitClickSequence(widget, target, 1);
98
- }
282
+ await deliverClick(widget, 1);
99
283
  };
100
284
 
101
- /** Emits a two-press click gesture at the widget's center, adding a Gtk.GestureClick when it has none. */
102
- const dblClick = (widget: Gtk.Widget): Promise<void> => emitClickSequence(widget, widget, 2);
103
- /** Emits a three-press click gesture at the widget's center, adding a Gtk.GestureClick when it has none. */
104
- const tripleClick = (widget: Gtk.Widget): Promise<void> => emitClickSequence(widget, widget, 3);
285
+ /**
286
+ * Delivers a two-press click gesture the way {@link click} delivers a single press, without trying
287
+ * activation first. A list box row or flow box child the presses reach, clicked directly or through
288
+ * a descendant, is activated and exclusively selected, as GTK's double-click path does whether or
289
+ * not the container activates on a single click. A widget whose click GTK4 implements itself
290
+ * receives that outcome once per press, so a list, grid, or column-view row is selected and then
291
+ * activated by the second press, a tree expander ends back where it started, a notebook tab stays on
292
+ * the page the first press opened, and a column header sorts and then inverts its order.
293
+ */
294
+ const dblClick = (widget: Gtk.Widget): Promise<void> => deliverClick(widget, 2);
295
+ /**
296
+ * Delivers a three-press click gesture the same way a double click is delivered, applying the same
297
+ * outcome to a list box row or flow box child the presses reach, and applying the outcome GTK4
298
+ * implements itself once per press to a row, tree expander, notebook tab, or column header.
299
+ */
300
+ const tripleClick = (widget: Gtk.Widget): Promise<void> => deliverClick(widget, 3);
105
301
 
106
- export { emitPress, emitRelease, click, dblClick, tripleClick };
302
+ export { clickGestures, emitClickPhase, getAuthoredClickGestures, click, dblClick, tripleClick };
@@ -0,0 +1,76 @@
1
+ import * as Gtk from "@gtkx/gi/gtk";
2
+ import { ancestorFor, children } from "../traversal.js";
3
+ import { getWidgetTextContent } from "../widget-accessible-properties.js";
4
+
5
+ const columnsFor = (view: Gtk.ColumnView): Gtk.ColumnViewColumn[] => {
6
+ const model = view.getColumns();
7
+ const columns: Gtk.ColumnViewColumn[] = [];
8
+
9
+ for (let index = 0; index < model.getNItems(); index++) {
10
+ const column = model.getItem(index);
11
+
12
+ if (column instanceof Gtk.ColumnViewColumn) {
13
+ columns.push(column);
14
+ }
15
+ }
16
+
17
+ return columns;
18
+ };
19
+
20
+ const headerIndexFor = (header: Gtk.Widget): number => {
21
+ const parent = header.getParent();
22
+
23
+ return parent === null ? -1 : [...children(parent)].indexOf(header);
24
+ };
25
+
26
+ const titledColumnFor = (columns: Gtk.ColumnViewColumn[], title: string | null): Gtk.ColumnViewColumn | null => {
27
+ const matches = columns.filter((column) => column.getTitle() === title);
28
+
29
+ return matches.length === 1 ? matches[0] ?? null : null;
30
+ };
31
+
32
+ const columnFor = (header: Gtk.Widget, view: Gtk.ColumnView): Gtk.ColumnViewColumn | null => {
33
+ const columns = columnsFor(view);
34
+ const picked = columns[headerIndexFor(header)] ?? null;
35
+ const title = getWidgetTextContent(header);
36
+
37
+ if (picked !== null && picked.getTitle() === title) {
38
+ return picked;
39
+ }
40
+
41
+ return titledColumnFor(columns, title) ?? picked;
42
+ };
43
+
44
+ const sortableColumnFor = (header: Gtk.Widget, view: Gtk.ColumnView): Gtk.ColumnViewColumn | null => {
45
+ const column = columnFor(header, view);
46
+
47
+ return column !== null && column.getSorter() !== null ? column : null;
48
+ };
49
+
50
+ const invertedSortDirection = (order: Gtk.SortType): Gtk.SortType =>
51
+ order === Gtk.SortType.ASCENDING ? Gtk.SortType.DESCENDING : Gtk.SortType.ASCENDING;
52
+
53
+ const nextSortDirection = (view: Gtk.ColumnView, column: Gtk.ColumnViewColumn): Gtk.SortType => {
54
+ const sorter = view.getSorter();
55
+
56
+ if (!(sorter instanceof Gtk.ColumnViewSorter) || sorter.getPrimarySortColumn() !== column) {
57
+ return Gtk.SortType.ASCENDING;
58
+ }
59
+
60
+ return invertedSortDirection(sorter.getPrimarySortOrder());
61
+ };
62
+
63
+ const applyHeaderClick = (header: Gtk.Widget, nPress: number): void => {
64
+ const view = ancestorFor(header, Gtk.ColumnView);
65
+ const column = view === null ? null : sortableColumnFor(header, view);
66
+
67
+ if (view === null || column === null) {
68
+ return;
69
+ }
70
+
71
+ for (let press = 1; press <= nPress; press++) {
72
+ view.sortByColumn(column, nextSortDirection(view, column));
73
+ }
74
+ };
75
+
76
+ export { applyHeaderClick };