pulse-framework 0.1.75__py3-none-any.whl → 0.1.77__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.
- pulse/__init__.py +12 -0
- pulse/channel.py +6 -3
- pulse/components/for_.py +7 -8
- pulse/dom/props.py +2 -0
- pulse/js/__init__.py +3 -0
- pulse/js/__init__.pyi +11 -0
- pulse/js/_types.py +3 -0
- pulse/js/animation.py +217 -0
- pulse/js/document.py +8 -0
- pulse/js/react.py +2 -2
- pulse/refs.py +893 -0
- pulse/render_session.py +27 -0
- pulse/renderer.py +23 -1
- pulse/transpiler/vdom.py +12 -1
- {pulse_framework-0.1.75.dist-info → pulse_framework-0.1.77.dist-info}/METADATA +1 -1
- {pulse_framework-0.1.75.dist-info → pulse_framework-0.1.77.dist-info}/RECORD +18 -16
- {pulse_framework-0.1.75.dist-info → pulse_framework-0.1.77.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.75.dist-info → pulse_framework-0.1.77.dist-info}/entry_points.txt +0 -0
pulse/refs.py
ADDED
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import re
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any, Generic, Literal, TypeVar, cast, overload, override
|
|
9
|
+
|
|
10
|
+
from pulse.channel import Channel
|
|
11
|
+
from pulse.context import PulseContext
|
|
12
|
+
from pulse.helpers import Disposable
|
|
13
|
+
from pulse.hooks.core import HookMetadata, HookState, hooks
|
|
14
|
+
from pulse.hooks.state import collect_component_identity
|
|
15
|
+
from pulse.scheduling import create_future, create_task
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
Number = int | float
|
|
19
|
+
|
|
20
|
+
_ATTR_ALIASES: dict[str, str] = {
|
|
21
|
+
"className": "class",
|
|
22
|
+
"htmlFor": "for",
|
|
23
|
+
"tabIndex": "tabindex",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_ATTR_NAME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_:\-\.]*$")
|
|
27
|
+
|
|
28
|
+
_GETTABLE_PROPS: set[str] = {
|
|
29
|
+
"value",
|
|
30
|
+
"checked",
|
|
31
|
+
"disabled",
|
|
32
|
+
"readOnly",
|
|
33
|
+
"selectedIndex",
|
|
34
|
+
"selectionStart",
|
|
35
|
+
"selectionEnd",
|
|
36
|
+
"selectionDirection",
|
|
37
|
+
"scrollTop",
|
|
38
|
+
"scrollLeft",
|
|
39
|
+
"scrollHeight",
|
|
40
|
+
"scrollWidth",
|
|
41
|
+
"clientWidth",
|
|
42
|
+
"clientHeight",
|
|
43
|
+
"offsetWidth",
|
|
44
|
+
"offsetHeight",
|
|
45
|
+
"innerText",
|
|
46
|
+
"textContent",
|
|
47
|
+
"className",
|
|
48
|
+
"id",
|
|
49
|
+
"name",
|
|
50
|
+
"type",
|
|
51
|
+
"tabIndex",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_SETTABLE_PROPS: set[str] = {
|
|
55
|
+
"value",
|
|
56
|
+
"checked",
|
|
57
|
+
"disabled",
|
|
58
|
+
"readOnly",
|
|
59
|
+
"selectedIndex",
|
|
60
|
+
"selectionStart",
|
|
61
|
+
"selectionEnd",
|
|
62
|
+
"selectionDirection",
|
|
63
|
+
"scrollTop",
|
|
64
|
+
"scrollLeft",
|
|
65
|
+
"className",
|
|
66
|
+
"id",
|
|
67
|
+
"name",
|
|
68
|
+
"type",
|
|
69
|
+
"tabIndex",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _normalize_attr_name(name: str) -> str:
|
|
74
|
+
return _ATTR_ALIASES.get(name, name)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _validate_attr_name(name: str) -> str:
|
|
78
|
+
if not isinstance(name, str):
|
|
79
|
+
raise TypeError("ref attribute name must be a string")
|
|
80
|
+
trimmed = name.strip()
|
|
81
|
+
if not trimmed:
|
|
82
|
+
raise ValueError("ref attribute name must be non-empty")
|
|
83
|
+
normalized = _normalize_attr_name(trimmed)
|
|
84
|
+
if not _ATTR_NAME_PATTERN.match(normalized):
|
|
85
|
+
raise ValueError(f"Invalid attribute name: {normalized}")
|
|
86
|
+
if normalized.lower().startswith("on"):
|
|
87
|
+
raise ValueError("ref attribute name cannot start with 'on'")
|
|
88
|
+
return normalized
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _validate_prop_name(name: str, *, settable: bool) -> str:
|
|
92
|
+
if not isinstance(name, str):
|
|
93
|
+
raise TypeError("ref property name must be a string")
|
|
94
|
+
trimmed = name.strip()
|
|
95
|
+
if not trimmed:
|
|
96
|
+
raise ValueError("ref property name must be non-empty")
|
|
97
|
+
if trimmed not in _GETTABLE_PROPS:
|
|
98
|
+
raise ValueError(f"Unsupported ref property: {trimmed}")
|
|
99
|
+
if settable and trimmed not in _SETTABLE_PROPS:
|
|
100
|
+
raise ValueError(f"Ref property is read-only: {trimmed}")
|
|
101
|
+
return trimmed
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class RefNotMounted(RuntimeError):
|
|
105
|
+
"""Raised when a ref operation is attempted before mount."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class RefTimeout(asyncio.TimeoutError):
|
|
109
|
+
"""Raised when waiting for a ref mount times out."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class RefHandle(Disposable, Generic[T]):
|
|
113
|
+
"""Server-side handle for a client DOM ref."""
|
|
114
|
+
|
|
115
|
+
__slots__: tuple[str, ...] = (
|
|
116
|
+
"_channel",
|
|
117
|
+
"id",
|
|
118
|
+
"_mounted",
|
|
119
|
+
"_mount_waiters",
|
|
120
|
+
"_mount_handlers",
|
|
121
|
+
"_unmount_handlers",
|
|
122
|
+
"_owns_channel",
|
|
123
|
+
"_remove_mount",
|
|
124
|
+
"_remove_unmount",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
_channel: Channel
|
|
128
|
+
id: str
|
|
129
|
+
_mounted: bool
|
|
130
|
+
_mount_waiters: list[asyncio.Future[None]]
|
|
131
|
+
_mount_handlers: list[Callable[[], Any]]
|
|
132
|
+
_unmount_handlers: list[Callable[[], Any]]
|
|
133
|
+
_owns_channel: bool
|
|
134
|
+
_remove_mount: Callable[[], None] | None
|
|
135
|
+
_remove_unmount: Callable[[], None] | None
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
channel: Channel,
|
|
140
|
+
*,
|
|
141
|
+
ref_id: str | None = None,
|
|
142
|
+
owns_channel: bool = True,
|
|
143
|
+
) -> None:
|
|
144
|
+
self._channel = channel
|
|
145
|
+
self.id = ref_id or uuid.uuid4().hex
|
|
146
|
+
self._mounted = False
|
|
147
|
+
self._mount_waiters = []
|
|
148
|
+
self._mount_handlers = []
|
|
149
|
+
self._unmount_handlers = []
|
|
150
|
+
self._owns_channel = owns_channel
|
|
151
|
+
self._remove_mount = self._channel.on("ref:mounted", self._on_mounted)
|
|
152
|
+
self._remove_unmount = self._channel.on("ref:unmounted", self._on_unmounted)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def channel_id(self) -> str:
|
|
156
|
+
return self._channel.id
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def mounted(self) -> bool:
|
|
160
|
+
return self._mounted
|
|
161
|
+
|
|
162
|
+
def on_mount(self, handler: Callable[[], Any]) -> Callable[[], None]:
|
|
163
|
+
self._mount_handlers.append(handler)
|
|
164
|
+
|
|
165
|
+
def _remove() -> None:
|
|
166
|
+
try:
|
|
167
|
+
self._mount_handlers.remove(handler)
|
|
168
|
+
except ValueError:
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
return _remove
|
|
172
|
+
|
|
173
|
+
def on_unmount(self, handler: Callable[[], Any]) -> Callable[[], None]:
|
|
174
|
+
self._unmount_handlers.append(handler)
|
|
175
|
+
|
|
176
|
+
def _remove() -> None:
|
|
177
|
+
try:
|
|
178
|
+
self._unmount_handlers.remove(handler)
|
|
179
|
+
except ValueError:
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
return _remove
|
|
183
|
+
|
|
184
|
+
async def wait_mounted(self, timeout: float | None = None) -> None:
|
|
185
|
+
if self._mounted:
|
|
186
|
+
return
|
|
187
|
+
fut = create_future()
|
|
188
|
+
self._mount_waiters.append(fut)
|
|
189
|
+
try:
|
|
190
|
+
if timeout is None:
|
|
191
|
+
await fut
|
|
192
|
+
else:
|
|
193
|
+
await asyncio.wait_for(fut, timeout=timeout)
|
|
194
|
+
except asyncio.TimeoutError as exc:
|
|
195
|
+
raise RefTimeout("Timed out waiting for ref to mount") from exc
|
|
196
|
+
finally:
|
|
197
|
+
if fut in self._mount_waiters:
|
|
198
|
+
self._mount_waiters.remove(fut)
|
|
199
|
+
|
|
200
|
+
def focus(self, *, prevent_scroll: bool | None = None) -> None:
|
|
201
|
+
payload = None
|
|
202
|
+
if prevent_scroll is not None:
|
|
203
|
+
if not isinstance(prevent_scroll, bool):
|
|
204
|
+
raise TypeError("focus() prevent_scroll must be a bool")
|
|
205
|
+
payload = {"preventScroll": prevent_scroll}
|
|
206
|
+
self._emit("focus", payload)
|
|
207
|
+
|
|
208
|
+
def blur(self) -> None:
|
|
209
|
+
self._emit("blur")
|
|
210
|
+
|
|
211
|
+
def click(self) -> None:
|
|
212
|
+
self._emit("click")
|
|
213
|
+
|
|
214
|
+
def submit(self) -> None:
|
|
215
|
+
self._emit("submit")
|
|
216
|
+
|
|
217
|
+
def reset(self) -> None:
|
|
218
|
+
self._emit("reset")
|
|
219
|
+
|
|
220
|
+
def scroll_into_view(
|
|
221
|
+
self,
|
|
222
|
+
*,
|
|
223
|
+
behavior: str | None = None,
|
|
224
|
+
block: str | None = None,
|
|
225
|
+
inline: str | None = None,
|
|
226
|
+
) -> None:
|
|
227
|
+
payload = {
|
|
228
|
+
k: v
|
|
229
|
+
for k, v in {
|
|
230
|
+
"behavior": behavior,
|
|
231
|
+
"block": block,
|
|
232
|
+
"inline": inline,
|
|
233
|
+
}.items()
|
|
234
|
+
if v is not None
|
|
235
|
+
}
|
|
236
|
+
self._emit("scrollIntoView", payload if payload else None)
|
|
237
|
+
|
|
238
|
+
def scroll_to(
|
|
239
|
+
self,
|
|
240
|
+
*,
|
|
241
|
+
top: float | int | None = None,
|
|
242
|
+
left: float | int | None = None,
|
|
243
|
+
behavior: str | None = None,
|
|
244
|
+
) -> None:
|
|
245
|
+
if top is not None and not isinstance(top, (int, float)):
|
|
246
|
+
raise TypeError("scroll_to() top must be a number")
|
|
247
|
+
if left is not None and not isinstance(left, (int, float)):
|
|
248
|
+
raise TypeError("scroll_to() left must be a number")
|
|
249
|
+
if behavior is not None and not isinstance(behavior, str):
|
|
250
|
+
raise TypeError("scroll_to() behavior must be a string")
|
|
251
|
+
payload = {
|
|
252
|
+
k: v
|
|
253
|
+
for k, v in {
|
|
254
|
+
"top": top,
|
|
255
|
+
"left": left,
|
|
256
|
+
"behavior": behavior,
|
|
257
|
+
}.items()
|
|
258
|
+
if v is not None
|
|
259
|
+
}
|
|
260
|
+
self._emit("scrollTo", payload if payload else None)
|
|
261
|
+
|
|
262
|
+
def scroll_by(
|
|
263
|
+
self,
|
|
264
|
+
*,
|
|
265
|
+
top: float | int | None = None,
|
|
266
|
+
left: float | int | None = None,
|
|
267
|
+
behavior: str | None = None,
|
|
268
|
+
) -> None:
|
|
269
|
+
if top is not None and not isinstance(top, (int, float)):
|
|
270
|
+
raise TypeError("scroll_by() top must be a number")
|
|
271
|
+
if left is not None and not isinstance(left, (int, float)):
|
|
272
|
+
raise TypeError("scroll_by() left must be a number")
|
|
273
|
+
if behavior is not None and not isinstance(behavior, str):
|
|
274
|
+
raise TypeError("scroll_by() behavior must be a string")
|
|
275
|
+
payload = {
|
|
276
|
+
k: v
|
|
277
|
+
for k, v in {
|
|
278
|
+
"top": top,
|
|
279
|
+
"left": left,
|
|
280
|
+
"behavior": behavior,
|
|
281
|
+
}.items()
|
|
282
|
+
if v is not None
|
|
283
|
+
}
|
|
284
|
+
self._emit("scrollBy", payload if payload else None)
|
|
285
|
+
|
|
286
|
+
async def measure(self, *, timeout: float | None = None) -> dict[str, Any] | None:
|
|
287
|
+
result = await self._request("measure", timeout=timeout)
|
|
288
|
+
if result is None:
|
|
289
|
+
return None
|
|
290
|
+
if isinstance(result, dict):
|
|
291
|
+
return result
|
|
292
|
+
raise TypeError("measure() expected dict result")
|
|
293
|
+
|
|
294
|
+
async def get_value(self, *, timeout: float | None = None) -> Any:
|
|
295
|
+
return await self._request("getValue", timeout=timeout)
|
|
296
|
+
|
|
297
|
+
async def set_value(self, value: Any, *, timeout: float | None = None) -> Any:
|
|
298
|
+
return await self._request("setValue", {"value": value}, timeout=timeout)
|
|
299
|
+
|
|
300
|
+
async def get_text(self, *, timeout: float | None = None) -> str | None:
|
|
301
|
+
result = await self._request("getText", timeout=timeout)
|
|
302
|
+
if result is None:
|
|
303
|
+
return None
|
|
304
|
+
if isinstance(result, str):
|
|
305
|
+
return result
|
|
306
|
+
raise TypeError("get_text() expected string result")
|
|
307
|
+
|
|
308
|
+
async def set_text(self, text: str, *, timeout: float | None = None) -> str | None:
|
|
309
|
+
result = await self._request("setText", {"text": text}, timeout=timeout)
|
|
310
|
+
if result is None:
|
|
311
|
+
return None
|
|
312
|
+
if isinstance(result, str):
|
|
313
|
+
return result
|
|
314
|
+
raise TypeError("set_text() expected string result")
|
|
315
|
+
|
|
316
|
+
def select(self) -> None:
|
|
317
|
+
self._emit("select")
|
|
318
|
+
|
|
319
|
+
def set_selection_range(
|
|
320
|
+
self, start: int, end: int, *, direction: str | None = None
|
|
321
|
+
) -> None:
|
|
322
|
+
if not isinstance(start, int) or not isinstance(end, int):
|
|
323
|
+
raise TypeError("set_selection_range() requires integer start/end")
|
|
324
|
+
if direction is not None and not isinstance(direction, str):
|
|
325
|
+
raise TypeError("set_selection_range() direction must be a string")
|
|
326
|
+
payload: dict[str, Any] = {"start": start, "end": end}
|
|
327
|
+
if direction is not None:
|
|
328
|
+
payload["direction"] = direction
|
|
329
|
+
self._emit("setSelectionRange", payload)
|
|
330
|
+
|
|
331
|
+
@overload
|
|
332
|
+
async def get_attr(
|
|
333
|
+
self,
|
|
334
|
+
name: Literal[
|
|
335
|
+
"className",
|
|
336
|
+
"class",
|
|
337
|
+
"id",
|
|
338
|
+
"name",
|
|
339
|
+
"type",
|
|
340
|
+
"title",
|
|
341
|
+
"placeholder",
|
|
342
|
+
"role",
|
|
343
|
+
"href",
|
|
344
|
+
"src",
|
|
345
|
+
"alt",
|
|
346
|
+
"htmlFor",
|
|
347
|
+
"for",
|
|
348
|
+
"tabIndex",
|
|
349
|
+
"tabindex",
|
|
350
|
+
"aria-label",
|
|
351
|
+
"aria-hidden",
|
|
352
|
+
"data-test",
|
|
353
|
+
"value",
|
|
354
|
+
],
|
|
355
|
+
*,
|
|
356
|
+
timeout: float | None = None,
|
|
357
|
+
) -> str | None: ...
|
|
358
|
+
|
|
359
|
+
@overload
|
|
360
|
+
async def get_attr(
|
|
361
|
+
self, name: str, *, timeout: float | None = None
|
|
362
|
+
) -> str | None: ...
|
|
363
|
+
|
|
364
|
+
async def get_attr(self, name: str, *, timeout: float | None = None) -> str | None:
|
|
365
|
+
normalized = _validate_attr_name(name)
|
|
366
|
+
result = await self._request("getAttr", {"name": normalized}, timeout=timeout)
|
|
367
|
+
if result is None:
|
|
368
|
+
return None
|
|
369
|
+
if isinstance(result, str):
|
|
370
|
+
return result
|
|
371
|
+
raise TypeError("get_attr() expected string result")
|
|
372
|
+
|
|
373
|
+
@overload
|
|
374
|
+
async def set_attr(
|
|
375
|
+
self,
|
|
376
|
+
name: Literal[
|
|
377
|
+
"className",
|
|
378
|
+
"class",
|
|
379
|
+
"id",
|
|
380
|
+
"name",
|
|
381
|
+
"type",
|
|
382
|
+
"title",
|
|
383
|
+
"placeholder",
|
|
384
|
+
"role",
|
|
385
|
+
"href",
|
|
386
|
+
"src",
|
|
387
|
+
"alt",
|
|
388
|
+
"htmlFor",
|
|
389
|
+
"for",
|
|
390
|
+
"tabIndex",
|
|
391
|
+
"tabindex",
|
|
392
|
+
"aria-label",
|
|
393
|
+
"aria-hidden",
|
|
394
|
+
"data-test",
|
|
395
|
+
"value",
|
|
396
|
+
],
|
|
397
|
+
value: str | int | float | bool | None,
|
|
398
|
+
*,
|
|
399
|
+
timeout: float | None = None,
|
|
400
|
+
) -> str | None: ...
|
|
401
|
+
|
|
402
|
+
@overload
|
|
403
|
+
async def set_attr(
|
|
404
|
+
self,
|
|
405
|
+
name: str,
|
|
406
|
+
value: Any,
|
|
407
|
+
*,
|
|
408
|
+
timeout: float | None = None,
|
|
409
|
+
) -> str | None: ...
|
|
410
|
+
|
|
411
|
+
async def set_attr(
|
|
412
|
+
self, name: str, value: Any, *, timeout: float | None = None
|
|
413
|
+
) -> str | None:
|
|
414
|
+
normalized = _validate_attr_name(name)
|
|
415
|
+
result = await self._request(
|
|
416
|
+
"setAttr", {"name": normalized, "value": value}, timeout=timeout
|
|
417
|
+
)
|
|
418
|
+
if result is None:
|
|
419
|
+
return None
|
|
420
|
+
if isinstance(result, str):
|
|
421
|
+
return result
|
|
422
|
+
raise TypeError("set_attr() expected string result")
|
|
423
|
+
|
|
424
|
+
async def remove_attr(self, name: str, *, timeout: float | None = None) -> None:
|
|
425
|
+
normalized = _validate_attr_name(name)
|
|
426
|
+
await self._request("removeAttr", {"name": normalized}, timeout=timeout)
|
|
427
|
+
|
|
428
|
+
@overload
|
|
429
|
+
async def get_prop(
|
|
430
|
+
self, name: Literal["value"], *, timeout: float | None = None
|
|
431
|
+
) -> str: ...
|
|
432
|
+
|
|
433
|
+
@overload
|
|
434
|
+
async def get_prop(
|
|
435
|
+
self, name: Literal["checked"], *, timeout: float | None = None
|
|
436
|
+
) -> bool: ...
|
|
437
|
+
|
|
438
|
+
@overload
|
|
439
|
+
async def get_prop(
|
|
440
|
+
self, name: Literal["disabled"], *, timeout: float | None = None
|
|
441
|
+
) -> bool: ...
|
|
442
|
+
|
|
443
|
+
@overload
|
|
444
|
+
async def get_prop(
|
|
445
|
+
self, name: Literal["readOnly"], *, timeout: float | None = None
|
|
446
|
+
) -> bool: ...
|
|
447
|
+
|
|
448
|
+
@overload
|
|
449
|
+
async def get_prop(
|
|
450
|
+
self, name: Literal["selectedIndex"], *, timeout: float | None = None
|
|
451
|
+
) -> Number: ...
|
|
452
|
+
|
|
453
|
+
@overload
|
|
454
|
+
async def get_prop(
|
|
455
|
+
self, name: Literal["selectionStart"], *, timeout: float | None = None
|
|
456
|
+
) -> Number | None: ...
|
|
457
|
+
|
|
458
|
+
@overload
|
|
459
|
+
async def get_prop(
|
|
460
|
+
self, name: Literal["selectionEnd"], *, timeout: float | None = None
|
|
461
|
+
) -> Number | None: ...
|
|
462
|
+
|
|
463
|
+
@overload
|
|
464
|
+
async def get_prop(
|
|
465
|
+
self, name: Literal["selectionDirection"], *, timeout: float | None = None
|
|
466
|
+
) -> str | None: ...
|
|
467
|
+
|
|
468
|
+
@overload
|
|
469
|
+
async def get_prop(
|
|
470
|
+
self, name: Literal["scrollTop"], *, timeout: float | None = None
|
|
471
|
+
) -> Number: ...
|
|
472
|
+
|
|
473
|
+
@overload
|
|
474
|
+
async def get_prop(
|
|
475
|
+
self, name: Literal["scrollLeft"], *, timeout: float | None = None
|
|
476
|
+
) -> Number: ...
|
|
477
|
+
|
|
478
|
+
@overload
|
|
479
|
+
async def get_prop(
|
|
480
|
+
self, name: Literal["scrollHeight"], *, timeout: float | None = None
|
|
481
|
+
) -> Number: ...
|
|
482
|
+
|
|
483
|
+
@overload
|
|
484
|
+
async def get_prop(
|
|
485
|
+
self, name: Literal["scrollWidth"], *, timeout: float | None = None
|
|
486
|
+
) -> Number: ...
|
|
487
|
+
|
|
488
|
+
@overload
|
|
489
|
+
async def get_prop(
|
|
490
|
+
self, name: Literal["clientWidth"], *, timeout: float | None = None
|
|
491
|
+
) -> Number: ...
|
|
492
|
+
|
|
493
|
+
@overload
|
|
494
|
+
async def get_prop(
|
|
495
|
+
self, name: Literal["clientHeight"], *, timeout: float | None = None
|
|
496
|
+
) -> Number: ...
|
|
497
|
+
|
|
498
|
+
@overload
|
|
499
|
+
async def get_prop(
|
|
500
|
+
self, name: Literal["offsetWidth"], *, timeout: float | None = None
|
|
501
|
+
) -> Number: ...
|
|
502
|
+
|
|
503
|
+
@overload
|
|
504
|
+
async def get_prop(
|
|
505
|
+
self, name: Literal["offsetHeight"], *, timeout: float | None = None
|
|
506
|
+
) -> Number: ...
|
|
507
|
+
|
|
508
|
+
@overload
|
|
509
|
+
async def get_prop(
|
|
510
|
+
self, name: Literal["innerText"], *, timeout: float | None = None
|
|
511
|
+
) -> str: ...
|
|
512
|
+
|
|
513
|
+
@overload
|
|
514
|
+
async def get_prop(
|
|
515
|
+
self, name: Literal["textContent"], *, timeout: float | None = None
|
|
516
|
+
) -> str | None: ...
|
|
517
|
+
|
|
518
|
+
@overload
|
|
519
|
+
async def get_prop(
|
|
520
|
+
self, name: Literal["className"], *, timeout: float | None = None
|
|
521
|
+
) -> str: ...
|
|
522
|
+
|
|
523
|
+
@overload
|
|
524
|
+
async def get_prop(
|
|
525
|
+
self, name: Literal["id"], *, timeout: float | None = None
|
|
526
|
+
) -> str: ...
|
|
527
|
+
|
|
528
|
+
@overload
|
|
529
|
+
async def get_prop(
|
|
530
|
+
self, name: Literal["name"], *, timeout: float | None = None
|
|
531
|
+
) -> str: ...
|
|
532
|
+
|
|
533
|
+
@overload
|
|
534
|
+
async def get_prop(
|
|
535
|
+
self, name: Literal["type"], *, timeout: float | None = None
|
|
536
|
+
) -> str: ...
|
|
537
|
+
|
|
538
|
+
@overload
|
|
539
|
+
async def get_prop(
|
|
540
|
+
self, name: Literal["tabIndex"], *, timeout: float | None = None
|
|
541
|
+
) -> Number: ...
|
|
542
|
+
|
|
543
|
+
@overload
|
|
544
|
+
async def get_prop(self, name: str, *, timeout: float | None = None) -> Any: ...
|
|
545
|
+
|
|
546
|
+
async def get_prop(self, name: str, *, timeout: float | None = None) -> Any:
|
|
547
|
+
prop = _validate_prop_name(name, settable=False)
|
|
548
|
+
return await self._request("getProp", {"name": prop}, timeout=timeout)
|
|
549
|
+
|
|
550
|
+
@overload
|
|
551
|
+
async def set_prop(
|
|
552
|
+
self, name: Literal["value"], value: str, *, timeout: float | None = None
|
|
553
|
+
) -> str: ...
|
|
554
|
+
|
|
555
|
+
@overload
|
|
556
|
+
async def set_prop(
|
|
557
|
+
self, name: Literal["checked"], value: bool, *, timeout: float | None = None
|
|
558
|
+
) -> bool: ...
|
|
559
|
+
|
|
560
|
+
@overload
|
|
561
|
+
async def set_prop(
|
|
562
|
+
self, name: Literal["disabled"], value: bool, *, timeout: float | None = None
|
|
563
|
+
) -> bool: ...
|
|
564
|
+
|
|
565
|
+
@overload
|
|
566
|
+
async def set_prop(
|
|
567
|
+
self, name: Literal["readOnly"], value: bool, *, timeout: float | None = None
|
|
568
|
+
) -> bool: ...
|
|
569
|
+
|
|
570
|
+
@overload
|
|
571
|
+
async def set_prop(
|
|
572
|
+
self,
|
|
573
|
+
name: Literal["selectedIndex"],
|
|
574
|
+
value: Number,
|
|
575
|
+
*,
|
|
576
|
+
timeout: float | None = None,
|
|
577
|
+
) -> Number: ...
|
|
578
|
+
|
|
579
|
+
@overload
|
|
580
|
+
async def set_prop(
|
|
581
|
+
self,
|
|
582
|
+
name: Literal["selectionStart"],
|
|
583
|
+
value: Number | None,
|
|
584
|
+
*,
|
|
585
|
+
timeout: float | None = None,
|
|
586
|
+
) -> Number | None: ...
|
|
587
|
+
|
|
588
|
+
@overload
|
|
589
|
+
async def set_prop(
|
|
590
|
+
self,
|
|
591
|
+
name: Literal["selectionEnd"],
|
|
592
|
+
value: Number | None,
|
|
593
|
+
*,
|
|
594
|
+
timeout: float | None = None,
|
|
595
|
+
) -> Number | None: ...
|
|
596
|
+
|
|
597
|
+
@overload
|
|
598
|
+
async def set_prop(
|
|
599
|
+
self,
|
|
600
|
+
name: Literal["selectionDirection"],
|
|
601
|
+
value: str | None,
|
|
602
|
+
*,
|
|
603
|
+
timeout: float | None = None,
|
|
604
|
+
) -> str | None: ...
|
|
605
|
+
|
|
606
|
+
@overload
|
|
607
|
+
async def set_prop(
|
|
608
|
+
self, name: Literal["scrollTop"], value: Number, *, timeout: float | None = None
|
|
609
|
+
) -> Number: ...
|
|
610
|
+
|
|
611
|
+
@overload
|
|
612
|
+
async def set_prop(
|
|
613
|
+
self,
|
|
614
|
+
name: Literal["scrollLeft"],
|
|
615
|
+
value: Number,
|
|
616
|
+
*,
|
|
617
|
+
timeout: float | None = None,
|
|
618
|
+
) -> Number: ...
|
|
619
|
+
|
|
620
|
+
@overload
|
|
621
|
+
async def set_prop(
|
|
622
|
+
self, name: Literal["className"], value: str, *, timeout: float | None = None
|
|
623
|
+
) -> str: ...
|
|
624
|
+
|
|
625
|
+
@overload
|
|
626
|
+
async def set_prop(
|
|
627
|
+
self, name: Literal["id"], value: str, *, timeout: float | None = None
|
|
628
|
+
) -> str: ...
|
|
629
|
+
|
|
630
|
+
@overload
|
|
631
|
+
async def set_prop(
|
|
632
|
+
self, name: Literal["name"], value: str, *, timeout: float | None = None
|
|
633
|
+
) -> str: ...
|
|
634
|
+
|
|
635
|
+
@overload
|
|
636
|
+
async def set_prop(
|
|
637
|
+
self, name: Literal["type"], value: str, *, timeout: float | None = None
|
|
638
|
+
) -> str: ...
|
|
639
|
+
|
|
640
|
+
@overload
|
|
641
|
+
async def set_prop(
|
|
642
|
+
self, name: Literal["tabIndex"], value: Number, *, timeout: float | None = None
|
|
643
|
+
) -> Number: ...
|
|
644
|
+
|
|
645
|
+
@overload
|
|
646
|
+
async def set_prop(
|
|
647
|
+
self, name: str, value: Any, *, timeout: float | None = None
|
|
648
|
+
) -> Any: ...
|
|
649
|
+
|
|
650
|
+
async def set_prop(
|
|
651
|
+
self, name: str, value: Any, *, timeout: float | None = None
|
|
652
|
+
) -> Any:
|
|
653
|
+
prop = _validate_prop_name(name, settable=True)
|
|
654
|
+
return await self._request(
|
|
655
|
+
"setProp", {"name": prop, "value": value}, timeout=timeout
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
async def set_style(
|
|
659
|
+
self, styles: dict[str, Any], *, timeout: float | None = None
|
|
660
|
+
) -> None:
|
|
661
|
+
if not isinstance(styles, dict):
|
|
662
|
+
raise TypeError("set_style() requires a dict")
|
|
663
|
+
for key, value in styles.items():
|
|
664
|
+
if not isinstance(key, str) or not key:
|
|
665
|
+
raise ValueError("set_style() keys must be non-empty strings")
|
|
666
|
+
if isinstance(value, bool):
|
|
667
|
+
raise TypeError("set_style() values must be string, number, or None")
|
|
668
|
+
if value is not None and not isinstance(value, (str, int, float)):
|
|
669
|
+
raise TypeError("set_style() values must be string, number, or None")
|
|
670
|
+
await self._request("setStyle", {"styles": styles}, timeout=timeout)
|
|
671
|
+
|
|
672
|
+
def _emit(self, op: str, payload: Any = None) -> None:
|
|
673
|
+
self._ensure_mounted()
|
|
674
|
+
self._channel.emit(
|
|
675
|
+
"ref:call",
|
|
676
|
+
{"refId": self.id, "op": op, "payload": payload},
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
async def _request(
|
|
680
|
+
self,
|
|
681
|
+
op: str,
|
|
682
|
+
payload: Any = None,
|
|
683
|
+
*,
|
|
684
|
+
timeout: float | None = None,
|
|
685
|
+
) -> Any:
|
|
686
|
+
self._ensure_mounted()
|
|
687
|
+
return await self._channel.request(
|
|
688
|
+
"ref:request",
|
|
689
|
+
{"refId": self.id, "op": op, "payload": payload},
|
|
690
|
+
timeout=timeout,
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
def _ensure_mounted(self) -> None:
|
|
694
|
+
if not self._mounted:
|
|
695
|
+
raise RefNotMounted("Ref is not mounted")
|
|
696
|
+
|
|
697
|
+
def _on_mounted(self, payload: Any) -> None:
|
|
698
|
+
if isinstance(payload, dict):
|
|
699
|
+
ref_id = cast(dict[str, Any], payload).get("refId")
|
|
700
|
+
if ref_id is not None and str(ref_id) != self.id:
|
|
701
|
+
return
|
|
702
|
+
self._mounted = True
|
|
703
|
+
for fut in list(self._mount_waiters):
|
|
704
|
+
if not fut.done():
|
|
705
|
+
fut.set_result(None)
|
|
706
|
+
self._mount_waiters.clear()
|
|
707
|
+
self._run_handlers(self._mount_handlers, label="mount")
|
|
708
|
+
|
|
709
|
+
def _on_unmounted(self, payload: Any) -> None:
|
|
710
|
+
if isinstance(payload, dict):
|
|
711
|
+
ref_id = cast(dict[str, Any], payload).get("refId")
|
|
712
|
+
if ref_id is not None and str(ref_id) != self.id:
|
|
713
|
+
return
|
|
714
|
+
self._mounted = False
|
|
715
|
+
self._run_handlers(self._unmount_handlers, label="unmount")
|
|
716
|
+
|
|
717
|
+
def _run_handlers(self, handlers: list[Callable[[], Any]], *, label: str) -> None:
|
|
718
|
+
for handler in list(handlers):
|
|
719
|
+
try:
|
|
720
|
+
result = handler()
|
|
721
|
+
except Exception:
|
|
722
|
+
# Fail early: propagate on next render via error log if desired
|
|
723
|
+
raise
|
|
724
|
+
if inspect.isawaitable(result):
|
|
725
|
+
task = create_task(result, name=f"ref:{self.id}:{label}")
|
|
726
|
+
|
|
727
|
+
def _on_done(done_task: asyncio.Future[Any]) -> None:
|
|
728
|
+
if done_task.cancelled():
|
|
729
|
+
return
|
|
730
|
+
try:
|
|
731
|
+
done_task.result()
|
|
732
|
+
except asyncio.CancelledError:
|
|
733
|
+
return
|
|
734
|
+
except Exception as exc:
|
|
735
|
+
loop = done_task.get_loop()
|
|
736
|
+
loop.call_exception_handler(
|
|
737
|
+
{
|
|
738
|
+
"message": f"Unhandled exception in ref {label} handler",
|
|
739
|
+
"exception": exc,
|
|
740
|
+
"context": {"ref_id": self.id, "handler": label},
|
|
741
|
+
}
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
task.add_done_callback(_on_done)
|
|
745
|
+
|
|
746
|
+
@override
|
|
747
|
+
def dispose(self) -> None:
|
|
748
|
+
self._mounted = False
|
|
749
|
+
if self._remove_mount is not None:
|
|
750
|
+
self._remove_mount()
|
|
751
|
+
self._remove_mount = None
|
|
752
|
+
if self._remove_unmount is not None:
|
|
753
|
+
self._remove_unmount()
|
|
754
|
+
self._remove_unmount = None
|
|
755
|
+
for fut in list(self._mount_waiters):
|
|
756
|
+
if not fut.done():
|
|
757
|
+
fut.set_exception(RefNotMounted("Ref disposed"))
|
|
758
|
+
self._mount_waiters.clear()
|
|
759
|
+
self._mount_handlers.clear()
|
|
760
|
+
self._unmount_handlers.clear()
|
|
761
|
+
if self._owns_channel:
|
|
762
|
+
self._channel.close()
|
|
763
|
+
|
|
764
|
+
@override
|
|
765
|
+
def __repr__(self) -> str:
|
|
766
|
+
return f"RefHandle(id={self.id}, channel={self.channel_id})"
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
class RefHookState(HookState):
|
|
770
|
+
__slots__: tuple[str, ...] = (
|
|
771
|
+
"instances",
|
|
772
|
+
"called_keys",
|
|
773
|
+
"_channel",
|
|
774
|
+
)
|
|
775
|
+
instances: dict[tuple[str, Any], RefHandle[Any]]
|
|
776
|
+
called_keys: set[tuple[str, Any]]
|
|
777
|
+
_channel: Channel | None
|
|
778
|
+
|
|
779
|
+
def __init__(self) -> None:
|
|
780
|
+
super().__init__()
|
|
781
|
+
self.instances = {}
|
|
782
|
+
self.called_keys = set()
|
|
783
|
+
self._channel = None
|
|
784
|
+
|
|
785
|
+
def _make_key(self, identity: Any, key: str | None) -> tuple[str, Any]:
|
|
786
|
+
if key is None:
|
|
787
|
+
return ("code", identity)
|
|
788
|
+
return ("key", key)
|
|
789
|
+
|
|
790
|
+
@override
|
|
791
|
+
def on_render_start(self, render_cycle: int) -> None:
|
|
792
|
+
super().on_render_start(render_cycle)
|
|
793
|
+
self.called_keys.clear()
|
|
794
|
+
|
|
795
|
+
def get_or_create(
|
|
796
|
+
self, identity: Any, key: str | None
|
|
797
|
+
) -> tuple[RefHandle[Any], bool]:
|
|
798
|
+
full_identity = self._make_key(identity, key)
|
|
799
|
+
if full_identity in self.called_keys:
|
|
800
|
+
if key is None:
|
|
801
|
+
raise RuntimeError(
|
|
802
|
+
"`pulse.ref` can only be called once per component render at the same location. "
|
|
803
|
+
+ "Use the `key` parameter to disambiguate: ps.ref(key=unique_value)"
|
|
804
|
+
)
|
|
805
|
+
raise RuntimeError(
|
|
806
|
+
f"`pulse.ref` can only be called once per component render with key='{key}'"
|
|
807
|
+
)
|
|
808
|
+
self.called_keys.add(full_identity)
|
|
809
|
+
|
|
810
|
+
existing = self.instances.get(full_identity)
|
|
811
|
+
if existing is not None:
|
|
812
|
+
if existing.__disposed__:
|
|
813
|
+
key_label = f"key='{key}'" if key is not None else "callsite"
|
|
814
|
+
raise RuntimeError(
|
|
815
|
+
"`pulse.ref` found a disposed cached RefHandle for "
|
|
816
|
+
+ key_label
|
|
817
|
+
+ ". Do not dispose handles returned by `pulse.ref`."
|
|
818
|
+
)
|
|
819
|
+
return existing, False
|
|
820
|
+
|
|
821
|
+
if self._channel is None or self._channel.closed:
|
|
822
|
+
ctx = PulseContext.get()
|
|
823
|
+
if ctx.render is None:
|
|
824
|
+
raise RuntimeError("ref() requires an active render session")
|
|
825
|
+
self._channel = ctx.render.get_ref_channel()
|
|
826
|
+
handle = RefHandle(self._channel, owns_channel=False)
|
|
827
|
+
self.instances[full_identity] = handle
|
|
828
|
+
return handle, True
|
|
829
|
+
|
|
830
|
+
@override
|
|
831
|
+
def dispose(self) -> None:
|
|
832
|
+
for handle in self.instances.values():
|
|
833
|
+
try:
|
|
834
|
+
handle.dispose()
|
|
835
|
+
except Exception:
|
|
836
|
+
pass
|
|
837
|
+
self._channel = None
|
|
838
|
+
self.instances.clear()
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
ref_hook_state = hooks.create(
|
|
842
|
+
"pulse:core.ref",
|
|
843
|
+
factory=RefHookState,
|
|
844
|
+
metadata=HookMetadata(
|
|
845
|
+
owner="pulse.core",
|
|
846
|
+
description="Internal storage for pulse.ref handles",
|
|
847
|
+
),
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def ref(
|
|
852
|
+
*,
|
|
853
|
+
key: str | None = None,
|
|
854
|
+
on_mount: Callable[[], Any] | None = None,
|
|
855
|
+
on_unmount: Callable[[], Any] | None = None,
|
|
856
|
+
) -> RefHandle[Any]:
|
|
857
|
+
"""Create or retrieve a stable ref handle for a component.
|
|
858
|
+
|
|
859
|
+
Args:
|
|
860
|
+
key: Optional key to disambiguate multiple refs created at the same callsite.
|
|
861
|
+
on_mount: Optional handler called when the ref mounts.
|
|
862
|
+
on_unmount: Optional handler called when the ref unmounts.
|
|
863
|
+
"""
|
|
864
|
+
if key is not None and not isinstance(key, str):
|
|
865
|
+
raise TypeError("ref() key must be a string")
|
|
866
|
+
if key == "":
|
|
867
|
+
raise ValueError("ref() requires a non-empty string key")
|
|
868
|
+
if on_mount is not None and not callable(on_mount):
|
|
869
|
+
raise TypeError("ref() on_mount must be callable")
|
|
870
|
+
if on_unmount is not None and not callable(on_unmount):
|
|
871
|
+
raise TypeError("ref() on_unmount must be callable")
|
|
872
|
+
|
|
873
|
+
identity: Any
|
|
874
|
+
if key is None:
|
|
875
|
+
frame = inspect.currentframe()
|
|
876
|
+
assert frame is not None
|
|
877
|
+
caller = frame.f_back
|
|
878
|
+
assert caller is not None
|
|
879
|
+
identity = collect_component_identity(caller)
|
|
880
|
+
else:
|
|
881
|
+
identity = key
|
|
882
|
+
|
|
883
|
+
hook_state = ref_hook_state()
|
|
884
|
+
handle, created = hook_state.get_or_create(identity, key)
|
|
885
|
+
if created:
|
|
886
|
+
if on_mount is not None:
|
|
887
|
+
handle.on_mount(on_mount)
|
|
888
|
+
if on_unmount is not None:
|
|
889
|
+
handle.on_unmount(on_unmount)
|
|
890
|
+
return handle
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
__all__ = ["RefHandle", "RefNotMounted", "RefTimeout", "ref"]
|