termwright 0.2.0__py3-none-any.whl
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.
- termwright/__init__.py +125 -0
- termwright/client.py +708 -0
- termwright/debug.py +169 -0
- termwright/diffing.py +118 -0
- termwright/errors.py +20 -0
- termwright/framing.py +196 -0
- termwright/limits.py +82 -0
- termwright/logging_bridge.py +108 -0
- termwright/logs.py +207 -0
- termwright/marker.py +128 -0
- termwright/messages.py +417 -0
- termwright/roles.py +57 -0
- termwright/textual.py +249 -0
- termwright/tree.py +282 -0
- termwright/validate.py +846 -0
- termwright-0.2.0.dist-info/METADATA +336 -0
- termwright-0.2.0.dist-info/RECORD +25 -0
- termwright-0.2.0.dist-info/WHEEL +4 -0
- termwright_probe/__init__.py +73 -0
- termwright_probe/__main__.py +45 -0
- termwright_probe/bootstrap.py +207 -0
- termwright_probe/defer.py +130 -0
- termwright_probe/session.py +197 -0
- termwright_probe/textual_probe.py +239 -0
- termwright_probe/textual_tree.py +634 -0
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
"""Turning one observed Textual frame into a semantic tree.
|
|
2
|
+
|
|
3
|
+
What this does differently from the hand-written adapter it replaces, each
|
|
4
|
+
point traceable to a measurement in `docs/architecture/audit/textual.md`:
|
|
5
|
+
|
|
6
|
+
**`bounds` is the visible rectangle.** `Widget.region` is where the widget
|
|
7
|
+
sits in screen coordinates whether or not a container clips it; the audit
|
|
8
|
+
found the adapter publishing that, which reports cells the user cannot see for
|
|
9
|
+
anything scrolled halfway out of a viewport. The truth is
|
|
10
|
+
`MapGeometry.visible_region`, defined by Textual as `clip ∩ region`.
|
|
11
|
+
|
|
12
|
+
**Paint order is real here.** Textual's compositor sorts widgets by
|
|
13
|
+
`MapGeometry.order` (`_compositor.py:763`), a per-ancestor tuple that compares
|
|
14
|
+
lexicographically. Ranking the frame's widgets by that same key gives a
|
|
15
|
+
`paintOrder` that is Textual's own answer rather than our guess, which is what
|
|
16
|
+
lets every node claim `occlusion: 'known'` and unlocks pointer actions the
|
|
17
|
+
driver otherwise refuses.
|
|
18
|
+
|
|
19
|
+
**Not displayed and scrolled out of view are different facts, and the tree now
|
|
20
|
+
says which.** A widget Textual is not displaying is `hidden` with no bounds; one
|
|
21
|
+
that is displayed and entirely clipped is `hidden` **and** `offscreen`, with a
|
|
22
|
+
zero-area rectangle at its own origin. The earlier encoding leaned on absent
|
|
23
|
+
`bounds` to mean "not displayed", which collided with what absent bounds
|
|
24
|
+
already means — "this producer cannot report geometry" — so a consumer reading
|
|
25
|
+
the tree generically could not tell them apart. `state.offscreen` removes the
|
|
26
|
+
ambiguity at the source.
|
|
27
|
+
|
|
28
|
+
The Textual knowledge below — which class means which role, where a widget
|
|
29
|
+
keeps its text — is deliberately copied from the adapter rather than imported
|
|
30
|
+
from it. The adapter is the thing this replaces, and a probe that imported it
|
|
31
|
+
would inherit its decisions instead of making its own. Phase 9 deletes the
|
|
32
|
+
adapter and the duplication with it.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from collections import Counter
|
|
38
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
39
|
+
from weakref import WeakKeyDictionary
|
|
40
|
+
|
|
41
|
+
from termwright.textual import ResolvedAnnotation, resolve_annotation
|
|
42
|
+
from termwright.tree import (
|
|
43
|
+
NodeGeometryObservations,
|
|
44
|
+
Observation as WireObservation,
|
|
45
|
+
Rect,
|
|
46
|
+
SemanticNode,
|
|
47
|
+
SemanticSnapshot,
|
|
48
|
+
SemanticState,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
#: Widget class name → semantic role, walked along the MRO so a subclass of a
|
|
52
|
+
#: mapped widget inherits its role with no registration at all: `SaveButton`
|
|
53
|
+
#: derived from `Button` is a button because Python already says so.
|
|
54
|
+
ROLE_BY_CLASS: Dict[str, str] = {
|
|
55
|
+
"Button": "button",
|
|
56
|
+
"Input": "textbox",
|
|
57
|
+
"MaskedInput": "textbox",
|
|
58
|
+
"TextArea": "textbox",
|
|
59
|
+
"Checkbox": "checkbox",
|
|
60
|
+
"Switch": "checkbox",
|
|
61
|
+
"RadioButton": "radio",
|
|
62
|
+
"ToggleButton": "checkbox",
|
|
63
|
+
"DataTable": "table",
|
|
64
|
+
"ListView": "list",
|
|
65
|
+
"ListItem": "listitem",
|
|
66
|
+
"OptionList": "list",
|
|
67
|
+
"SelectionList": "list",
|
|
68
|
+
"RadioSet": "list",
|
|
69
|
+
"Select": "list",
|
|
70
|
+
"Tree": "list",
|
|
71
|
+
"DirectoryTree": "list",
|
|
72
|
+
"Tabs": "list",
|
|
73
|
+
"Tab": "tab",
|
|
74
|
+
"TabPane": "region",
|
|
75
|
+
"TabbedContent": "region",
|
|
76
|
+
"Label": "text",
|
|
77
|
+
"Static": "text",
|
|
78
|
+
"Digits": "text",
|
|
79
|
+
"Pretty": "text",
|
|
80
|
+
"Markdown": "text",
|
|
81
|
+
"MarkdownViewer": "region",
|
|
82
|
+
"RichLog": "text",
|
|
83
|
+
"Log": "text",
|
|
84
|
+
"Sparkline": "progressbar",
|
|
85
|
+
"ProgressBar": "progressbar",
|
|
86
|
+
"LoadingIndicator": "status",
|
|
87
|
+
"Toast": "alert",
|
|
88
|
+
"Rule": "separator",
|
|
89
|
+
"ScrollBar": "scrollbar",
|
|
90
|
+
"Header": "region",
|
|
91
|
+
"Footer": "region",
|
|
92
|
+
"Collapsible": "region",
|
|
93
|
+
"ContentSwitcher": "region",
|
|
94
|
+
"Container": "region",
|
|
95
|
+
"ScrollableContainer": "region",
|
|
96
|
+
"Horizontal": "region",
|
|
97
|
+
"Vertical": "region",
|
|
98
|
+
"Grid": "region",
|
|
99
|
+
"Center": "region",
|
|
100
|
+
"Middle": "region",
|
|
101
|
+
"ModalScreen": "dialog",
|
|
102
|
+
"Screen": "application",
|
|
103
|
+
"Widget": "generic",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#: Roles whose accessible name comes from what they contain, per the adapter
|
|
107
|
+
#: conventions in the protocol README.
|
|
108
|
+
NAME_FROM_CONTENT_ROLES = frozenset(
|
|
109
|
+
{"listitem", "menuitem", "tab", "button", "checkbox", "radio", "cell", "row", "heading"}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
#: Longest name derived from a node's contents, in characters.
|
|
113
|
+
MAX_CONTENT_NAME = 200
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Identities:
|
|
117
|
+
"""Stable node ids for the lifetime of each widget object.
|
|
118
|
+
|
|
119
|
+
Textual keeps a retained DOM, so a widget object survives between frames
|
|
120
|
+
and its identity can be tracked — the property Ratatui cannot offer at all.
|
|
121
|
+
A `recompose` builds new objects, and those correctly get new ids: the node
|
|
122
|
+
really is new, and pretending otherwise would report a change where a
|
|
123
|
+
replacement happened.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self) -> None:
|
|
127
|
+
self._ids: "WeakKeyDictionary[Any, str]" = WeakKeyDictionary()
|
|
128
|
+
self._next = 0
|
|
129
|
+
|
|
130
|
+
def of(self, widget: Any, semantic_key: Optional[str] = None) -> str:
|
|
131
|
+
if semantic_key is not None:
|
|
132
|
+
return f"k:{semantic_key}"
|
|
133
|
+
existing = self._ids.get(widget)
|
|
134
|
+
if existing is not None:
|
|
135
|
+
return existing
|
|
136
|
+
self._next += 1
|
|
137
|
+
assigned = f"w{self._next}"
|
|
138
|
+
self._ids[widget] = assigned
|
|
139
|
+
return assigned
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def role_for(widget: Any, annotation: Optional[ResolvedAnnotation] = None) -> str:
|
|
143
|
+
"""Semantic role: the SDK annotation, then the class ancestry."""
|
|
144
|
+
resolved = annotation if annotation is not None else resolve_annotation(widget)
|
|
145
|
+
if resolved.role is not None:
|
|
146
|
+
return resolved.role
|
|
147
|
+
for klass in type(widget).__mro__:
|
|
148
|
+
role = ROLE_BY_CLASS.get(klass.__name__)
|
|
149
|
+
if role is not None:
|
|
150
|
+
return role
|
|
151
|
+
return "generic"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _first_text(*candidates: Any) -> str:
|
|
155
|
+
for candidate in candidates:
|
|
156
|
+
if candidate is None:
|
|
157
|
+
continue
|
|
158
|
+
if isinstance(candidate, str):
|
|
159
|
+
if candidate:
|
|
160
|
+
return candidate
|
|
161
|
+
continue
|
|
162
|
+
text = str(candidate).strip()
|
|
163
|
+
if text:
|
|
164
|
+
return text
|
|
165
|
+
return ""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def name_from_content(widget: Any) -> str:
|
|
169
|
+
"""Join the text of a widget's descendants, as ARIA names from content."""
|
|
170
|
+
try:
|
|
171
|
+
descendants = widget.query("*")
|
|
172
|
+
except Exception:
|
|
173
|
+
return ""
|
|
174
|
+
parts: List[str] = []
|
|
175
|
+
for child in descendants:
|
|
176
|
+
text = _first_text(
|
|
177
|
+
getattr(child, "label", None),
|
|
178
|
+
getattr(child, "content", None),
|
|
179
|
+
getattr(child, "renderable", None),
|
|
180
|
+
).strip()
|
|
181
|
+
if text:
|
|
182
|
+
parts.append(text)
|
|
183
|
+
return " ".join(parts)[:MAX_CONTENT_NAME].strip()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def name_for(
|
|
187
|
+
widget: Any,
|
|
188
|
+
role: Optional[str] = None,
|
|
189
|
+
annotation: Optional[ResolvedAnnotation] = None,
|
|
190
|
+
) -> str:
|
|
191
|
+
"""Accessible name: annotation, then own text, then contents, then id."""
|
|
192
|
+
resolved = annotation if annotation is not None else resolve_annotation(widget)
|
|
193
|
+
if resolved.name is not None:
|
|
194
|
+
return resolved.name
|
|
195
|
+
|
|
196
|
+
own = _first_text(
|
|
197
|
+
getattr(widget, "label", None),
|
|
198
|
+
getattr(widget, "placeholder", None),
|
|
199
|
+
getattr(widget, "content", None),
|
|
200
|
+
getattr(widget, "renderable", None),
|
|
201
|
+
)
|
|
202
|
+
if own:
|
|
203
|
+
return own
|
|
204
|
+
if role in NAME_FROM_CONTENT_ROLES:
|
|
205
|
+
from_content = name_from_content(widget)
|
|
206
|
+
if from_content:
|
|
207
|
+
return from_content
|
|
208
|
+
return _first_text(getattr(widget, "name", None), getattr(widget, "id", None))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_id_for(widget: Any, annotation: Optional[ResolvedAnnotation] = None) -> Optional[str]:
|
|
212
|
+
"""Test id: the author's annotation, then Textual's own DOM id."""
|
|
213
|
+
resolved = annotation if annotation is not None else resolve_annotation(widget)
|
|
214
|
+
if resolved.test_id is not None:
|
|
215
|
+
return resolved.test_id
|
|
216
|
+
native = getattr(widget, "id", None)
|
|
217
|
+
return native if isinstance(native, str) and native else None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def actions_for(role: str) -> Optional[Sequence[str]]:
|
|
221
|
+
if role in ("button", "menuitem", "tab"):
|
|
222
|
+
return ("focus", "activate")
|
|
223
|
+
if role in ("checkbox", "radio"):
|
|
224
|
+
return ("focus", "toggle")
|
|
225
|
+
if role == "textbox":
|
|
226
|
+
return ("focus", "setValue")
|
|
227
|
+
if role in ("list", "table"):
|
|
228
|
+
return ("focus", "scroll", "select")
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def value_for(widget: Any, role: str) -> Optional[str]:
|
|
233
|
+
"""Current value of a value-bearing widget, as text.
|
|
234
|
+
|
|
235
|
+
`''` is a value: it says the field is empty, where absence says the widget
|
|
236
|
+
does not bear one at all.
|
|
237
|
+
"""
|
|
238
|
+
if role not in ("textbox", "progressbar"):
|
|
239
|
+
return None
|
|
240
|
+
value = getattr(widget, "text", None) if role == "textbox" else None
|
|
241
|
+
if not isinstance(value, str):
|
|
242
|
+
value = getattr(widget, "value", None)
|
|
243
|
+
if isinstance(value, bool) or value is None:
|
|
244
|
+
return None
|
|
245
|
+
return value if isinstance(value, str) else str(value)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _rect(region: Any) -> Optional[Rect]:
|
|
249
|
+
"""A Textual `Region` as a protocol rect, or `None` when there is none."""
|
|
250
|
+
if region is None:
|
|
251
|
+
return None
|
|
252
|
+
try:
|
|
253
|
+
return Rect(
|
|
254
|
+
row=int(region.y),
|
|
255
|
+
column=int(region.x),
|
|
256
|
+
width=max(0, int(region.width)),
|
|
257
|
+
height=max(0, int(region.height)),
|
|
258
|
+
)
|
|
259
|
+
except (AttributeError, TypeError, ValueError):
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class WidgetObservation:
|
|
264
|
+
"""One widget as the probe found it, before it becomes a node."""
|
|
265
|
+
|
|
266
|
+
__slots__ = ("widget", "geometry", "displayed", "paint_order")
|
|
267
|
+
|
|
268
|
+
def __init__(self, widget: Any, geometry: Any, displayed: bool) -> None:
|
|
269
|
+
self.widget = widget
|
|
270
|
+
self.geometry = geometry
|
|
271
|
+
self.displayed = displayed
|
|
272
|
+
self.paint_order: Optional[int] = None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def observe(app: Any) -> List[WidgetObservation]:
|
|
276
|
+
"""Read the active screen: every widget, its geometry and its display flag.
|
|
277
|
+
|
|
278
|
+
Called from `post_display_hook`, where the compositor has finished, so the
|
|
279
|
+
geometry is this frame's rather than the previous one's.
|
|
280
|
+
"""
|
|
281
|
+
screen = app.screen
|
|
282
|
+
widgets = [screen]
|
|
283
|
+
try:
|
|
284
|
+
widgets.extend(screen.query("*"))
|
|
285
|
+
except Exception:
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
observations: List[WidgetObservation] = []
|
|
289
|
+
for widget in widgets:
|
|
290
|
+
displayed = True
|
|
291
|
+
ancestor = widget
|
|
292
|
+
while ancestor is not None:
|
|
293
|
+
displayed = displayed and bool(getattr(ancestor, "display", True)) and bool(
|
|
294
|
+
getattr(ancestor, "visible", True)
|
|
295
|
+
)
|
|
296
|
+
ancestor = getattr(ancestor, "parent", None)
|
|
297
|
+
geometry = None
|
|
298
|
+
try:
|
|
299
|
+
geometry = screen.find_widget(widget)
|
|
300
|
+
except Exception:
|
|
301
|
+
# A widget the compositor does not know about — mid-mount, or on a
|
|
302
|
+
# screen that is no longer active. It has no geometry this frame,
|
|
303
|
+
# which is a fact rather than an error.
|
|
304
|
+
geometry = None
|
|
305
|
+
observations.append(WidgetObservation(widget, geometry, displayed))
|
|
306
|
+
|
|
307
|
+
_rank_paint_order(observations)
|
|
308
|
+
return observations
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _rank_paint_order(observations: List[WidgetObservation]) -> None:
|
|
312
|
+
"""Rank by Textual's own compositing key, so later means on top.
|
|
313
|
+
|
|
314
|
+
`MapGeometry.order` is a tuple of per-ancestor triples and compares
|
|
315
|
+
lexicographically; the compositor sorts by exactly this. Ranking rather
|
|
316
|
+
than publishing the tuple keeps the wire field an integer without losing
|
|
317
|
+
the ordering it encodes.
|
|
318
|
+
"""
|
|
319
|
+
ordered = [
|
|
320
|
+
item
|
|
321
|
+
for item in observations
|
|
322
|
+
if item.geometry is not None and getattr(item.geometry, "order", None) is not None
|
|
323
|
+
]
|
|
324
|
+
try:
|
|
325
|
+
ordered.sort(key=lambda item: item.geometry.order)
|
|
326
|
+
except TypeError:
|
|
327
|
+
# Mixed key shapes across Textual versions: no honest ranking, so no
|
|
328
|
+
# claim of one. Every node then reports occlusion 'unknown'.
|
|
329
|
+
return
|
|
330
|
+
for rank, item in enumerate(ordered):
|
|
331
|
+
item.paint_order = rank
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def build_snapshot(
|
|
335
|
+
app: Any,
|
|
336
|
+
identities: Identities,
|
|
337
|
+
*,
|
|
338
|
+
session_id: str,
|
|
339
|
+
revision: int,
|
|
340
|
+
qualified: bool = False,
|
|
341
|
+
) -> SemanticSnapshot:
|
|
342
|
+
"""The semantic tree for the frame that just landed."""
|
|
343
|
+
observations = observe(app)
|
|
344
|
+
included = {id(item.widget) for item in observations}
|
|
345
|
+
annotations = {id(item.widget): _probe_annotation(item.widget) for item in observations}
|
|
346
|
+
key_counts = Counter(
|
|
347
|
+
annotation.key for annotation in annotations.values() if annotation.key is not None
|
|
348
|
+
)
|
|
349
|
+
# Duplicate author keys are not identities. Degrade every colliding node to
|
|
350
|
+
# Textual's retained object identity for this frame; keeping one by
|
|
351
|
+
# traversal order would be unstable, while duplicate ids reject the whole
|
|
352
|
+
# snapshot.
|
|
353
|
+
semantic_keys = {
|
|
354
|
+
widget_id: annotation.key
|
|
355
|
+
if annotation.key is not None and key_counts[annotation.key] == 1
|
|
356
|
+
else None
|
|
357
|
+
for widget_id, annotation in annotations.items()
|
|
358
|
+
}
|
|
359
|
+
screen = app.screen
|
|
360
|
+
focused = getattr(app, "focused", None)
|
|
361
|
+
|
|
362
|
+
nodes: List[SemanticNode] = []
|
|
363
|
+
root_ids: List[str] = []
|
|
364
|
+
for item in observations:
|
|
365
|
+
widget = item.widget
|
|
366
|
+
annotation = annotations[id(widget)]
|
|
367
|
+
role = role_for(widget, annotation)
|
|
368
|
+
semantic_key = semantic_keys[id(widget)]
|
|
369
|
+
node_id = identities.of(widget, semantic_key)
|
|
370
|
+
|
|
371
|
+
parent_id: Optional[str] = None
|
|
372
|
+
parent = getattr(widget, "parent", None)
|
|
373
|
+
while parent is not None and id(parent) not in included:
|
|
374
|
+
parent = getattr(parent, "parent", None)
|
|
375
|
+
if parent is not None and parent is not widget:
|
|
376
|
+
parent_id = identities.of(parent, semantic_keys[id(parent)])
|
|
377
|
+
if parent_id is None:
|
|
378
|
+
root_ids.append(node_id)
|
|
379
|
+
|
|
380
|
+
bounds, hidden, offscreen = _geometry_of(item)
|
|
381
|
+
intended = _rect(getattr(item.geometry, "region", None)) if item.geometry is not None else None
|
|
382
|
+
visible = _rect(getattr(item.geometry, "visible_region", None)) if item.geometry is not None else None
|
|
383
|
+
geometry = None
|
|
384
|
+
if qualified:
|
|
385
|
+
if not item.displayed:
|
|
386
|
+
absent = WireObservation(status="absent", reason="not-displayed")
|
|
387
|
+
geometry = NodeGeometryObservations(
|
|
388
|
+
displayed=WireObservation(status="known", value=False, evidence="probe"),
|
|
389
|
+
intendedRect=absent,
|
|
390
|
+
visibleRect=absent,
|
|
391
|
+
)
|
|
392
|
+
elif item.geometry is None:
|
|
393
|
+
unknown = WireObservation(status="unknown", reason="temporary")
|
|
394
|
+
geometry = NodeGeometryObservations(
|
|
395
|
+
displayed=WireObservation(status="known", value=True, evidence="probe"),
|
|
396
|
+
intendedRect=unknown,
|
|
397
|
+
visibleRect=unknown,
|
|
398
|
+
)
|
|
399
|
+
else:
|
|
400
|
+
geometry = NodeGeometryObservations(
|
|
401
|
+
displayed=WireObservation(status="known", value=True, evidence="probe"),
|
|
402
|
+
intendedRect=(WireObservation(status="known", value=intended, evidence="probe") if intended is not None else WireObservation(status="unknown", reason="not-reported")),
|
|
403
|
+
visibleRect=(WireObservation(status="known", value=visible, evidence="viewport-clip") if visible is not None else WireObservation(status="unsupported", capability="visible-rect", reason="framework-unobservable")),
|
|
404
|
+
)
|
|
405
|
+
annotated = _annotated_fields(annotation, semantic_key is not None)
|
|
406
|
+
nodes.append(
|
|
407
|
+
SemanticNode(
|
|
408
|
+
id=node_id,
|
|
409
|
+
parentId=parent_id,
|
|
410
|
+
role=role,
|
|
411
|
+
name=(
|
|
412
|
+
annotation.name
|
|
413
|
+
if widget is screen and annotation.name is not None
|
|
414
|
+
else _app_name(app)
|
|
415
|
+
if widget is screen
|
|
416
|
+
else name_for(widget, role, annotation)
|
|
417
|
+
),
|
|
418
|
+
description=annotation.description,
|
|
419
|
+
testId=test_id_for(widget, annotation),
|
|
420
|
+
value=value_for(widget, role),
|
|
421
|
+
bounds=None if qualified else bounds,
|
|
422
|
+
state=_state_of(item, widget, role, focused, hidden, offscreen),
|
|
423
|
+
extended=annotation.extended,
|
|
424
|
+
actions=(
|
|
425
|
+
annotation.actions
|
|
426
|
+
if annotation.actions is not None
|
|
427
|
+
else actions_for(role)
|
|
428
|
+
),
|
|
429
|
+
labelledBy=_relationship_ids(
|
|
430
|
+
annotation.labelled_by, included, identities, semantic_keys
|
|
431
|
+
),
|
|
432
|
+
describedBy=_relationship_ids(
|
|
433
|
+
annotation.described_by, included, identities, semantic_keys
|
|
434
|
+
),
|
|
435
|
+
frameworkType=type(widget).__name__ if role == "generic" else None,
|
|
436
|
+
occlusion=None if qualified else "unknown",
|
|
437
|
+
p="framework",
|
|
438
|
+
px=annotated or None,
|
|
439
|
+
geometry=geometry,
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
hit_regions = _hit_regions(screen, observations, identities, semantic_keys) if qualified else None
|
|
444
|
+
|
|
445
|
+
return SemanticSnapshot(
|
|
446
|
+
sessionId=session_id,
|
|
447
|
+
revision=revision,
|
|
448
|
+
columns=int(getattr(app, "size", _Size()).width),
|
|
449
|
+
rows=int(getattr(app, "size", _Size()).height),
|
|
450
|
+
rootIds=root_ids,
|
|
451
|
+
nodes=nodes,
|
|
452
|
+
v=2 if qualified else 1,
|
|
453
|
+
coordinateSpace=(WireObservation(status="known", value="viewport-cells", evidence="probe") if qualified else None),
|
|
454
|
+
hitGrid=(
|
|
455
|
+
WireObservation(status="known", value={"regions": hit_regions}, evidence="hit-grid")
|
|
456
|
+
if qualified and hit_regions is not None
|
|
457
|
+
else WireObservation(status="unsupported", capability="pointer-hit-grid", reason="framework-unobservable")
|
|
458
|
+
if qualified
|
|
459
|
+
else None
|
|
460
|
+
),
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _hit_regions(
|
|
465
|
+
screen: Any,
|
|
466
|
+
observations: Sequence[WidgetObservation],
|
|
467
|
+
identities: Identities,
|
|
468
|
+
semantic_keys: Dict[int, Optional[str]],
|
|
469
|
+
) -> Optional[List[Dict[str, Any]]]:
|
|
470
|
+
"""Compress Textual's exact fresh-pointer recipient map into row runs."""
|
|
471
|
+
by_object = {id(item.widget): identities.of(item.widget, semantic_keys[id(item.widget)]) for item in observations}
|
|
472
|
+
width = max(0, int(getattr(getattr(screen, "size", None), "width", 0)))
|
|
473
|
+
height = max(0, int(getattr(getattr(screen, "size", None), "height", 0)))
|
|
474
|
+
regions: List[Dict[str, Any]] = []
|
|
475
|
+
lookup = getattr(screen, "get_widget_at", None)
|
|
476
|
+
if not callable(lookup):
|
|
477
|
+
return None
|
|
478
|
+
for row in range(height):
|
|
479
|
+
run_owner: Optional[str] = None
|
|
480
|
+
run_start = 0
|
|
481
|
+
for column in range(width + 1):
|
|
482
|
+
owner: Optional[str] = None
|
|
483
|
+
if column < width:
|
|
484
|
+
try:
|
|
485
|
+
widget, _region = lookup(column, row)
|
|
486
|
+
if not bool(getattr(widget, "loading", False)):
|
|
487
|
+
owner = by_object.get(id(widget))
|
|
488
|
+
if owner is None:
|
|
489
|
+
# A known framework recipient without a semantic id
|
|
490
|
+
# is not "no recipient". Refuse the entire complete
|
|
491
|
+
# map rather than manufacture a false empty cell.
|
|
492
|
+
return None
|
|
493
|
+
except Exception:
|
|
494
|
+
owner = None
|
|
495
|
+
if owner == run_owner:
|
|
496
|
+
continue
|
|
497
|
+
if run_owner is not None:
|
|
498
|
+
regions.append({"rect": {"row": row, "column": run_start, "width": column - run_start, "height": 1}, "recipientId": run_owner})
|
|
499
|
+
run_owner = owner
|
|
500
|
+
run_start = column
|
|
501
|
+
return regions
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
class _Size:
|
|
505
|
+
"""Fallback when the app has no size yet — an unstarted app has none."""
|
|
506
|
+
|
|
507
|
+
width = 80
|
|
508
|
+
height = 24
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _app_name(app: Any) -> str:
|
|
512
|
+
title = getattr(app, "title", None)
|
|
513
|
+
if isinstance(title, str) and title:
|
|
514
|
+
return title
|
|
515
|
+
return type(app).__name__
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _geometry_of(item: WidgetObservation) -> Tuple[Optional[Rect], bool, bool]:
|
|
519
|
+
"""Bounds to publish, whether the node is hidden, and whether it is offscreen.
|
|
520
|
+
|
|
521
|
+
Three cases, and the tree distinguishes all of them:
|
|
522
|
+
|
|
523
|
+
- not displayed: `hidden`, no bounds at all;
|
|
524
|
+
- displayed but entirely clipped: `hidden` and `offscreen`, with a
|
|
525
|
+
zero-area rect at its own origin — "it is somewhere, and none of it is
|
|
526
|
+
on screen";
|
|
527
|
+
- visible: the intersection of its region with the clip.
|
|
528
|
+
"""
|
|
529
|
+
if not item.displayed or item.geometry is None:
|
|
530
|
+
return None, True, False
|
|
531
|
+
|
|
532
|
+
visible = _rect(getattr(item.geometry, "visible_region", None))
|
|
533
|
+
if visible is None:
|
|
534
|
+
# An older Textual without the property: fall back to the region and
|
|
535
|
+
# say so by refusing the occlusion claim elsewhere.
|
|
536
|
+
return _rect(getattr(item.geometry, "region", None)), False, False
|
|
537
|
+
if visible.width == 0 or visible.height == 0:
|
|
538
|
+
region = _rect(getattr(item.geometry, "region", None))
|
|
539
|
+
origin = region if region is not None else visible
|
|
540
|
+
return Rect(row=origin.row, column=origin.column, width=0, height=0), True, True
|
|
541
|
+
return visible, False, False
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _state_of(
|
|
545
|
+
item: WidgetObservation,
|
|
546
|
+
widget: Any,
|
|
547
|
+
role: str,
|
|
548
|
+
focused: Any,
|
|
549
|
+
hidden: bool,
|
|
550
|
+
offscreen: bool,
|
|
551
|
+
) -> Optional[SemanticState]:
|
|
552
|
+
is_focused = focused is widget
|
|
553
|
+
checked: Optional[bool] = None
|
|
554
|
+
if role in ("checkbox", "radio"):
|
|
555
|
+
value = getattr(widget, "value", None)
|
|
556
|
+
if isinstance(value, bool):
|
|
557
|
+
checked = value
|
|
558
|
+
collapsed = getattr(widget, "collapsed", None)
|
|
559
|
+
read_only = getattr(widget, "read_only", None)
|
|
560
|
+
|
|
561
|
+
state = SemanticState(
|
|
562
|
+
# Nothing off-screen holds the focus, whatever the app still points at.
|
|
563
|
+
focused=True if is_focused and not hidden else None,
|
|
564
|
+
disabled=True if bool(getattr(widget, "disabled", False)) else None,
|
|
565
|
+
hidden=True if hidden else None,
|
|
566
|
+
# Only claimed for a node Textual laid out and then clipped entirely
|
|
567
|
+
# away; a widget with display off is hidden without being anywhere.
|
|
568
|
+
offscreen=True if offscreen else None,
|
|
569
|
+
checked=checked,
|
|
570
|
+
expanded=(not collapsed) if isinstance(collapsed, bool) else None,
|
|
571
|
+
multiline=True if type(widget).__name__ == "TextArea" else None,
|
|
572
|
+
readonly=True if read_only is True else None,
|
|
573
|
+
)
|
|
574
|
+
return state if state.to_wire() else None
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _relationship_ids(
|
|
578
|
+
targets: Sequence[Any],
|
|
579
|
+
included: set,
|
|
580
|
+
identities: Identities,
|
|
581
|
+
semantic_keys: Dict[int, Optional[str]],
|
|
582
|
+
) -> Optional[Sequence[str]]:
|
|
583
|
+
resolved: List[str] = []
|
|
584
|
+
for target in targets:
|
|
585
|
+
target_id = id(target)
|
|
586
|
+
if target_id not in included:
|
|
587
|
+
continue
|
|
588
|
+
resolved.append(identities.of(target, semantic_keys[target_id]))
|
|
589
|
+
return tuple(resolved) if resolved else None
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _probe_annotation(widget: Any) -> ResolvedAnnotation:
|
|
593
|
+
"""A broken optional annotation must not remove the framework's facts.
|
|
594
|
+
|
|
595
|
+
Static invalid roles are rejected when the decorator is created. Dynamic
|
|
596
|
+
getters can still throw or return a value of the wrong type later; the
|
|
597
|
+
side-channel then ignores that annotation for this frame instead of
|
|
598
|
+
failing the entire snapshot or reaching into the application's render.
|
|
599
|
+
"""
|
|
600
|
+
try:
|
|
601
|
+
return resolve_annotation(widget)
|
|
602
|
+
except Exception:
|
|
603
|
+
return ResolvedAnnotation()
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _annotated_fields(
|
|
607
|
+
annotation: ResolvedAnnotation, semantic_key_applied: bool = True
|
|
608
|
+
) -> Dict[str, str]:
|
|
609
|
+
"""Per-field provenance for whatever the author annotated by hand.
|
|
610
|
+
|
|
611
|
+
The node as a whole is `framework` — we read it from Textual — but a name
|
|
612
|
+
or a test id the author wrote is theirs, and a consumer resolving a
|
|
613
|
+
conflict needs to know which is which.
|
|
614
|
+
"""
|
|
615
|
+
annotated: Dict[str, str] = {}
|
|
616
|
+
if annotation.name is not None:
|
|
617
|
+
annotated["name"] = "annotation"
|
|
618
|
+
if annotation.role is not None:
|
|
619
|
+
annotated["role"] = "annotation"
|
|
620
|
+
if annotation.description is not None:
|
|
621
|
+
annotated["description"] = "annotation"
|
|
622
|
+
if annotation.test_id is not None:
|
|
623
|
+
annotated["testId"] = "annotation"
|
|
624
|
+
if annotation.extended is not None:
|
|
625
|
+
annotated["extended"] = "annotation"
|
|
626
|
+
if annotation.labelled_by:
|
|
627
|
+
annotated["labelledBy"] = "annotation"
|
|
628
|
+
if annotation.described_by:
|
|
629
|
+
annotated["describedBy"] = "annotation"
|
|
630
|
+
if annotation.actions is not None:
|
|
631
|
+
annotated["actions"] = "annotation"
|
|
632
|
+
if semantic_key_applied and annotation.key is not None:
|
|
633
|
+
annotated["id"] = "annotation"
|
|
634
|
+
return annotated
|