@gtkx/testing 1.0.0-rc.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) 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/build-queries.d.ts +27 -10
  7. package/dist/build-queries.d.ts.map +1 -1
  8. package/dist/build-queries.js +22 -24
  9. package/dist/build-queries.js.map +1 -1
  10. package/dist/cleanup-registry.d.ts +1 -1
  11. package/dist/cleanup-registry.d.ts.map +1 -1
  12. package/dist/cleanup-registry.js.map +1 -1
  13. package/dist/errors.d.ts +10 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +10 -1
  16. package/dist/errors.js.map +1 -1
  17. package/dist/fire-event.d.ts +35 -6
  18. package/dist/fire-event.d.ts.map +1 -1
  19. package/dist/fire-event.js +23 -7
  20. package/dist/fire-event.js.map +1 -1
  21. package/dist/frame-sync.d.ts +2 -1
  22. package/dist/frame-sync.d.ts.map +1 -1
  23. package/dist/frame-sync.js +15 -5
  24. package/dist/frame-sync.js.map +1 -1
  25. package/dist/harness-window.d.ts +4 -0
  26. package/dist/harness-window.d.ts.map +1 -0
  27. package/dist/harness-window.js +6 -0
  28. package/dist/harness-window.js.map +1 -0
  29. package/dist/index.d.ts +14 -7
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +9 -5
  32. package/dist/index.js.map +1 -1
  33. package/dist/internal.d.ts +4 -0
  34. package/dist/internal.d.ts.map +1 -0
  35. package/dist/internal.js +4 -0
  36. package/dist/internal.js.map +1 -0
  37. package/dist/matchers.d.ts +88 -46
  38. package/dist/matchers.d.ts.map +1 -1
  39. package/dist/matchers.js +156 -18
  40. package/dist/matchers.js.map +1 -1
  41. package/dist/pretty-format.d.ts +2 -0
  42. package/dist/pretty-format.d.ts.map +1 -0
  43. package/dist/pretty-format.js +2 -0
  44. package/dist/pretty-format.js.map +1 -0
  45. package/dist/pretty-widget.d.ts +5 -2
  46. package/dist/pretty-widget.d.ts.map +1 -1
  47. package/dist/pretty-widget.js +73 -56
  48. package/dist/pretty-widget.js.map +1 -1
  49. package/dist/production.d.ts +8 -0
  50. package/dist/production.d.ts.map +1 -0
  51. package/dist/production.js +34 -0
  52. package/dist/production.js.map +1 -0
  53. package/dist/queries.d.ts +98 -56
  54. package/dist/queries.d.ts.map +1 -1
  55. package/dist/queries.js +123 -83
  56. package/dist/queries.js.map +1 -1
  57. package/dist/query-helpers.d.ts +44 -0
  58. package/dist/query-helpers.d.ts.map +1 -0
  59. package/dist/query-helpers.js +57 -0
  60. package/dist/query-helpers.js.map +1 -0
  61. package/dist/render-children.d.ts +9 -0
  62. package/dist/render-children.d.ts.map +1 -0
  63. package/dist/render-children.js +11 -0
  64. package/dist/render-children.js.map +1 -0
  65. package/dist/render.d.ts.map +1 -1
  66. package/dist/render.js +11 -9
  67. package/dist/render.js.map +1 -1
  68. package/dist/role-helpers.d.ts +12 -3
  69. package/dist/role-helpers.d.ts.map +1 -1
  70. package/dist/role-helpers.js +13 -4
  71. package/dist/role-helpers.js.map +1 -1
  72. package/dist/role-naming.d.ts +5 -0
  73. package/dist/role-naming.d.ts.map +1 -0
  74. package/dist/role-naming.js +96 -0
  75. package/dist/role-naming.js.map +1 -0
  76. package/dist/screenshot.d.ts +12 -20
  77. package/dist/screenshot.d.ts.map +1 -1
  78. package/dist/screenshot.js +183 -91
  79. package/dist/screenshot.js.map +1 -1
  80. package/dist/traversal.d.ts +4 -1
  81. package/dist/traversal.d.ts.map +1 -1
  82. package/dist/traversal.js +25 -6
  83. package/dist/traversal.js.map +1 -1
  84. package/dist/types.d.ts +26 -14
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/types.js.map +1 -1
  87. package/dist/user-event/adjustment.d.ts +3 -0
  88. package/dist/user-event/adjustment.d.ts.map +1 -1
  89. package/dist/user-event/adjustment.js +23 -2
  90. package/dist/user-event/adjustment.js.map +1 -1
  91. package/dist/user-event/click.d.ts +43 -8
  92. package/dist/user-event/click.d.ts.map +1 -1
  93. package/dist/user-event/click.js +175 -44
  94. package/dist/user-event/click.js.map +1 -1
  95. package/dist/user-event/column-header-click.d.ts +4 -0
  96. package/dist/user-event/column-header-click.d.ts.map +1 -0
  97. package/dist/user-event/column-header-click.js +65 -0
  98. package/dist/user-event/column-header-click.js.map +1 -0
  99. package/dist/user-event/event-wrapper.d.ts.map +1 -1
  100. package/dist/user-event/event-wrapper.js +2 -1
  101. package/dist/user-event/event-wrapper.js.map +1 -1
  102. package/dist/user-event/index.d.ts +51 -10
  103. package/dist/user-event/index.d.ts.map +1 -1
  104. package/dist/user-event/index.js +43 -26
  105. package/dist/user-event/index.js.map +1 -1
  106. package/dist/user-event/indexed-children.d.ts +10 -0
  107. package/dist/user-event/indexed-children.d.ts.map +1 -0
  108. package/dist/user-event/indexed-children.js +50 -0
  109. package/dist/user-event/indexed-children.js.map +1 -0
  110. package/dist/user-event/keyboard.d.ts.map +1 -1
  111. package/dist/user-event/keyboard.js +33 -4
  112. package/dist/user-event/keyboard.js.map +1 -1
  113. package/dist/user-event/list-row-click.d.ts +4 -0
  114. package/dist/user-event/list-row-click.d.ts.map +1 -0
  115. package/dist/user-event/list-row-click.js +39 -0
  116. package/dist/user-event/list-row-click.js.map +1 -0
  117. package/dist/user-event/native-click.d.ts +6 -0
  118. package/dist/user-event/native-click.d.ts.map +1 -0
  119. package/dist/user-event/native-click.js +32 -0
  120. package/dist/user-event/native-click.js.map +1 -0
  121. package/dist/user-event/pointer.d.ts +1 -1
  122. package/dist/user-event/pointer.d.ts.map +1 -1
  123. package/dist/user-event/pointer.js +33 -9
  124. package/dist/user-event/pointer.js.map +1 -1
  125. package/dist/user-event/selection.d.ts +11 -8
  126. package/dist/user-event/selection.d.ts.map +1 -1
  127. package/dist/user-event/selection.js +49 -38
  128. package/dist/user-event/selection.js.map +1 -1
  129. package/dist/user-event/text.d.ts +6 -2
  130. package/dist/user-event/text.d.ts.map +1 -1
  131. package/dist/user-event/text.js +68 -18
  132. package/dist/user-event/text.js.map +1 -1
  133. package/dist/widget-accessible-properties.d.ts +14 -26
  134. package/dist/widget-accessible-properties.d.ts.map +1 -1
  135. package/dist/widget-accessible-properties.js +135 -190
  136. package/dist/widget-accessible-properties.js.map +1 -1
  137. package/dist/widget-getters.d.ts +8 -0
  138. package/dist/widget-getters.d.ts.map +1 -0
  139. package/dist/widget-getters.js +19 -0
  140. package/dist/widget-getters.js.map +1 -0
  141. package/dist/within.d.ts +3 -2
  142. package/dist/within.d.ts.map +1 -1
  143. package/dist/within.js +3 -2
  144. package/dist/within.js.map +1 -1
  145. package/package.json +12 -7
  146. package/src/accessible-native.ts +333 -0
  147. package/src/build-queries.ts +47 -41
  148. package/src/cleanup-registry.ts +1 -1
  149. package/src/errors.ts +12 -1
  150. package/src/fire-event.ts +61 -8
  151. package/src/frame-sync.ts +21 -5
  152. package/src/harness-window.ts +9 -0
  153. package/src/index.ts +18 -11
  154. package/src/internal.ts +11 -0
  155. package/src/matchers.ts +428 -70
  156. package/src/pretty-format.ts +1 -0
  157. package/src/pretty-widget.ts +97 -70
  158. package/src/production.ts +52 -0
  159. package/src/queries.ts +127 -89
  160. package/src/query-helpers.ts +97 -0
  161. package/src/render-children.ts +25 -0
  162. package/src/render.tsx +16 -11
  163. package/src/role-helpers.ts +14 -4
  164. package/src/role-naming.ts +99 -0
  165. package/src/screenshot.ts +272 -103
  166. package/src/traversal.ts +44 -6
  167. package/src/types.ts +28 -16
  168. package/src/user-event/adjustment.ts +29 -2
  169. package/src/user-event/click.ts +247 -52
  170. package/src/user-event/column-header-click.ts +90 -0
  171. package/src/user-event/event-wrapper.ts +3 -1
  172. package/src/user-event/index.ts +100 -37
  173. package/src/user-event/indexed-children.ts +75 -0
  174. package/src/user-event/keyboard.ts +52 -4
  175. package/src/user-event/list-row-click.ts +57 -0
  176. package/src/user-event/native-click.ts +46 -0
  177. package/src/user-event/pointer.ts +48 -9
  178. package/src/user-event/selection.ts +67 -41
  179. package/src/user-event/text.ts +92 -21
  180. package/src/widget-accessible-properties.ts +183 -230
  181. package/src/widget-getters.ts +32 -0
  182. package/src/within.ts +3 -2
@@ -1,7 +1,7 @@
1
1
  import * as Gtk from "@gtkx/gi/gtk";
2
2
  import { sortStringsBy } from "@gtkx/utils";
3
3
  import { type Container, traverse } from "./traversal.js";
4
- import { getWidgetAccessibleName } from "./widget-accessible-properties.js";
4
+ import { getWidgetAccessibleName, getWidgetLevel } from "./widget-accessible-properties.js";
5
5
 
6
6
  const ROLE_NAMES_BY_VALUE = enumNamesByValue(Gtk.AccessibleRole);
7
7
 
@@ -47,10 +47,11 @@ const formatRoleList = (roles: Iterable<Gtk.AccessibleRole>): string => {
47
47
  };
48
48
 
49
49
  /**
50
- * Groups every widget in a container's tree by its accessible role name.
50
+ * Groups every mapped widget in a container's tree by its accessible role name. Widgets that are not
51
+ * mapped are left out.
51
52
  *
52
53
  * @param container The scope to traverse.
53
- * @returns A map from role name to the widgets that have that role.
54
+ * @returns A map from role name to the mapped widgets that have that role.
54
55
  */
55
56
  const getRoles = (container: Container): Map<string, Gtk.Widget[]> => {
56
57
  const roles: Map<string, Gtk.Widget[]> = new Map();
@@ -80,6 +81,7 @@ const formatWidgetPreview = (widget: Gtk.Widget, name: string | null): string =>
80
81
  /**
81
82
  * Formats the accessible roles in a container's tree as a readable string,
82
83
  * listing each role together with its widgets and their accessible names.
84
+ * Widgets that are not mapped are left out.
83
85
  *
84
86
  * @param container The scope to inspect.
85
87
  */
@@ -116,4 +118,12 @@ const logRoles = (container: Container): void => {
116
118
  console.log(prettyRoles(container));
117
119
  };
118
120
 
119
- export { formatRole, formatRoleList, getRoles, prettyRoles, logRoles };
121
+ /**
122
+ * Returns the heading or hierarchy level GTK reports for a widget, or undefined when it declares
123
+ * none.
124
+ *
125
+ * @param widget The widget to read the level from.
126
+ */
127
+ const computeHeadingLevel = (widget: Gtk.Widget): number | undefined => getWidgetLevel(widget) ?? undefined;
128
+
129
+ export { computeHeadingLevel, formatRole, formatRoleList, getRoles, prettyRoles, logRoles };
@@ -0,0 +1,99 @@
1
+ import * as Gtk from "@gtkx/gi/gtk";
2
+
3
+ const FROM_AUTHOR = 1;
4
+ const FROM_CONTENT = 2;
5
+ const PROHIBITED = 4;
6
+
7
+ const NAMING: Partial<Record<Gtk.AccessibleRole, number>> = {
8
+ [Gtk.AccessibleRole.ALERT]: FROM_AUTHOR,
9
+ [Gtk.AccessibleRole.ALERT_DIALOG]: FROM_AUTHOR,
10
+ [Gtk.AccessibleRole.APPLICATION]: FROM_AUTHOR,
11
+ [Gtk.AccessibleRole.ARTICLE]: FROM_AUTHOR,
12
+ [Gtk.AccessibleRole.BANNER]: FROM_AUTHOR,
13
+ [Gtk.AccessibleRole.BLOCK_QUOTE]: FROM_AUTHOR,
14
+ [Gtk.AccessibleRole.BUTTON]: FROM_AUTHOR | FROM_CONTENT,
15
+ [Gtk.AccessibleRole.CAPTION]: PROHIBITED,
16
+ [Gtk.AccessibleRole.CELL]: FROM_AUTHOR | FROM_CONTENT,
17
+ [Gtk.AccessibleRole.CHECKBOX]: FROM_AUTHOR | FROM_CONTENT,
18
+ [Gtk.AccessibleRole.COLUMN_HEADER]: FROM_AUTHOR | FROM_CONTENT,
19
+ [Gtk.AccessibleRole.COMBO_BOX]: FROM_AUTHOR,
20
+ [Gtk.AccessibleRole.COMMAND]: PROHIBITED,
21
+ [Gtk.AccessibleRole.COMMENT]: FROM_AUTHOR | FROM_CONTENT,
22
+ [Gtk.AccessibleRole.COMPOSITE]: PROHIBITED,
23
+ [Gtk.AccessibleRole.DIALOG]: FROM_AUTHOR,
24
+ [Gtk.AccessibleRole.DOCUMENT]: FROM_AUTHOR,
25
+ [Gtk.AccessibleRole.FEED]: FROM_AUTHOR,
26
+ [Gtk.AccessibleRole.FORM]: FROM_AUTHOR,
27
+ [Gtk.AccessibleRole.GENERIC]: PROHIBITED,
28
+ [Gtk.AccessibleRole.GRID]: FROM_AUTHOR,
29
+ [Gtk.AccessibleRole.GRID_CELL]: FROM_AUTHOR | FROM_CONTENT,
30
+ [Gtk.AccessibleRole.GROUP]: FROM_AUTHOR,
31
+ [Gtk.AccessibleRole.HEADING]: FROM_AUTHOR | FROM_CONTENT,
32
+ [Gtk.AccessibleRole.IMG]: FROM_AUTHOR,
33
+ [Gtk.AccessibleRole.INPUT]: PROHIBITED,
34
+ [Gtk.AccessibleRole.LABEL]: FROM_AUTHOR | FROM_CONTENT,
35
+ [Gtk.AccessibleRole.LANDMARK]: PROHIBITED,
36
+ [Gtk.AccessibleRole.LEGEND]: PROHIBITED,
37
+ [Gtk.AccessibleRole.LINK]: FROM_AUTHOR | FROM_CONTENT,
38
+ [Gtk.AccessibleRole.LIST]: FROM_AUTHOR,
39
+ [Gtk.AccessibleRole.LIST_BOX]: FROM_AUTHOR,
40
+ [Gtk.AccessibleRole.LIST_ITEM]: FROM_AUTHOR,
41
+ [Gtk.AccessibleRole.LOG]: FROM_AUTHOR,
42
+ [Gtk.AccessibleRole.MAIN]: FROM_AUTHOR,
43
+ [Gtk.AccessibleRole.MARQUEE]: FROM_AUTHOR,
44
+ [Gtk.AccessibleRole.MATH]: FROM_AUTHOR,
45
+ [Gtk.AccessibleRole.METER]: FROM_AUTHOR,
46
+ [Gtk.AccessibleRole.MENU]: FROM_AUTHOR,
47
+ [Gtk.AccessibleRole.MENU_BAR]: FROM_AUTHOR,
48
+ [Gtk.AccessibleRole.MENU_ITEM]: FROM_AUTHOR | FROM_CONTENT,
49
+ [Gtk.AccessibleRole.MENU_ITEM_CHECKBOX]: FROM_AUTHOR | FROM_CONTENT,
50
+ [Gtk.AccessibleRole.MENU_ITEM_RADIO]: FROM_AUTHOR | FROM_CONTENT,
51
+ [Gtk.AccessibleRole.NAVIGATION]: FROM_AUTHOR,
52
+ [Gtk.AccessibleRole.NONE]: PROHIBITED,
53
+ [Gtk.AccessibleRole.NOTE]: FROM_AUTHOR,
54
+ [Gtk.AccessibleRole.OPTION]: FROM_AUTHOR | FROM_CONTENT,
55
+ [Gtk.AccessibleRole.PARAGRAPH]: PROHIBITED,
56
+ [Gtk.AccessibleRole.PRESENTATION]: PROHIBITED,
57
+ [Gtk.AccessibleRole.PROGRESS_BAR]: FROM_AUTHOR,
58
+ [Gtk.AccessibleRole.RADIO]: FROM_AUTHOR | FROM_CONTENT,
59
+ [Gtk.AccessibleRole.RADIO_GROUP]: FROM_AUTHOR,
60
+ [Gtk.AccessibleRole.RANGE]: PROHIBITED,
61
+ [Gtk.AccessibleRole.REGION]: FROM_AUTHOR,
62
+ [Gtk.AccessibleRole.ROW]: FROM_AUTHOR | FROM_CONTENT,
63
+ [Gtk.AccessibleRole.ROW_GROUP]: FROM_AUTHOR,
64
+ [Gtk.AccessibleRole.ROW_HEADER]: FROM_AUTHOR | FROM_CONTENT,
65
+ [Gtk.AccessibleRole.SCROLLBAR]: FROM_AUTHOR,
66
+ [Gtk.AccessibleRole.SEARCH]: FROM_AUTHOR,
67
+ [Gtk.AccessibleRole.SEARCH_BOX]: FROM_AUTHOR,
68
+ [Gtk.AccessibleRole.SECTION]: PROHIBITED,
69
+ [Gtk.AccessibleRole.SECTION_HEAD]: PROHIBITED,
70
+ [Gtk.AccessibleRole.SELECT]: PROHIBITED,
71
+ [Gtk.AccessibleRole.SEPARATOR]: FROM_AUTHOR,
72
+ [Gtk.AccessibleRole.SLIDER]: FROM_AUTHOR,
73
+ [Gtk.AccessibleRole.SPIN_BUTTON]: FROM_AUTHOR,
74
+ [Gtk.AccessibleRole.STATUS]: FROM_AUTHOR,
75
+ [Gtk.AccessibleRole.STRUCTURE]: PROHIBITED,
76
+ [Gtk.AccessibleRole.SWITCH]: FROM_AUTHOR | FROM_CONTENT,
77
+ [Gtk.AccessibleRole.TAB]: FROM_AUTHOR | FROM_CONTENT,
78
+ [Gtk.AccessibleRole.TABLE]: FROM_AUTHOR,
79
+ [Gtk.AccessibleRole.TAB_LIST]: FROM_AUTHOR,
80
+ [Gtk.AccessibleRole.TAB_PANEL]: FROM_AUTHOR,
81
+ [Gtk.AccessibleRole.TERMINAL]: FROM_AUTHOR,
82
+ [Gtk.AccessibleRole.TEXT_BOX]: FROM_AUTHOR,
83
+ [Gtk.AccessibleRole.TIME]: FROM_AUTHOR,
84
+ [Gtk.AccessibleRole.TIMER]: FROM_AUTHOR,
85
+ [Gtk.AccessibleRole.TOGGLE_BUTTON]: FROM_AUTHOR,
86
+ [Gtk.AccessibleRole.TOOLBAR]: FROM_AUTHOR,
87
+ [Gtk.AccessibleRole.TOOLTIP]: FROM_AUTHOR | FROM_CONTENT,
88
+ [Gtk.AccessibleRole.TREE]: FROM_AUTHOR,
89
+ [Gtk.AccessibleRole.TREE_GRID]: FROM_AUTHOR,
90
+ [Gtk.AccessibleRole.TREE_ITEM]: FROM_AUTHOR | FROM_CONTENT,
91
+ [Gtk.AccessibleRole.WIDGET]: FROM_AUTHOR | FROM_CONTENT,
92
+ [Gtk.AccessibleRole.WINDOW]: FROM_AUTHOR,
93
+ };
94
+
95
+ const namingFor = (role: Gtk.AccessibleRole): number => NAMING[role] ?? 0;
96
+ const isNameProhibited = (role: Gtk.AccessibleRole): boolean => (namingFor(role) & PROHIBITED) !== 0;
97
+ const isNameFromAuthor = (role: Gtk.AccessibleRole): boolean => (namingFor(role) & FROM_AUTHOR) !== 0;
98
+
99
+ export { isNameFromAuthor, isNameProhibited };
package/src/screenshot.ts CHANGED
@@ -1,12 +1,49 @@
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 { descendants } from "./traversal.js";
7
10
  import { waitFor } from "./wait-for.js";
8
11
 
12
+ type FrameProbe = {
13
+ counter: bigint | null;
14
+ startedAt: number;
15
+ };
16
+
17
+ type CaptureFailure = "no-size" | "no-content";
18
+
19
+ type CaptureState = {
20
+ probe: FrameProbe;
21
+ failure: CaptureFailure | null;
22
+ };
23
+
24
+ type CaptureResult =
25
+ { status: "captured"; result: ScreenshotResult } |
26
+ { status: "failed"; failure: CaptureFailure };
27
+
28
+ type CaptureOutcome =
29
+ { status: "captured"; result: ScreenshotResult } |
30
+ { status: "stalled"; failure: CaptureFailure };
31
+
9
32
  const DEFAULT_SCREENSHOT_INTERVAL = 10;
33
+ const PRESENTATION_PROBE_MS = 250;
34
+
35
+ const NOT_PRESENTING_MESSAGE =
36
+ "Screenshot failed: the display is not presenting frames to this window, so GTK has no rendered " +
37
+ "content to capture. The window is hidden, minimized, or its compositor is not presenting it; this " +
38
+ "is a display problem, not a widget problem.";
39
+
40
+ const EMPTY_WIDGET_HINT =
41
+ "The display is presenting frames to this window, so the widget itself is empty: it painted nothing.";
42
+
43
+ const OTHER_FAILURE_HINT =
44
+ "The display is presenting frames to this window, so the capture failed for another reason.";
45
+
46
+ const NOTHING_ON_SCREEN_MESSAGE = "Nothing is on screen to capture: no toplevel window is mapped";
10
47
 
11
48
  const bytesToBase64 = (bytes: number[]): string => {
12
49
  return Buffer.from(bytes).toString("base64");
@@ -16,162 +53,294 @@ const describeWidgetState = (widget: Gtk.Widget): string =>
16
53
  `realized=${String(widget.getRealized())} mapped=${String(widget.getMapped())} ` +
17
54
  `visible=${String(widget.getVisible())}`;
18
55
 
19
- const captureSnapshot = (widget: Gtk.Widget, scale: number): ScreenshotResult => {
20
- const paintable = new Gtk.WidgetPaintable({ widget });
56
+ const getMappedPopovers = (widget: Gtk.Widget): Gtk.Popover[] => {
57
+ const popovers: Gtk.Popover[] = [];
58
+
59
+ for (const descendant of descendants(widget)) {
60
+ if (descendant instanceof Gtk.Popover && descendant.getMapped()) {
61
+ popovers.push(descendant);
62
+ }
63
+ }
64
+
65
+ return popovers;
66
+ };
67
+
68
+ const appendPopover = (snapshot: Gtk.Snapshot, target: Gtk.Widget, popover: Gtk.Popover): void => {
69
+ const [hasBounds, bounds] = popover.computeBounds(target);
70
+ const paintable = new Gtk.WidgetPaintable({ widget: popover });
21
71
  const width = paintable.getIntrinsicWidth();
22
72
  const height = paintable.getIntrinsicHeight();
23
73
 
24
- if (width <= 0 || height <= 0) {
25
- throw new Error(`Widget has no size: ensure it is realized and visible (${describeWidgetState(widget)})`);
74
+ if (!hasBounds || width <= 0 || height <= 0) {
75
+ return;
76
+ }
77
+
78
+ snapshot.save();
79
+ snapshot.translate(new Graphene.Point({ x: bounds.origin.x, y: bounds.origin.y }));
80
+ paintable.snapshot(snapshot, width, height);
81
+ snapshot.restore();
82
+ };
83
+
84
+ const appendPopovers = (snapshot: Gtk.Snapshot, target: Gtk.Widget): void => {
85
+ for (const popover of getMappedPopovers(target)) {
86
+ appendPopover(snapshot, target, popover);
26
87
  }
88
+ };
89
+
90
+ const getSurface = (widget: Gtk.Widget): Gdk.Surface | null => widget.getNative()?.getSurface() ?? null;
91
+ const isSurfaceOnScreen = (widget: Gtk.Widget): boolean => getSurface(widget)?.getMapped() ?? false;
92
+
93
+ const getFrameClock = (widget: Gtk.Widget): Gdk.FrameClock | null =>
94
+ widget.getRoot()?.getFrameClock() ?? widget.getFrameClock();
95
+
96
+ const getFrameCounter = (widget: Gtk.Widget): bigint | null => getFrameClock(widget)?.getFrameCounter() ?? null;
97
+
98
+ const describePresentation = (widget: Gtk.Widget): string =>
99
+ `${describeWidgetState(widget)} surfaceMapped=${String(isSurfaceOnScreen(widget))} ` +
100
+ `frameCounter=${String(getFrameCounter(widget))}`;
101
+
102
+ const requestFrame = (widget: Gtk.Widget): void => {
103
+ getFrameClock(widget)?.requestPhase(Gdk.FrameClockPhase.UPDATE);
104
+ };
105
+
106
+ const startFrameProbe = (widget: Gtk.Widget): FrameProbe => {
107
+ requestFrame(widget);
27
108
 
109
+ return { counter: getFrameCounter(widget), startedAt: Date.now() };
110
+ };
111
+
112
+ const updateFrameProbe = (widget: Gtk.Widget, probe: FrameProbe): void => {
113
+ requestFrame(widget);
114
+
115
+ if (probe.counter !== null) {
116
+ return;
117
+ }
118
+
119
+ const counter = getFrameCounter(widget);
120
+
121
+ if (counter !== null) {
122
+ probe.counter = counter;
123
+ probe.startedAt = Date.now();
124
+ }
125
+ };
126
+
127
+ const hasFrameAdvanced = (widget: Gtk.Widget, probe: FrameProbe): boolean => {
128
+ const counter = getFrameCounter(widget);
129
+
130
+ return counter !== null && probe.counter !== null && counter > probe.counter;
131
+ };
132
+
133
+ const isPresenting = (widget: Gtk.Widget, probe: FrameProbe): boolean =>
134
+ isSurfaceOnScreen(widget) && hasFrameAdvanced(widget, probe);
135
+
136
+ const isPresentationStalled = (widget: Gtk.Widget, probe: FrameProbe): boolean =>
137
+ Date.now() - probe.startedAt >= PRESENTATION_PROBE_MS && !isPresenting(widget, probe);
138
+
139
+ const allocateRoot = (widget: Gtk.Widget): void => {
140
+ const root = widget.getRoot();
141
+
142
+ if (!root) {
143
+ return;
144
+ }
145
+
146
+ const width = root.getWidth();
147
+ const height = root.getHeight();
148
+
149
+ if (width > 0 && height > 0) {
150
+ root.allocate(width, height, -1, null);
151
+ }
152
+ };
153
+
154
+ const isDirectlyPaintable = (child: Gtk.Widget): boolean =>
155
+ child.getMapped() && child.getWidth() > 0 && !(child instanceof Gtk.Popover);
156
+
157
+ const getPaintableNode = (widget: Gtk.Widget, scale: number, width: number, height: number): Gsk.RenderNode | null => {
158
+ const paintable = new Gtk.WidgetPaintable({ widget });
28
159
  const snapshot = new Gtk.Snapshot();
29
160
  snapshot.scale(scale, scale);
30
161
  paintable.snapshot(snapshot, width, height);
31
- const renderNode = snapshot.toNode();
162
+ appendPopovers(snapshot, widget);
32
163
 
33
- if (!renderNode) {
34
- throw new Error(`Widget produced no render content (${describeWidgetState(widget)})`);
164
+ return snapshot.toNode();
165
+ };
166
+
167
+ const getChildrenNode = (widget: Gtk.Widget, scale: number): Gsk.RenderNode | null => {
168
+ const snapshot = new Gtk.Snapshot();
169
+ snapshot.scale(scale, scale);
170
+
171
+ for (let child = widget.getFirstChild(); child !== null; child = child.getNextSibling()) {
172
+ if (isDirectlyPaintable(child)) {
173
+ widget.snapshotChild(child, snapshot);
174
+ }
175
+ }
176
+
177
+ appendPopovers(snapshot, widget);
178
+
179
+ return snapshot.toNode();
180
+ };
181
+
182
+ const getRenderNode = (widget: Gtk.Widget, scale: number, width: number, height: number): Gsk.RenderNode | null => {
183
+ const presented = getPaintableNode(widget, scale, width, height);
184
+
185
+ if (presented) {
186
+ return presented;
35
187
  }
36
188
 
37
- const display = widget.getDisplay();
189
+ allocateRoot(widget);
190
+
191
+ return getPaintableNode(widget, scale, width, height) ?? getChildrenNode(widget, scale);
192
+ };
193
+
194
+ const renderToPng = (widget: Gtk.Widget, node: Gsk.RenderNode, width: number, height: number): ScreenshotResult => {
38
195
  const renderer = new Gsk.CairoRenderer();
39
- renderer.realizeForDisplay(display);
196
+ renderer.realizeForDisplay(widget.getDisplay());
40
197
 
41
198
  try {
42
- const texture = renderer.renderTexture(renderNode, null);
43
- const pngBytes = texture.saveToPngBytes();
44
- const data = pngBytes.getData();
199
+ const viewport = Graphene.Rect.alloc().init(0, 0, width, height);
200
+ const texture = renderer.renderTexture(node, viewport);
201
+ const data = texture.saveToPngBytes().getData();
45
202
 
46
203
  if (!data) {
47
204
  throw new Error("Failed to serialize screenshot to PNG");
48
205
  }
49
206
 
50
- return {
51
- data: bytesToBase64(data),
52
- mimeType: "image/png",
53
- width: Math.round(width * scale),
54
- height: Math.round(height * scale),
55
- };
207
+ return { data: bytesToBase64(data), mimeType: "image/png", width, height };
56
208
  } finally {
57
209
  renderer.unrealize();
58
210
  }
59
211
  };
60
212
 
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;
213
+ const captureSnapshot = (widget: Gtk.Widget, scale: number): CaptureResult => {
214
+ const paintable = new Gtk.WidgetPaintable({ widget });
215
+ const width = paintable.getIntrinsicWidth();
216
+ const height = paintable.getIntrinsicHeight();
70
217
 
71
- if (!Number.isFinite(scale) || scale <= 0) {
72
- throw new Error(`Screenshot scale must be a positive number, got ${String(scale)}`);
218
+ if (width <= 0 || height <= 0) {
219
+ return { status: "failed", failure: "no-size" };
73
220
  }
74
221
 
75
- return waitFor(() => captureSnapshot(widget, scale), {
76
- timeout: options?.timeout,
77
- interval: options?.interval ?? DEFAULT_SCREENSHOT_INTERVAL,
78
- });
79
- };
80
-
81
- const firstToplevelWindow = (windows: Gtk.Widget[]): Gtk.Window => {
82
- const [first] = windows;
222
+ const renderNode = getRenderNode(widget, scale, width, height);
83
223
 
84
- if (!(first instanceof Gtk.Window)) {
85
- throw new TypeError("First toplevel is not a Window");
224
+ if (!renderNode) {
225
+ return { status: "failed", failure: "no-content" };
86
226
  }
87
227
 
88
- return first;
228
+ const scaledWidth = Math.round(width * scale);
229
+ const scaledHeight = Math.round(height * scale);
230
+
231
+ return { status: "captured", result: renderToPng(widget, renderNode, scaledWidth, scaledHeight) };
89
232
  };
90
233
 
91
- const windowAtIndex = (windows: Gtk.Widget[], index: number): Gtk.Window => {
92
- const indexed = windows[index];
234
+ const failureMessage = (widget: Gtk.Widget, failure: CaptureFailure): string =>
235
+ failure === "no-size"
236
+ ? `Widget has no size: ensure it is realized and visible (${describeWidgetState(widget)})`
237
+ : `Widget produced no render content (${describeWidgetState(widget)})`;
93
238
 
94
- if (!(indexed instanceof Gtk.Window)) {
95
- throw new TypeError(`Window at index ${String(index)} not found`);
96
- }
239
+ const notPresentingMessage = (widget: Gtk.Widget): string =>
240
+ `${NOT_PRESENTING_MESSAGE} (${describePresentation(widget)})`;
97
241
 
98
- return indexed;
99
- };
242
+ const stalledMessage = (widget: Gtk.Widget, failure: CaptureFailure): string =>
243
+ `${notPresentingMessage(widget)}\n\n${failureMessage(widget, failure)}`;
100
244
 
101
- const isWindow = (widget: Gtk.Widget): widget is Gtk.Window => widget instanceof Gtk.Window;
245
+ const presentingHint = (failure: CaptureFailure | null): string =>
246
+ failure === "no-content" ? EMPTY_WIDGET_HINT : OTHER_FAILURE_HINT;
102
247
 
103
- const hasMatchingTitle = (window: Gtk.Window, selector: string | RegExp): boolean => {
104
- const title = window.getTitle() ?? "";
248
+ const timeoutHint = (widget: Gtk.Widget, state: CaptureState): string =>
249
+ isPresenting(widget, state.probe) ? presentingHint(state.failure) : notPresentingMessage(widget);
105
250
 
106
- return selector instanceof RegExp ? selector.test(title) : title.includes(selector);
107
- };
251
+ const explainTimeout = (error: Error, widget: Gtk.Widget, state: CaptureState): Error =>
252
+ getConfig().getElementError(`${error.message}\n\n${timeoutHint(widget, state)}`);
108
253
 
109
- const describeTitleSelector = (selector: string | RegExp): string =>
110
- selector instanceof RegExp ? selector.toString() : `"${selector}"`;
254
+ const attemptCapture = (widget: Gtk.Widget, scale: number, state: CaptureState): CaptureOutcome => {
255
+ const attempt = captureSnapshot(widget, scale);
111
256
 
112
- const windowByTitle = (windows: Gtk.Widget[], selector: string | RegExp): Gtk.Window => {
113
- const found = windows.filter(isWindow).find((window) => hasMatchingTitle(window, selector));
257
+ if (attempt.status === "captured") {
258
+ return attempt;
259
+ }
260
+
261
+ state.failure = attempt.failure;
262
+ updateFrameProbe(widget, state.probe);
114
263
 
115
- if (!found) {
116
- throw new Error(`No window found with title matching ${describeTitleSelector(selector)}`);
264
+ if (isPresentationStalled(widget, state.probe)) {
265
+ return { status: "stalled", failure: attempt.failure };
117
266
  }
118
267
 
119
- return found;
268
+ throw new Error(failureMessage(widget, attempt.failure));
269
+ };
270
+
271
+ const writeScreenshot = (result: ScreenshotResult, path: string): void => {
272
+ mkdirSync(dirname(path), { recursive: true });
273
+ writeFileSync(path, Buffer.from(result.data, "base64"));
120
274
  };
121
275
 
122
- const resolveWindow = (selector?: WindowSelector): Gtk.Window => {
123
- const windows = Gtk.Window.listToplevels();
276
+ const captureUntilPaintable = (
277
+ widget: Gtk.Widget,
278
+ scale: number,
279
+ options: ScreenshotOptions | undefined,
280
+ ): Promise<CaptureOutcome> => {
281
+ const state: CaptureState = { probe: startFrameProbe(widget), failure: null };
282
+
283
+ return waitFor(() => attemptCapture(widget, scale, state), {
284
+ timeout: options?.timeout,
285
+ interval: options?.interval ?? DEFAULT_SCREENSHOT_INTERVAL,
286
+ onTimeout: (error) => explainTimeout(error, widget, state),
287
+ });
288
+ };
124
289
 
125
- if (windows.length === 0) {
126
- throw new Error("No windows available for screenshot");
290
+ /**
291
+ * Captures a PNG snapshot of a widget, retrying until it has a paintable size,
292
+ * and writes the image to `options.path` when one is given, creating any
293
+ * missing parent directories. Popovers open inside the widget render on their
294
+ * own surfaces, so they are composited into the image at their on-screen
295
+ * positions. A display that never presents a frame leaves GTK without a cached
296
+ * render node, so the widget's contents are then snapshotted directly; when
297
+ * even that yields nothing, the failure names the display instead of blaming
298
+ * the widget.
299
+ *
300
+ * @param widget The widget to render to an image.
301
+ * @param options Optional scale, timeout, retry interval, and output path.
302
+ * @returns The base64-encoded PNG data along with its mime type and dimensions.
303
+ */
304
+ const screenshot = async (widget: Gtk.Widget, options?: ScreenshotOptions): Promise<ScreenshotResult> => {
305
+ const scale = options?.scale ?? 1;
306
+
307
+ if (!Number.isFinite(scale) || scale <= 0) {
308
+ throw new Error(`Screenshot scale must be a positive number, got ${String(scale)}`);
127
309
  }
128
310
 
129
- if (selector === undefined) {
130
- return firstToplevelWindow(windows);
311
+ const outcome = await captureUntilPaintable(widget, scale, options);
312
+
313
+ if (outcome.status === "stalled") {
314
+ throw new Error(stalledMessage(widget, outcome.failure));
131
315
  }
132
316
 
133
- if (typeof selector === "number") {
134
- return windowAtIndex(windows, selector);
317
+ if (options?.path) {
318
+ writeScreenshot(outcome.result, options.path);
135
319
  }
136
320
 
137
- return windowByTitle(windows, selector);
321
+ return outcome.result;
138
322
  };
139
323
 
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"));
324
+ const isWindow = (widget: Gtk.Widget): widget is Gtk.Window => widget instanceof Gtk.Window;
325
+ const isOnScreen = (window: Gtk.Window): boolean => window.getMapped();
326
+ const isActiveWindow = (window: Gtk.Window): boolean => window.isActive();
144
327
 
145
- return filepath;
146
- };
328
+ const activeToplevel = (): Gtk.Window => {
329
+ const toplevels = Gtk.Window.listToplevels().filter((widget) => isWindow(widget));
330
+ const onScreen = toplevels.filter((window) => isOnScreen(window));
331
+ const target = onScreen.find((window) => isActiveWindow(window)) ?? onScreen[0];
147
332
 
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}`);
333
+ if (!target) {
334
+ throw new Error(NOTHING_ON_SCREEN_MESSAGE);
335
+ }
336
+
337
+ return target;
155
338
  };
156
339
 
157
- /**
158
- * Captures a screenshot of a toplevel window, writes it to a temporary file,
159
- * logs its path, and returns the image data.
160
- *
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.
164
- * @returns The base64-encoded PNG data along with its mime type and dimensions.
165
- */
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));
340
+ const captureScreen = async (options?: ScreenshotOptions): Promise<ScreenshotResult> => {
341
+ const target = activeToplevel();
173
342
 
174
- return result;
343
+ return screenshot(target, options);
175
344
  };
176
345
 
177
- export { screenshot, logScreenshotPath, captureAndSaveScreenshot };
346
+ export { captureScreen, screenshot };
package/src/traversal.ts CHANGED
@@ -16,23 +16,51 @@ type Container = QueryContainer | Gtk.Application | typeof TOPLEVELS;
16
16
  const TOPLEVELS: unique symbol = Symbol("gtkx.toplevels");
17
17
 
18
18
  const isApplication = (container: Container): container is Gtk.Application => container instanceof Gtk.Application;
19
+ const isAnyWidget = (): boolean => true;
20
+ const isOnScreen = (widget: Gtk.Widget): boolean => widget.getMapped();
21
+
22
+ const traverseWidgetTree = function* (
23
+ root: Gtk.Widget,
24
+ isIncluded: (widget: Gtk.Widget) => boolean,
25
+ ): Generator<Gtk.Widget> {
26
+ if (isIncluded(root)) {
27
+ yield root;
28
+ }
19
29
 
20
- const traverseWidgetTree = function* (root: Gtk.Widget): Generator<Gtk.Widget> {
21
- yield root;
22
30
  let child = root.getFirstChild();
23
31
 
24
32
  while (child) {
25
- yield* traverseWidgetTree(child);
33
+ yield* traverseWidgetTree(child, isIncluded);
34
+ child = child.getNextSibling();
35
+ }
36
+ };
37
+
38
+ const children = function* (widget: Gtk.Widget): Generator<Gtk.Widget> {
39
+ let child = widget.getFirstChild();
40
+
41
+ while (child) {
42
+ yield child;
26
43
  child = child.getNextSibling();
27
44
  }
28
45
  };
29
46
 
30
47
  const descendants = function* (widget: Gtk.Widget): Generator<Gtk.Widget> {
31
- const tree = traverseWidgetTree(widget);
48
+ const tree = traverseWidgetTree(widget, isAnyWidget);
32
49
  tree.next();
33
50
  yield* tree;
34
51
  };
35
52
 
53
+ const relationCandidates = (widget: Gtk.Widget): Gtk.Accessible[] => {
54
+ const pool: Gtk.Accessible[] = [...widget.listMnemonicLabels(), ...descendants(widget)];
55
+ const root = widget.getRoot();
56
+
57
+ if (root instanceof Gtk.Widget) {
58
+ pool.push(...traverseWidgetTree(root, isAnyWidget));
59
+ }
60
+
61
+ return [...new Set(pool)];
62
+ };
63
+
36
64
  const resolveRoot = (container: QueryContainer): Gtk.Widget | null => {
37
65
  if (container instanceof Gtk.Widget) {
38
66
  return container;
@@ -69,7 +97,7 @@ const roots = function* (container: Container): Generator<Gtk.Widget> {
69
97
 
70
98
  const traverse = function* (container: Container): Generator<Gtk.Widget> {
71
99
  for (const root of roots(container)) {
72
- yield* traverseWidgetTree(root);
100
+ yield* traverseWidgetTree(root, isOnScreen);
73
101
  }
74
102
  };
75
103
 
@@ -85,4 +113,14 @@ const findAll = (container: Container, isMatch: (node: Gtk.Widget) => boolean):
85
113
  return results;
86
114
  };
87
115
 
88
- export { TOPLEVELS, descendants, roots, traverse, findAll, type Container };
116
+ export {
117
+ TOPLEVELS,
118
+ children,
119
+ descendants,
120
+ findAll,
121
+ isOnScreen,
122
+ relationCandidates,
123
+ roots,
124
+ traverse,
125
+ type Container,
126
+ };