@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/screenshot.ts CHANGED
@@ -1,12 +1,51 @@
1
+ import * as Gdk from "@gtkx/gi/gdk";
2
+ import * as Graphene from "@gtkx/gi/graphene";
1
3
  import * as Gsk from "@gtkx/gi/gsk";
2
4
  import * as Gtk from "@gtkx/gi/gtk";
3
- import { mkdtempSync, writeFileSync } from "node:fs";
4
- import { tmpdir } from "node:os";
5
- import { join } from "node:path";
6
- import type { ScreenshotOptions, ScreenshotResult, WindowSelector } from "./types.js";
5
+ import { mkdirSync, writeFileSync } from "node:fs";
6
+ import { dirname } from "node:path";
7
+ import type { ScreenshotOptions, ScreenshotResult } from "./types.js";
8
+ import { getConfig } from "./config.js";
9
+ import { now } from "./timers.js";
10
+ import { descendants } from "./traversal.js";
7
11
  import { waitFor } from "./wait-for.js";
12
+ import { activeToplevel, mappedToplevels } from "./window-state.js";
13
+
14
+ type FrameProbe = {
15
+ counter: bigint | null;
16
+ startedAt: number;
17
+ };
18
+
19
+ type CaptureFailure = "no-size" | "no-content";
20
+
21
+ type CaptureState = {
22
+ probe: FrameProbe;
23
+ failure: CaptureFailure | null;
24
+ };
25
+
26
+ type CaptureResult =
27
+ { status: "captured"; result: ScreenshotResult } |
28
+ { status: "failed"; failure: CaptureFailure };
29
+
30
+ type CaptureOutcome =
31
+ { status: "captured"; result: ScreenshotResult } |
32
+ { status: "stalled"; failure: CaptureFailure };
8
33
 
9
34
  const DEFAULT_SCREENSHOT_INTERVAL = 10;
35
+ const PRESENTATION_PROBE_MS = 250;
36
+
37
+ const NOT_PRESENTING_MESSAGE =
38
+ "Screenshot failed: the display is not presenting frames to this window, so GTK has no rendered " +
39
+ "content to capture. The window is hidden, minimized, or its compositor is not presenting it; this " +
40
+ "is a display problem, not a widget problem.";
41
+
42
+ const EMPTY_WIDGET_HINT =
43
+ "The display is presenting frames to this window, so the widget itself is empty: it painted nothing.";
44
+
45
+ const OTHER_FAILURE_HINT =
46
+ "The display is presenting frames to this window, so the capture failed for another reason.";
47
+
48
+ const NOTHING_ON_SCREEN_MESSAGE = "Nothing is on screen to capture: no toplevel window is mapped";
10
49
 
11
50
  const bytesToBase64 = (bytes: number[]): string => {
12
51
  return Buffer.from(bytes).toString("base64");
@@ -16,162 +55,285 @@ const describeWidgetState = (widget: Gtk.Widget): string =>
16
55
  `realized=${String(widget.getRealized())} mapped=${String(widget.getMapped())} ` +
17
56
  `visible=${String(widget.getVisible())}`;
18
57
 
19
- const captureSnapshot = (widget: Gtk.Widget, scale: number): ScreenshotResult => {
20
- const paintable = new Gtk.WidgetPaintable({ widget });
58
+ const getMappedPopovers = (widget: Gtk.Widget): Gtk.Popover[] => {
59
+ const popovers: Gtk.Popover[] = [];
60
+
61
+ for (const descendant of descendants(widget)) {
62
+ if (descendant instanceof Gtk.Popover && descendant.getMapped()) {
63
+ popovers.push(descendant);
64
+ }
65
+ }
66
+
67
+ return popovers;
68
+ };
69
+
70
+ const appendPopover = (snapshot: Gtk.Snapshot, target: Gtk.Widget, popover: Gtk.Popover): void => {
71
+ const [hasBounds, bounds] = popover.computeBounds(target);
72
+ const paintable = new Gtk.WidgetPaintable({ widget: popover });
21
73
  const width = paintable.getIntrinsicWidth();
22
74
  const height = paintable.getIntrinsicHeight();
23
75
 
24
- if (width <= 0 || height <= 0) {
25
- throw new Error(`Widget has no size: ensure it is realized and visible (${describeWidgetState(widget)})`);
76
+ if (!hasBounds || width <= 0 || height <= 0) {
77
+ return;
26
78
  }
27
79
 
28
- const snapshot = new Gtk.Snapshot();
29
- snapshot.scale(scale, scale);
80
+ snapshot.save();
81
+ snapshot.translate(new Graphene.Point({ x: bounds.origin.x, y: bounds.origin.y }));
30
82
  paintable.snapshot(snapshot, width, height);
31
- const renderNode = snapshot.toNode();
83
+ snapshot.restore();
84
+ };
32
85
 
33
- if (!renderNode) {
34
- throw new Error(`Widget produced no render content (${describeWidgetState(widget)})`);
86
+ const appendPopovers = (snapshot: Gtk.Snapshot, target: Gtk.Widget): void => {
87
+ for (const popover of getMappedPopovers(target)) {
88
+ appendPopover(snapshot, target, popover);
35
89
  }
90
+ };
36
91
 
37
- const display = widget.getDisplay();
38
- const renderer = new Gsk.CairoRenderer();
39
- renderer.realizeForDisplay(display);
92
+ const getSurface = (widget: Gtk.Widget): Gdk.Surface | null => widget.getNative()?.getSurface() ?? null;
93
+ const isSurfaceOnScreen = (widget: Gtk.Widget): boolean => getSurface(widget)?.getMapped() ?? false;
40
94
 
41
- try {
42
- const texture = renderer.renderTexture(renderNode, null);
43
- const pngBytes = texture.saveToPngBytes();
44
- const data = pngBytes.getData();
95
+ const getFrameClock = (widget: Gtk.Widget): Gdk.FrameClock | null =>
96
+ widget.getRoot()?.getFrameClock() ?? widget.getFrameClock();
45
97
 
46
- if (!data) {
47
- throw new Error("Failed to serialize screenshot to PNG");
48
- }
98
+ const getFrameCounter = (widget: Gtk.Widget): bigint | null => getFrameClock(widget)?.getFrameCounter() ?? null;
49
99
 
50
- return {
51
- data: bytesToBase64(data),
52
- mimeType: "image/png",
53
- width: Math.round(width * scale),
54
- height: Math.round(height * scale),
55
- };
56
- } finally {
57
- renderer.unrealize();
100
+ const describePresentation = (widget: Gtk.Widget): string =>
101
+ `${describeWidgetState(widget)} surfaceMapped=${String(isSurfaceOnScreen(widget))} ` +
102
+ `frameCounter=${String(getFrameCounter(widget))}`;
103
+
104
+ const requestFrame = (widget: Gtk.Widget): void => {
105
+ getFrameClock(widget)?.requestPhase(Gdk.FrameClockPhase.UPDATE);
106
+ };
107
+
108
+ const startFrameProbe = (widget: Gtk.Widget): FrameProbe => {
109
+ requestFrame(widget);
110
+
111
+ return { counter: getFrameCounter(widget), startedAt: now() };
112
+ };
113
+
114
+ const updateFrameProbe = (widget: Gtk.Widget, probe: FrameProbe): void => {
115
+ requestFrame(widget);
116
+
117
+ if (probe.counter !== null) {
118
+ return;
119
+ }
120
+
121
+ const counter = getFrameCounter(widget);
122
+
123
+ if (counter !== null) {
124
+ probe.counter = counter;
125
+ probe.startedAt = now();
58
126
  }
59
127
  };
60
128
 
61
- /**
62
- * Captures a PNG snapshot of a widget, retrying until it has a paintable size.
63
- *
64
- * @param widget The widget to render to an image.
65
- * @param options Optional scale, timeout, and retry interval.
66
- * @returns The base64-encoded PNG data along with its mime type and dimensions.
67
- */
68
- const screenshot = async (widget: Gtk.Widget, options?: ScreenshotOptions): Promise<ScreenshotResult> => {
69
- const scale = options?.scale ?? 1;
129
+ const hasFrameAdvanced = (widget: Gtk.Widget, probe: FrameProbe): boolean => {
130
+ const counter = getFrameCounter(widget);
70
131
 
71
- if (!Number.isFinite(scale) || scale <= 0) {
72
- throw new Error(`Screenshot scale must be a positive number, got ${String(scale)}`);
132
+ return counter !== null && probe.counter !== null && counter > probe.counter;
133
+ };
134
+
135
+ const isPresenting = (widget: Gtk.Widget, probe: FrameProbe): boolean =>
136
+ isSurfaceOnScreen(widget) && hasFrameAdvanced(widget, probe);
137
+
138
+ const isPresentationStalled = (widget: Gtk.Widget, probe: FrameProbe): boolean =>
139
+ now() - probe.startedAt >= PRESENTATION_PROBE_MS && !isPresenting(widget, probe);
140
+
141
+ const allocateRoot = (widget: Gtk.Widget): void => {
142
+ const root = widget.getRoot();
143
+
144
+ if (!root) {
145
+ return;
73
146
  }
74
147
 
75
- return waitFor(() => captureSnapshot(widget, scale), {
76
- timeout: options?.timeout,
77
- interval: options?.interval ?? DEFAULT_SCREENSHOT_INTERVAL,
78
- });
148
+ const width = root.getWidth();
149
+ const height = root.getHeight();
150
+
151
+ if (width > 0 && height > 0) {
152
+ root.allocate(width, height, -1, null);
153
+ }
79
154
  };
80
155
 
81
- const firstToplevelWindow = (windows: Gtk.Widget[]): Gtk.Window => {
82
- const [first] = windows;
156
+ const isDirectlyPaintable = (child: Gtk.Widget): boolean =>
157
+ child.getMapped() && child.getWidth() > 0 && !(child instanceof Gtk.Popover);
83
158
 
84
- if (!(first instanceof Gtk.Window)) {
85
- throw new TypeError("First toplevel is not a Window");
159
+ const getPaintableNode = (widget: Gtk.Widget, scale: number, width: number, height: number): Gsk.RenderNode | null => {
160
+ const paintable = new Gtk.WidgetPaintable({ widget });
161
+ const snapshot = new Gtk.Snapshot();
162
+ snapshot.scale(scale, scale);
163
+ paintable.snapshot(snapshot, width, height);
164
+ appendPopovers(snapshot, widget);
165
+
166
+ return snapshot.toNode();
167
+ };
168
+
169
+ const getChildrenNode = (widget: Gtk.Widget, scale: number): Gsk.RenderNode | null => {
170
+ const snapshot = new Gtk.Snapshot();
171
+ snapshot.scale(scale, scale);
172
+
173
+ for (let child = widget.getFirstChild(); child !== null; child = child.getNextSibling()) {
174
+ if (isDirectlyPaintable(child)) {
175
+ widget.snapshotChild(child, snapshot);
176
+ }
86
177
  }
87
178
 
88
- return first;
179
+ appendPopovers(snapshot, widget);
180
+
181
+ return snapshot.toNode();
89
182
  };
90
183
 
91
- const windowAtIndex = (windows: Gtk.Widget[], index: number): Gtk.Window => {
92
- const indexed = windows[index];
184
+ const getRenderNode = (widget: Gtk.Widget, scale: number, width: number, height: number): Gsk.RenderNode | null => {
185
+ const presented = getPaintableNode(widget, scale, width, height);
93
186
 
94
- if (!(indexed instanceof Gtk.Window)) {
95
- throw new TypeError(`Window at index ${String(index)} not found`);
187
+ if (presented) {
188
+ return presented;
96
189
  }
97
190
 
98
- return indexed;
191
+ allocateRoot(widget);
192
+
193
+ return getPaintableNode(widget, scale, width, height) ?? getChildrenNode(widget, scale);
99
194
  };
100
195
 
101
- const isWindow = (widget: Gtk.Widget): widget is Gtk.Window => widget instanceof Gtk.Window;
196
+ const renderToPng = (widget: Gtk.Widget, node: Gsk.RenderNode, width: number, height: number): ScreenshotResult => {
197
+ const renderer = new Gsk.CairoRenderer();
198
+ renderer.realizeForDisplay(widget.getDisplay());
102
199
 
103
- const hasMatchingTitle = (window: Gtk.Window, selector: string | RegExp): boolean => {
104
- const title = window.getTitle() ?? "";
200
+ try {
201
+ const viewport = Graphene.Rect.alloc().init(0, 0, width, height);
202
+ const texture = renderer.renderTexture(node, viewport);
203
+ const data = texture.saveToPngBytes().getData();
105
204
 
106
- return selector instanceof RegExp ? selector.test(title) : title.includes(selector);
205
+ if (!data) {
206
+ throw new Error("Failed to serialize screenshot to PNG");
207
+ }
208
+
209
+ return { data: bytesToBase64(data), mimeType: "image/png", width, height };
210
+ } finally {
211
+ renderer.unrealize();
212
+ }
107
213
  };
108
214
 
109
- const describeTitleSelector = (selector: string | RegExp): string =>
110
- selector instanceof RegExp ? selector.toString() : `"${selector}"`;
215
+ const captureSnapshot = (widget: Gtk.Widget, scale: number): CaptureResult => {
216
+ const paintable = new Gtk.WidgetPaintable({ widget });
217
+ const width = paintable.getIntrinsicWidth();
218
+ const height = paintable.getIntrinsicHeight();
111
219
 
112
- const windowByTitle = (windows: Gtk.Widget[], selector: string | RegExp): Gtk.Window => {
113
- const found = windows.filter(isWindow).find((window) => hasMatchingTitle(window, selector));
220
+ if (width <= 0 || height <= 0) {
221
+ return { status: "failed", failure: "no-size" };
222
+ }
223
+
224
+ const renderNode = getRenderNode(widget, scale, width, height);
114
225
 
115
- if (!found) {
116
- throw new Error(`No window found with title matching ${describeTitleSelector(selector)}`);
226
+ if (!renderNode) {
227
+ return { status: "failed", failure: "no-content" };
117
228
  }
118
229
 
119
- return found;
230
+ const scaledWidth = Math.round(width * scale);
231
+ const scaledHeight = Math.round(height * scale);
232
+
233
+ return { status: "captured", result: renderToPng(widget, renderNode, scaledWidth, scaledHeight) };
120
234
  };
121
235
 
122
- const resolveWindow = (selector?: WindowSelector): Gtk.Window => {
123
- const windows = Gtk.Window.listToplevels();
236
+ const failureMessage = (widget: Gtk.Widget, failure: CaptureFailure): string =>
237
+ failure === "no-size"
238
+ ? `Widget has no size: ensure it is realized and visible (${describeWidgetState(widget)})`
239
+ : `Widget produced no render content (${describeWidgetState(widget)})`;
124
240
 
125
- if (windows.length === 0) {
126
- throw new Error("No windows available for screenshot");
127
- }
241
+ const notPresentingMessage = (widget: Gtk.Widget): string =>
242
+ `${NOT_PRESENTING_MESSAGE} (${describePresentation(widget)})`;
243
+
244
+ const stalledMessage = (widget: Gtk.Widget, failure: CaptureFailure): string =>
245
+ `${notPresentingMessage(widget)}\n\n${failureMessage(widget, failure)}`;
246
+
247
+ const presentingHint = (failure: CaptureFailure | null): string =>
248
+ failure === "no-content" ? EMPTY_WIDGET_HINT : OTHER_FAILURE_HINT;
128
249
 
129
- if (selector === undefined) {
130
- return firstToplevelWindow(windows);
250
+ const timeoutHint = (widget: Gtk.Widget, state: CaptureState): string =>
251
+ isPresenting(widget, state.probe) ? presentingHint(state.failure) : notPresentingMessage(widget);
252
+
253
+ const explainTimeout = (error: Error, widget: Gtk.Widget, state: CaptureState): Error =>
254
+ getConfig().getElementError(`${error.message}\n\n${timeoutHint(widget, state)}`);
255
+
256
+ const attemptCapture = (widget: Gtk.Widget, scale: number, state: CaptureState): CaptureOutcome => {
257
+ const attempt = captureSnapshot(widget, scale);
258
+
259
+ if (attempt.status === "captured") {
260
+ return attempt;
131
261
  }
132
262
 
133
- if (typeof selector === "number") {
134
- return windowAtIndex(windows, selector);
263
+ state.failure = attempt.failure;
264
+ updateFrameProbe(widget, state.probe);
265
+
266
+ if (isPresentationStalled(widget, state.probe)) {
267
+ return { status: "stalled", failure: attempt.failure };
135
268
  }
136
269
 
137
- return windowByTitle(windows, selector);
270
+ throw new Error(failureMessage(widget, attempt.failure));
138
271
  };
139
272
 
140
- const saveScreenshotToTempFile = (result: ScreenshotResult): string => {
141
- const dir = mkdtempSync(join(tmpdir(), "gtkx-screenshots-"));
142
- const filepath = join(dir, "screenshot.png");
143
- writeFileSync(filepath, Buffer.from(result.data, "base64"));
144
-
145
- return filepath;
273
+ const writeScreenshot = (result: ScreenshotResult, path: string): void => {
274
+ mkdirSync(dirname(path), { recursive: true });
275
+ writeFileSync(path, Buffer.from(result.data, "base64"));
146
276
  };
147
277
 
148
- /**
149
- * Prints a clickable file URL for a saved screenshot to the console.
150
- *
151
- * @param filepath Absolute path of the saved screenshot file.
152
- */
153
- const logScreenshotPath = (filepath: string): void => {
154
- console.log(`Screenshot saved: file://${filepath}`);
278
+ const captureUntilPaintable = (
279
+ widget: Gtk.Widget,
280
+ scale: number,
281
+ options: ScreenshotOptions | undefined,
282
+ ): Promise<CaptureOutcome> => {
283
+ const state: CaptureState = { probe: startFrameProbe(widget), failure: null };
284
+
285
+ return waitFor(() => attemptCapture(widget, scale, state), {
286
+ timeout: options?.timeout,
287
+ interval: options?.interval ?? DEFAULT_SCREENSHOT_INTERVAL,
288
+ onTimeout: (error) => explainTimeout(error, widget, state),
289
+ });
155
290
  };
156
291
 
157
292
  /**
158
- * Captures a screenshot of a toplevel window, writes it to a temporary file,
159
- * logs its path, and returns the image data.
293
+ * Captures a PNG snapshot of a widget, retrying until it has a paintable size,
294
+ * and writes the image to `options.path` when one is given, creating any
295
+ * missing parent directories. Popovers open inside the widget render on their
296
+ * own surfaces, so they are composited into the image at their on-screen
297
+ * positions. A display that never presents a frame leaves GTK without a cached
298
+ * render node, so the widget's contents are then snapshotted directly; when
299
+ * even that yields nothing, the failure names the display instead of blaming
300
+ * the widget.
160
301
  *
161
- * @param selector Chooses the window by index, title substring, or regular
162
- * expression; defaults to the first toplevel.
163
- * @param options Optional scale, timeout, and retry interval.
302
+ * @param widget The widget to render to an image.
303
+ * @param options Optional scale, timeout, retry interval, and output path.
164
304
  * @returns The base64-encoded PNG data along with its mime type and dimensions.
165
305
  */
166
- const captureAndSaveScreenshot = async (
167
- selector?: WindowSelector,
168
- options?: ScreenshotOptions,
169
- ): Promise<ScreenshotResult> => {
170
- const target = resolveWindow(selector);
171
- const result = await screenshot(target, options);
172
- logScreenshotPath(saveScreenshotToTempFile(result));
306
+ const screenshot = async (widget: Gtk.Widget, options?: ScreenshotOptions): Promise<ScreenshotResult> => {
307
+ const scale = options?.scale ?? 1;
173
308
 
174
- return result;
309
+ if (!Number.isFinite(scale) || scale <= 0) {
310
+ throw new Error(`Screenshot scale must be a positive number, got ${String(scale)}`);
311
+ }
312
+
313
+ const outcome = await captureUntilPaintable(widget, scale, options);
314
+
315
+ if (outcome.status === "stalled") {
316
+ throw new Error(stalledMessage(widget, outcome.failure));
317
+ }
318
+
319
+ if (options?.path) {
320
+ writeScreenshot(outcome.result, options.path);
321
+ }
322
+
323
+ return outcome.result;
175
324
  };
176
325
 
177
- export { screenshot, logScreenshotPath, captureAndSaveScreenshot };
326
+ const screenTarget = (): Gtk.Window => {
327
+ const target = activeToplevel() ?? mappedToplevels()[0];
328
+
329
+ if (!target) {
330
+ throw new Error(NOTHING_ON_SCREEN_MESSAGE);
331
+ }
332
+
333
+ return target;
334
+ };
335
+
336
+ const captureScreen = async (options?: ScreenshotOptions): Promise<ScreenshotResult> =>
337
+ screenshot(screenTarget(), options);
338
+
339
+ export { captureScreen, screenshot };
package/src/timers.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { clearTimeout, setTimeout } from "node:timers";
3
+
4
+ type FakeClock = { tickAsync: (ms: number) => Promise<unknown> };
5
+ type TimeoutHandle = ReturnType<typeof setTimeout>;
6
+
7
+ const now = (): number => performance.now();
8
+ const scheduleTimeout = (callback: () => void, ms: number): TimeoutHandle => setTimeout(callback, ms);
9
+
10
+ const cancelTimeout = (handle: TimeoutHandle): void => {
11
+ clearTimeout(handle);
12
+ };
13
+
14
+ const delay = (ms: number): Promise<void> =>
15
+ new Promise((resolve) => {
16
+ scheduleTimeout(() => {
17
+ resolve();
18
+ }, ms);
19
+ });
20
+
21
+ const getFakeClock = (): FakeClock | null => {
22
+ const { clock } = globalThis.setTimeout as typeof globalThis.setTimeout & { clock?: FakeClock };
23
+
24
+ return clock ?? null;
25
+ };
26
+
27
+ const advanceFakeClock = async (ms: number): Promise<void> => {
28
+ await getFakeClock()?.tickAsync(ms);
29
+ };
30
+
31
+ export { advanceFakeClock, cancelTimeout, delay, now, scheduleTimeout };
package/src/traversal.ts CHANGED
@@ -11,28 +11,76 @@ type QueryContainer = Gtk.Widget | Gtk.EventController | Gtk.LayoutManager | Gtk
11
11
  * sentinel representing all current toplevel windows.
12
12
  */
13
13
  type Container = QueryContainer | Gtk.Application | typeof TOPLEVELS;
14
+ type WidgetClass<T extends Gtk.Widget> = abstract new (...args: never[]) => T;
14
15
 
15
16
  /** Container sentinel that widens a query to every toplevel window currently open. */
16
17
  const TOPLEVELS: unique symbol = Symbol("gtkx.toplevels");
17
18
 
18
19
  const isApplication = (container: Container): container is Gtk.Application => container instanceof Gtk.Application;
20
+ const isAnyWidget = (): boolean => true;
21
+ const isOnScreen = (widget: Gtk.Widget): boolean => widget.getMapped();
22
+
23
+ const traverseWidgetTree = function* (
24
+ root: Gtk.Widget,
25
+ isIncluded: (widget: Gtk.Widget) => boolean,
26
+ ): Generator<Gtk.Widget> {
27
+ if (isIncluded(root)) {
28
+ yield root;
29
+ }
19
30
 
20
- const traverseWidgetTree = function* (root: Gtk.Widget): Generator<Gtk.Widget> {
21
- yield root;
22
31
  let child = root.getFirstChild();
23
32
 
24
33
  while (child) {
25
- yield* traverseWidgetTree(child);
34
+ yield* traverseWidgetTree(child, isIncluded);
35
+ child = child.getNextSibling();
36
+ }
37
+ };
38
+
39
+ const children = function* (widget: Gtk.Widget): Generator<Gtk.Widget> {
40
+ let child = widget.getFirstChild();
41
+
42
+ while (child) {
43
+ yield child;
26
44
  child = child.getNextSibling();
27
45
  }
28
46
  };
29
47
 
30
48
  const descendants = function* (widget: Gtk.Widget): Generator<Gtk.Widget> {
31
- const tree = traverseWidgetTree(widget);
49
+ const tree = traverseWidgetTree(widget, isAnyWidget);
32
50
  tree.next();
33
51
  yield* tree;
34
52
  };
35
53
 
54
+ const ancestors = function* (widget: Gtk.Widget): Generator<Gtk.Widget> {
55
+ let parent = widget.getParent();
56
+
57
+ while (parent) {
58
+ yield parent;
59
+ parent = parent.getParent();
60
+ }
61
+ };
62
+
63
+ const ancestorFor = <T extends Gtk.Widget>(widget: Gtk.Widget, type: WidgetClass<T>): T | null => {
64
+ for (const ancestor of ancestors(widget)) {
65
+ if (ancestor instanceof type) {
66
+ return ancestor;
67
+ }
68
+ }
69
+
70
+ return null;
71
+ };
72
+
73
+ const relationCandidates = (widget: Gtk.Widget): Gtk.Accessible[] => {
74
+ const pool: Gtk.Accessible[] = [...widget.listMnemonicLabels(), ...descendants(widget)];
75
+ const root = widget.getRoot();
76
+
77
+ if (root instanceof Gtk.Widget) {
78
+ pool.push(...traverseWidgetTree(root, isAnyWidget));
79
+ }
80
+
81
+ return [...new Set(pool)];
82
+ };
83
+
36
84
  const resolveRoot = (container: QueryContainer): Gtk.Widget | null => {
37
85
  if (container instanceof Gtk.Widget) {
38
86
  return container;
@@ -69,7 +117,7 @@ const roots = function* (container: Container): Generator<Gtk.Widget> {
69
117
 
70
118
  const traverse = function* (container: Container): Generator<Gtk.Widget> {
71
119
  for (const root of roots(container)) {
72
- yield* traverseWidgetTree(root);
120
+ yield* traverseWidgetTree(root, isOnScreen);
73
121
  }
74
122
  };
75
123
 
@@ -85,4 +133,16 @@ const findAll = (container: Container, isMatch: (node: Gtk.Widget) => boolean):
85
133
  return results;
86
134
  };
87
135
 
88
- export { TOPLEVELS, descendants, roots, traverse, findAll, type Container };
136
+ export {
137
+ TOPLEVELS,
138
+ ancestorFor,
139
+ ancestors,
140
+ children,
141
+ descendants,
142
+ findAll,
143
+ isOnScreen,
144
+ relationCandidates,
145
+ roots,
146
+ traverse,
147
+ type Container,
148
+ };