Skip to content

Commit fa4ff52

Browse files
committed
Implement async rerender scheduler and refactor Output API
1 parent 583d262 commit fa4ff52

File tree

12 files changed

+321
-177
lines changed

12 files changed

+321
-177
lines changed

examples/incremental-rendering/incremental-rendering.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,4 +304,5 @@ def on_input(input_char, key):
304304
render(
305305
incremental_rendering_example,
306306
incremental_rendering=True,
307+
interactive=True,
307308
).wait_until_exit()

src/pyinkcli/hooks/_runtime.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
"""
66

77
from __future__ import annotations
8+
import threading
9+
import time
810
from dataclasses import dataclass, field
911
from typing import Any, Callable, Generic, Literal, Optional, TypeVar, Union
1012

@@ -49,6 +51,7 @@ class RuntimeState:
4951
current_update_priority: UpdatePriority = "default"
5052
pending_update_priority: Optional[UpdatePriority] = None
5153
after_batch_callbacks: list[Callable[[], None]] = field(default_factory=list)
54+
scheduled_rerender: bool = False
5255

5356

5457
@dataclass
@@ -59,6 +62,59 @@ class Ref(Generic[T]):
5962
_runtime = RuntimeState()
6063
_rerender_callback: Optional[Callable[[], None]] = None
6164
_UNSET = object()
65+
_scheduler_lock = threading.Lock()
66+
_scheduler_event = threading.Event()
67+
_scheduler_thread: Optional[threading.Thread] = None
68+
69+
70+
def _ensure_scheduler_thread() -> None:
71+
global _scheduler_thread
72+
73+
with _scheduler_lock:
74+
if _scheduler_thread is not None and _scheduler_thread.is_alive():
75+
return
76+
77+
thread = threading.Thread(
78+
target=_scheduled_rerender_loop,
79+
name="pyinkcli-hooks-rerender",
80+
daemon=True,
81+
)
82+
thread.start()
83+
_scheduler_thread = thread
84+
85+
86+
def _scheduled_rerender_loop() -> None:
87+
while True:
88+
_scheduler_event.wait()
89+
_scheduler_event.clear()
90+
# Match JS-style end-of-tick batching: defer dispatch until the current
91+
# Python thread yields so adjacent setter calls collapse into one render.
92+
time.sleep(0)
93+
_flush_scheduled_rerender()
94+
95+
96+
def _schedule_scheduled_rerender() -> None:
97+
_ensure_scheduler_thread()
98+
with _scheduler_lock:
99+
if _runtime.scheduled_rerender:
100+
return
101+
_runtime.scheduled_rerender = True
102+
_scheduler_event.set()
103+
104+
105+
def _flush_scheduled_rerender() -> bool:
106+
callback: Optional[Callable[[], None]]
107+
with _scheduler_lock:
108+
if not _runtime.scheduled_rerender:
109+
return False
110+
_runtime.scheduled_rerender = False
111+
callback = _rerender_callback
112+
113+
if callback is None:
114+
return False
115+
116+
callback()
117+
return True
62118

63119

64120
def _has_initialized_state_slot(state: HookState, hook_index: int) -> bool:
@@ -273,11 +329,17 @@ def _clear_hook_state() -> None:
273329
_runtime.current_update_priority = "default"
274330
_runtime.pending_update_priority = None
275331
_runtime.after_batch_callbacks.clear()
332+
_runtime.scheduled_rerender = False
333+
_scheduler_event.clear()
276334

277335

278336
def _set_rerender_callback(callback: Optional[Callable[[], None]]) -> None:
279337
global _rerender_callback
280338
_rerender_callback = callback
339+
if callback is None:
340+
with _scheduler_lock:
341+
_runtime.scheduled_rerender = False
342+
_scheduler_event.clear()
281343

282344

283345
def _priority_rank(priority: UpdatePriority) -> int:
@@ -422,7 +484,7 @@ def _request_rerender() -> None:
422484
return
423485

424486
if _rerender_callback is not None:
425-
_rerender_callback()
487+
_schedule_scheduled_rerender()
426488

427489

428490
def _flush_batched_rerender() -> None:

src/pyinkcli/ink.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from pyinkcli.hooks.use_input import _dispatch_input, _clear_input_handlers
3535
from pyinkcli.hooks._runtime import (
3636
_clear_hook_state,
37+
_flush_scheduled_rerender,
3738
_reset_hook_state,
3839
_set_rerender_callback,
3940
)
@@ -416,6 +417,7 @@ def wait_until_render_flush(self, timeout: Optional[float] = None) -> None:
416417
if self._is_unmounted or self._is_unmounting:
417418
return
418419

420+
_flush_scheduled_rerender()
419421
self._wait_for_render_flush(timeout=timeout)
420422
self._wait_for_transition_idle(timeout=timeout)
421423

@@ -473,14 +475,17 @@ def _on_render_callback(self) -> None:
473475
if self._is_unmounted:
474476
return
475477

476-
start_time = time.time()
477-
result = self._sanitize_render_result(
478-
render_dom(self._root_node, self._is_screen_reader_enabled)
479-
)
480-
481478
if self._on_render:
482-
metrics = RenderMetrics(render_time=time.time() - start_time)
479+
start_time = time.perf_counter()
480+
result = self._sanitize_render_result(
481+
render_dom(self._root_node, self._is_screen_reader_enabled)
482+
)
483+
metrics = RenderMetrics(render_time=time.perf_counter() - start_time)
483484
self._on_render(metrics)
485+
else:
486+
result = self._sanitize_render_result(
487+
render_dom(self._root_node, self._is_screen_reader_enabled)
488+
)
484489

485490
static_output_delta = self._get_static_output_delta(result.staticOutput)
486491
has_static_output = static_output_delta != ""

0 commit comments

Comments
 (0)