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
termwright/textual.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Optional semantic intent for custom Textual widgets.
|
|
2
|
+
|
|
3
|
+
Textual's automatic probe already knows the DOM, geometry, focus and widget
|
|
4
|
+
state. This module deliberately cannot describe any of those physical facts;
|
|
5
|
+
it only supplies application meaning that the framework cannot infer.
|
|
6
|
+
|
|
7
|
+
The class decorator is the normal API::
|
|
8
|
+
|
|
9
|
+
@semantic(
|
|
10
|
+
role="button",
|
|
11
|
+
name=lambda widget: widget.label,
|
|
12
|
+
test_id="deploy-production",
|
|
13
|
+
extended=lambda widget: {"environment": widget.environment},
|
|
14
|
+
key=lambda widget: f"deployment:{widget.environment}",
|
|
15
|
+
)
|
|
16
|
+
class DeployWidget(Widget):
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
``annotate`` is available for third-party widget instances that cannot be
|
|
20
|
+
decorated. Both APIs are dormant metadata only: importing this module opens
|
|
21
|
+
no socket and does not install the Textual probe.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass, replace
|
|
27
|
+
from typing import Any, Callable, Mapping, Optional, Sequence, Tuple, TypeVar, Union
|
|
28
|
+
from weakref import WeakKeyDictionary
|
|
29
|
+
|
|
30
|
+
from .roles import ACTION_SET, SEMANTIC_ROLES
|
|
31
|
+
|
|
32
|
+
T = TypeVar("T")
|
|
33
|
+
ResolvedOrFactory = Union[T, Callable[[Any], T]]
|
|
34
|
+
Relationship = Union[Any, Sequence[Any]]
|
|
35
|
+
|
|
36
|
+
_CLASS_ANNOTATION = "__termwright_textual_semantics__"
|
|
37
|
+
_instances: "WeakKeyDictionary[Any, SemanticAnnotation]" = WeakKeyDictionary()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class SemanticAnnotation:
|
|
42
|
+
"""Developer-owned semantic intent.
|
|
43
|
+
|
|
44
|
+
There are intentionally no bounds, focus, visibility, rendered-text or
|
|
45
|
+
portable framework-state fields. Those are observed facts and an
|
|
46
|
+
annotation must not be able to contradict them.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
role: Optional[ResolvedOrFactory[str]] = None
|
|
50
|
+
name: Optional[ResolvedOrFactory[str]] = None
|
|
51
|
+
description: Optional[ResolvedOrFactory[str]] = None
|
|
52
|
+
test_id: Optional[ResolvedOrFactory[str]] = None
|
|
53
|
+
extended: Optional[ResolvedOrFactory[Mapping[str, Any]]] = None
|
|
54
|
+
labelled_by: Optional[ResolvedOrFactory[Relationship]] = None
|
|
55
|
+
described_by: Optional[ResolvedOrFactory[Relationship]] = None
|
|
56
|
+
actions: Optional[ResolvedOrFactory[Sequence[str]]] = None
|
|
57
|
+
key: Optional[ResolvedOrFactory[str]] = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class ResolvedAnnotation:
|
|
62
|
+
"""One annotation evaluated against the current widget instance."""
|
|
63
|
+
|
|
64
|
+
role: Optional[str] = None
|
|
65
|
+
name: Optional[str] = None
|
|
66
|
+
description: Optional[str] = None
|
|
67
|
+
test_id: Optional[str] = None
|
|
68
|
+
extended: Optional[Mapping[str, Any]] = None
|
|
69
|
+
labelled_by: Tuple[Any, ...] = ()
|
|
70
|
+
described_by: Tuple[Any, ...] = ()
|
|
71
|
+
actions: Optional[Tuple[str, ...]] = None
|
|
72
|
+
key: Optional[str] = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def semantic(
|
|
76
|
+
*,
|
|
77
|
+
role: Optional[ResolvedOrFactory[str]] = None,
|
|
78
|
+
name: Optional[ResolvedOrFactory[str]] = None,
|
|
79
|
+
description: Optional[ResolvedOrFactory[str]] = None,
|
|
80
|
+
test_id: Optional[ResolvedOrFactory[str]] = None,
|
|
81
|
+
extended: Optional[ResolvedOrFactory[Mapping[str, Any]]] = None,
|
|
82
|
+
labelled_by: Optional[ResolvedOrFactory[Relationship]] = None,
|
|
83
|
+
described_by: Optional[ResolvedOrFactory[Relationship]] = None,
|
|
84
|
+
actions: Optional[ResolvedOrFactory[Sequence[str]]] = None,
|
|
85
|
+
key: Optional[ResolvedOrFactory[str]] = None,
|
|
86
|
+
) -> Callable[[T], T]:
|
|
87
|
+
"""Decorate a Textual widget class with semantic intent.
|
|
88
|
+
|
|
89
|
+
Values may be constants or callables receiving the live widget. A
|
|
90
|
+
subclass inherits the declaration naturally; another decorator on the
|
|
91
|
+
subclass replaces only the fields it explicitly supplies.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
annotation = _make_annotation(
|
|
95
|
+
role=role,
|
|
96
|
+
name=name,
|
|
97
|
+
description=description,
|
|
98
|
+
test_id=test_id,
|
|
99
|
+
extended=extended,
|
|
100
|
+
labelled_by=labelled_by,
|
|
101
|
+
described_by=described_by,
|
|
102
|
+
actions=actions,
|
|
103
|
+
key=key,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def decorate(klass: T) -> T:
|
|
107
|
+
inherited = getattr(klass, _CLASS_ANNOTATION, SemanticAnnotation())
|
|
108
|
+
setattr(klass, _CLASS_ANNOTATION, _merge(inherited, annotation))
|
|
109
|
+
return klass
|
|
110
|
+
|
|
111
|
+
return decorate
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def annotate(
|
|
115
|
+
widget: T,
|
|
116
|
+
*,
|
|
117
|
+
role: Optional[ResolvedOrFactory[str]] = None,
|
|
118
|
+
name: Optional[ResolvedOrFactory[str]] = None,
|
|
119
|
+
description: Optional[ResolvedOrFactory[str]] = None,
|
|
120
|
+
test_id: Optional[ResolvedOrFactory[str]] = None,
|
|
121
|
+
extended: Optional[ResolvedOrFactory[Mapping[str, Any]]] = None,
|
|
122
|
+
labelled_by: Optional[ResolvedOrFactory[Relationship]] = None,
|
|
123
|
+
described_by: Optional[ResolvedOrFactory[Relationship]] = None,
|
|
124
|
+
actions: Optional[ResolvedOrFactory[Sequence[str]]] = None,
|
|
125
|
+
key: Optional[ResolvedOrFactory[str]] = None,
|
|
126
|
+
) -> T:
|
|
127
|
+
"""Attach semantic intent to one retained widget and return that widget."""
|
|
128
|
+
|
|
129
|
+
current = _instances.get(widget, SemanticAnnotation())
|
|
130
|
+
_instances[widget] = _merge(
|
|
131
|
+
current,
|
|
132
|
+
_make_annotation(
|
|
133
|
+
role=role,
|
|
134
|
+
name=name,
|
|
135
|
+
description=description,
|
|
136
|
+
test_id=test_id,
|
|
137
|
+
extended=extended,
|
|
138
|
+
labelled_by=labelled_by,
|
|
139
|
+
described_by=described_by,
|
|
140
|
+
actions=actions,
|
|
141
|
+
key=key,
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
return widget
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def remove_annotation(widget: Any) -> None:
|
|
148
|
+
"""Remove an instance annotation; class annotations remain in force."""
|
|
149
|
+
|
|
150
|
+
_instances.pop(widget, None)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def resolve_annotation(widget: Any) -> ResolvedAnnotation:
|
|
154
|
+
"""Resolve inherited and instance declarations for the automatic probe."""
|
|
155
|
+
|
|
156
|
+
declared = getattr(type(widget), _CLASS_ANNOTATION, SemanticAnnotation())
|
|
157
|
+
instance = _instances.get(widget)
|
|
158
|
+
annotation = _merge(declared, instance) if instance is not None else declared
|
|
159
|
+
|
|
160
|
+
role = _optional_string(_resolve(annotation.role, widget), "role")
|
|
161
|
+
if role is not None and role not in SEMANTIC_ROLES:
|
|
162
|
+
raise ValueError(f"unknown semantic role: {role!r}")
|
|
163
|
+
|
|
164
|
+
extended_value = _resolve(annotation.extended, widget)
|
|
165
|
+
if extended_value is not None and not isinstance(extended_value, Mapping):
|
|
166
|
+
raise TypeError("extended must resolve to a mapping")
|
|
167
|
+
extended = dict(extended_value) if extended_value is not None else None
|
|
168
|
+
|
|
169
|
+
action_value = _resolve(annotation.actions, widget)
|
|
170
|
+
actions = _semantic_actions(action_value)
|
|
171
|
+
|
|
172
|
+
return ResolvedAnnotation(
|
|
173
|
+
role=role,
|
|
174
|
+
name=_optional_string(_resolve(annotation.name, widget), "name", allow_empty=True),
|
|
175
|
+
description=_optional_string(
|
|
176
|
+
_resolve(annotation.description, widget), "description", allow_empty=True
|
|
177
|
+
),
|
|
178
|
+
test_id=_optional_string(_resolve(annotation.test_id, widget), "test_id"),
|
|
179
|
+
extended=extended,
|
|
180
|
+
labelled_by=_relationships(_resolve(annotation.labelled_by, widget)),
|
|
181
|
+
described_by=_relationships(_resolve(annotation.described_by, widget)),
|
|
182
|
+
actions=actions,
|
|
183
|
+
key=_optional_string(_resolve(annotation.key, widget), "key"),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _make_annotation(**values: Any) -> SemanticAnnotation:
|
|
188
|
+
role = values.get("role")
|
|
189
|
+
if isinstance(role, str) and role not in SEMANTIC_ROLES:
|
|
190
|
+
raise ValueError(f"unknown semantic role: {role!r}")
|
|
191
|
+
actions = values.get("actions")
|
|
192
|
+
if actions is not None and not callable(actions):
|
|
193
|
+
_semantic_actions(actions)
|
|
194
|
+
return SemanticAnnotation(**values)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _semantic_actions(value: Any) -> Optional[Tuple[str, ...]]:
|
|
198
|
+
if value is None:
|
|
199
|
+
return None
|
|
200
|
+
if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
|
|
201
|
+
raise TypeError("actions must resolve to a sequence of semantic actions")
|
|
202
|
+
actions = tuple(value)
|
|
203
|
+
if any(not isinstance(action, str) or action not in ACTION_SET for action in actions):
|
|
204
|
+
raise ValueError("actions must contain only v1 semantic actions")
|
|
205
|
+
if len(set(actions)) != len(actions):
|
|
206
|
+
raise ValueError("actions must not contain duplicates")
|
|
207
|
+
return actions
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _merge(base: SemanticAnnotation, override: Optional[SemanticAnnotation]) -> SemanticAnnotation:
|
|
211
|
+
if override is None:
|
|
212
|
+
return base
|
|
213
|
+
values = {
|
|
214
|
+
field: getattr(override, field)
|
|
215
|
+
if getattr(override, field) is not None
|
|
216
|
+
else getattr(base, field)
|
|
217
|
+
for field in SemanticAnnotation.__dataclass_fields__
|
|
218
|
+
}
|
|
219
|
+
return replace(base, **values)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _resolve(value: Optional[ResolvedOrFactory[T]], widget: Any) -> Optional[T]:
|
|
223
|
+
return value(widget) if callable(value) else value
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _optional_string(value: Any, field: str, *, allow_empty: bool = False) -> Optional[str]:
|
|
227
|
+
if value is None:
|
|
228
|
+
return None
|
|
229
|
+
if not isinstance(value, str) or (not allow_empty and not value):
|
|
230
|
+
qualifier = "a string" if allow_empty else "a non-empty string"
|
|
231
|
+
raise TypeError(f"{field} must resolve to {qualifier}")
|
|
232
|
+
return value
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _relationships(value: Any) -> Tuple[Any, ...]:
|
|
236
|
+
if value is None:
|
|
237
|
+
return ()
|
|
238
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
|
239
|
+
return tuple(value)
|
|
240
|
+
return (value,)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
__all__ = [
|
|
244
|
+
"ResolvedAnnotation",
|
|
245
|
+
"SemanticAnnotation",
|
|
246
|
+
"annotate",
|
|
247
|
+
"remove_annotation",
|
|
248
|
+
"semantic",
|
|
249
|
+
]
|
termwright/tree.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Semantic tree DTOs.
|
|
2
|
+
|
|
3
|
+
These mirror ``@termwright/protocol``'s ``tree.ts``. ``to_wire`` drops unset
|
|
4
|
+
optionals, because the wire schema is strict: an explicit ``null`` is a
|
|
5
|
+
validation failure, not "absent".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Dict, List, Mapping, Optional, Sequence, Union
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Rect:
|
|
16
|
+
"""Zero-based viewport cell coordinates."""
|
|
17
|
+
|
|
18
|
+
row: int
|
|
19
|
+
column: int
|
|
20
|
+
width: int
|
|
21
|
+
height: int
|
|
22
|
+
|
|
23
|
+
def to_wire(self) -> Dict[str, int]:
|
|
24
|
+
return {"row": self.row, "column": self.column, "width": self.width, "height": self.height}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Observation:
|
|
29
|
+
"""Evidence-qualified wire fact; value is present only for ``known``."""
|
|
30
|
+
|
|
31
|
+
status: str
|
|
32
|
+
value: Any = None
|
|
33
|
+
evidence: Optional[str] = None
|
|
34
|
+
reason: Optional[str] = None
|
|
35
|
+
capability: Optional[str] = None
|
|
36
|
+
|
|
37
|
+
def to_wire(self) -> Dict[str, Any]:
|
|
38
|
+
wire: Dict[str, Any] = {"status": self.status}
|
|
39
|
+
if self.status == "known":
|
|
40
|
+
wire["value"] = self.value.to_wire() if hasattr(self.value, "to_wire") else self.value
|
|
41
|
+
wire["evidence"] = self.evidence
|
|
42
|
+
elif self.status == "unsupported":
|
|
43
|
+
wire["capability"] = self.capability
|
|
44
|
+
wire["reason"] = self.reason
|
|
45
|
+
else:
|
|
46
|
+
wire["reason"] = self.reason
|
|
47
|
+
return wire
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class NodeGeometryObservations:
|
|
52
|
+
displayed: Observation
|
|
53
|
+
intendedRect: Observation
|
|
54
|
+
visibleRect: Observation
|
|
55
|
+
|
|
56
|
+
def to_wire(self) -> Dict[str, Any]:
|
|
57
|
+
return {"displayed": self.displayed.to_wire(), "intendedRect": self.intendedRect.to_wire(), "visibleRect": self.visibleRect.to_wire()}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class SemanticState:
|
|
62
|
+
"""Closed state set; unset members are omitted from the wire form."""
|
|
63
|
+
|
|
64
|
+
disabled: Optional[bool] = None
|
|
65
|
+
focused: Optional[bool] = None
|
|
66
|
+
selected: Optional[bool] = None
|
|
67
|
+
checked: Optional[Union[bool, str]] = None
|
|
68
|
+
expanded: Optional[bool] = None
|
|
69
|
+
modal: Optional[bool] = None
|
|
70
|
+
busy: Optional[bool] = None
|
|
71
|
+
hidden: Optional[bool] = None
|
|
72
|
+
#: Every cell is outside the visible area — scrolled away, not undisplayed.
|
|
73
|
+
#: Implies ``hidden``; the pair without it is refused by validation.
|
|
74
|
+
offscreen: Optional[bool] = None
|
|
75
|
+
readonly: Optional[bool] = None
|
|
76
|
+
multiline: Optional[bool] = None
|
|
77
|
+
orientation: Optional[str] = None
|
|
78
|
+
level: Optional[int] = None
|
|
79
|
+
positionInSet: Optional[int] = None
|
|
80
|
+
setSize: Optional[int] = None
|
|
81
|
+
scrollOffset: Optional[int] = None
|
|
82
|
+
scrollExtent: Optional[int] = None
|
|
83
|
+
|
|
84
|
+
def to_wire(self) -> Dict[str, Any]:
|
|
85
|
+
return {
|
|
86
|
+
name: value
|
|
87
|
+
for name, value in self.__dict__.items()
|
|
88
|
+
if value is not None
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class SemanticTextRange:
|
|
94
|
+
"""Maps grapheme offsets of a node's text onto cell coordinates."""
|
|
95
|
+
|
|
96
|
+
startOffset: int
|
|
97
|
+
endOffset: int
|
|
98
|
+
rect: Rect
|
|
99
|
+
|
|
100
|
+
def to_wire(self) -> Dict[str, Any]:
|
|
101
|
+
return {
|
|
102
|
+
"startOffset": self.startOffset,
|
|
103
|
+
"endOffset": self.endOffset,
|
|
104
|
+
"rect": self.rect.to_wire(),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class SemanticNode:
|
|
110
|
+
"""One accessible node. ``bounds`` are absolute viewport cells."""
|
|
111
|
+
|
|
112
|
+
id: str
|
|
113
|
+
role: str
|
|
114
|
+
name: str = ""
|
|
115
|
+
parentId: Optional[str] = None
|
|
116
|
+
description: Optional[str] = None
|
|
117
|
+
value: Optional[str] = None
|
|
118
|
+
bounds: Optional[Rect] = None
|
|
119
|
+
state: Optional[SemanticState] = None
|
|
120
|
+
#: Application-defined JSON state. Portable flags stay in ``state``.
|
|
121
|
+
extended: Optional[Mapping[str, Any]] = None
|
|
122
|
+
actions: Optional[Sequence[str]] = None
|
|
123
|
+
labelledBy: Optional[Sequence[str]] = None
|
|
124
|
+
describedBy: Optional[Sequence[str]] = None
|
|
125
|
+
textRanges: Optional[Sequence[SemanticTextRange]] = None
|
|
126
|
+
testId: Optional[str] = None
|
|
127
|
+
#: What the UI framework calls this widget. Required when ``role`` is
|
|
128
|
+
#: ``generic``: an unrecognised widget must at least name its own type, so
|
|
129
|
+
#: a reader can tell one unknown thing from another.
|
|
130
|
+
frameworkType: Optional[str] = None
|
|
131
|
+
#: Whether the producer can say if these cells are covered by something
|
|
132
|
+
#: painted later. Only a producer that observes paint order may say
|
|
133
|
+
#: ``"known"``; the driver refuses pointer actions on anything else.
|
|
134
|
+
occlusion: Optional[str] = None
|
|
135
|
+
#: Where this node's facts came from, as a whole.
|
|
136
|
+
p: Optional[str] = None
|
|
137
|
+
#: Where individual fields came from, when they differ from ``p``.
|
|
138
|
+
px: Optional[Mapping[str, str]] = None
|
|
139
|
+
geometry: Optional[NodeGeometryObservations] = None
|
|
140
|
+
|
|
141
|
+
def to_wire(self) -> Dict[str, Any]:
|
|
142
|
+
wire: Dict[str, Any] = {"id": self.id, "role": self.role, "name": self.name}
|
|
143
|
+
if self.parentId is not None:
|
|
144
|
+
wire["parentId"] = self.parentId
|
|
145
|
+
if self.description is not None:
|
|
146
|
+
wire["description"] = self.description
|
|
147
|
+
if self.value is not None:
|
|
148
|
+
wire["value"] = self.value
|
|
149
|
+
if self.bounds is not None:
|
|
150
|
+
wire["bounds"] = self.bounds.to_wire()
|
|
151
|
+
if self.state is not None:
|
|
152
|
+
state = self.state.to_wire()
|
|
153
|
+
if state:
|
|
154
|
+
wire["state"] = state
|
|
155
|
+
if self.extended is not None:
|
|
156
|
+
wire["extended"] = _canonical_extended(self.extended)
|
|
157
|
+
if self.actions is not None:
|
|
158
|
+
wire["actions"] = list(self.actions)
|
|
159
|
+
if self.labelledBy is not None:
|
|
160
|
+
wire["labelledBy"] = list(self.labelledBy)
|
|
161
|
+
if self.describedBy is not None:
|
|
162
|
+
wire["describedBy"] = list(self.describedBy)
|
|
163
|
+
if self.textRanges is not None:
|
|
164
|
+
wire["textRanges"] = [item.to_wire() for item in self.textRanges]
|
|
165
|
+
if self.testId is not None:
|
|
166
|
+
wire["testId"] = self.testId
|
|
167
|
+
if self.frameworkType is not None:
|
|
168
|
+
wire["frameworkType"] = self.frameworkType
|
|
169
|
+
if self.occlusion is not None:
|
|
170
|
+
wire["occlusion"] = self.occlusion
|
|
171
|
+
if self.p is not None:
|
|
172
|
+
wire["p"] = self.p
|
|
173
|
+
if self.px:
|
|
174
|
+
wire["px"] = dict(self.px)
|
|
175
|
+
if self.geometry is not None:
|
|
176
|
+
wire["geometry"] = self.geometry.to_wire()
|
|
177
|
+
return wire
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _canonical_extended(value: Any) -> Any:
|
|
181
|
+
"""Copy JSON-like domain state with stable object-key ordering."""
|
|
182
|
+
if isinstance(value, Mapping):
|
|
183
|
+
return {key: _canonical_extended(value[key]) for key in sorted(value)}
|
|
184
|
+
if isinstance(value, (list, tuple)):
|
|
185
|
+
return [_canonical_extended(item) for item in value]
|
|
186
|
+
return value
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class CursorInfo:
|
|
191
|
+
"""Terminal cursor position, in viewport cells."""
|
|
192
|
+
|
|
193
|
+
row: int
|
|
194
|
+
column: int
|
|
195
|
+
visible: bool
|
|
196
|
+
shape: Optional[str] = None
|
|
197
|
+
|
|
198
|
+
def to_wire(self) -> Dict[str, Any]:
|
|
199
|
+
wire: Dict[str, Any] = {"row": self.row, "column": self.column, "visible": self.visible}
|
|
200
|
+
if self.shape is not None:
|
|
201
|
+
wire["shape"] = self.shape
|
|
202
|
+
return wire
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True)
|
|
206
|
+
class SemanticSnapshot:
|
|
207
|
+
"""A whole tree for one committed render."""
|
|
208
|
+
|
|
209
|
+
sessionId: str
|
|
210
|
+
revision: int
|
|
211
|
+
columns: int
|
|
212
|
+
rows: int
|
|
213
|
+
rootIds: Sequence[str] = field(default_factory=list)
|
|
214
|
+
nodes: Sequence[SemanticNode] = field(default_factory=list)
|
|
215
|
+
cursor: Optional[CursorInfo] = None
|
|
216
|
+
v: int = 1
|
|
217
|
+
coordinateSpace: Optional[Observation] = None
|
|
218
|
+
hitGrid: Optional[Observation] = None
|
|
219
|
+
|
|
220
|
+
def to_wire(self) -> Dict[str, Any]:
|
|
221
|
+
wire: Dict[str, Any] = {
|
|
222
|
+
"v": self.v,
|
|
223
|
+
"sessionId": self.sessionId,
|
|
224
|
+
"revision": self.revision,
|
|
225
|
+
"columns": self.columns,
|
|
226
|
+
"rows": self.rows,
|
|
227
|
+
}
|
|
228
|
+
if self.cursor is not None:
|
|
229
|
+
wire["cursor"] = self.cursor.to_wire()
|
|
230
|
+
wire["rootIds"] = list(self.rootIds)
|
|
231
|
+
wire["nodes"] = [node.to_wire() for node in self.nodes]
|
|
232
|
+
if self.coordinateSpace is not None:
|
|
233
|
+
wire["coordinateSpace"] = self.coordinateSpace.to_wire()
|
|
234
|
+
if self.hitGrid is not None:
|
|
235
|
+
wire["hitGrid"] = self.hitGrid.to_wire()
|
|
236
|
+
return wire
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def snapshot_from_wire(value: Dict[str, Any]) -> SemanticSnapshot:
|
|
240
|
+
"""Rebuild a snapshot from an already-validated wire object."""
|
|
241
|
+
nodes: List[SemanticNode] = []
|
|
242
|
+
for raw in value["nodes"]:
|
|
243
|
+
bounds = raw.get("bounds")
|
|
244
|
+
state = raw.get("state")
|
|
245
|
+
ranges = raw.get("textRanges")
|
|
246
|
+
nodes.append(
|
|
247
|
+
SemanticNode(
|
|
248
|
+
id=raw["id"],
|
|
249
|
+
role=raw["role"],
|
|
250
|
+
name=raw.get("name", ""),
|
|
251
|
+
parentId=raw.get("parentId"),
|
|
252
|
+
description=raw.get("description"),
|
|
253
|
+
value=raw.get("value"),
|
|
254
|
+
bounds=Rect(**bounds) if bounds is not None else None,
|
|
255
|
+
state=SemanticState(**state) if state is not None else None,
|
|
256
|
+
actions=tuple(raw["actions"]) if raw.get("actions") is not None else None,
|
|
257
|
+
labelledBy=tuple(raw["labelledBy"]) if raw.get("labelledBy") is not None else None,
|
|
258
|
+
describedBy=tuple(raw["describedBy"]) if raw.get("describedBy") is not None else None,
|
|
259
|
+
textRanges=tuple(
|
|
260
|
+
SemanticTextRange(
|
|
261
|
+
startOffset=item["startOffset"],
|
|
262
|
+
endOffset=item["endOffset"],
|
|
263
|
+
rect=Rect(**item["rect"]),
|
|
264
|
+
)
|
|
265
|
+
for item in ranges
|
|
266
|
+
)
|
|
267
|
+
if ranges is not None
|
|
268
|
+
else None,
|
|
269
|
+
testId=raw.get("testId"),
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
cursor = value.get("cursor")
|
|
273
|
+
return SemanticSnapshot(
|
|
274
|
+
sessionId=value["sessionId"],
|
|
275
|
+
revision=value["revision"],
|
|
276
|
+
columns=value["columns"],
|
|
277
|
+
rows=value["rows"],
|
|
278
|
+
rootIds=tuple(value["rootIds"]),
|
|
279
|
+
nodes=tuple(nodes),
|
|
280
|
+
cursor=CursorInfo(**cursor) if cursor is not None else None,
|
|
281
|
+
v=value["v"],
|
|
282
|
+
)
|