diff --git a/nicegui/async_updater.py b/nicegui/async_updater.py deleted file mode 100644 index 28e0f1662..000000000 --- a/nicegui/async_updater.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import Any, Coroutine, Generator - -from . import globals - - -class AsyncUpdater: - - def __init__(self, coro: Coroutine) -> None: - self.coro = coro - - def __await__(self) -> Generator[Any, None, Any]: - coro_iter = self.coro.__await__() - iter_send, iter_throw = coro_iter.send, coro_iter.throw - send, message = iter_send, None - while True: - try: - signal = send(message) - self.lazy_update() - except StopIteration as err: - return err.value - else: - send = iter_send - try: - message = yield signal - except BaseException as err: - send, message = iter_throw, err - - def lazy_update(self) -> None: - for slot in globals.get_slot_stack(): - slot.lazy_update() diff --git a/nicegui/client.py b/nicegui/client.py index 007c72f4b..2b96c4959 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -9,7 +9,7 @@ from fastapi.responses import Response from fastapi.templating import Jinja2Templates -from . import background_tasks, globals +from . import globals, outbox from .dependencies import generate_js_imports, generate_vue_content from .element import Element from .favicon import get_favicon_url @@ -114,7 +114,7 @@ async def run_javascript(self, code: str, *, 'code': code, 'request_id': request_id if respond else None, } - background_tasks.create(globals.sio.emit('run_javascript', command, room=self.id)) + outbox.enqueue_message('run_javascript', command, self.id) if not respond: return None deadline = time.time() + timeout @@ -126,7 +126,7 @@ async def run_javascript(self, code: str, *, def open(self, target: Union[Callable, str]) -> None: path = target if isinstance(target, str) else globals.page_routes[target] - background_tasks.create(globals.sio.emit('open', path, room=self.id)) + outbox.enqueue_message('open', path, self.id) def on_connect(self, handler: Union[Callable, Awaitable]) -> None: self.connect_handlers.append(handler) diff --git a/nicegui/element.py b/nicegui/element.py index def13b92f..a8b95a800 100644 --- a/nicegui/element.py +++ b/nicegui/element.py @@ -4,9 +4,9 @@ import re from abc import ABC from copy import deepcopy -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union -from . import background_tasks, binding, events, globals, updates +from . import binding, events, globals, outbox from .elements.mixins.visibility import Visibility from .event_listener import EventListener from .slot import Slot @@ -39,6 +39,9 @@ def __init__(self, tag: str, *, _client: Optional[Client] = None) -> None: if slot_stack: self.parent_slot = slot_stack[-1] self.parent_slot.children.append(self) + outbox.enqueue_update(self.parent_slot.parent) + + outbox.enqueue_update(self) def add_slot(self, name: str) -> Slot: self.slots[name] = Slot(self, name) @@ -180,13 +183,13 @@ def collect_descendant_ids(self) -> List[int]: return ids def update(self) -> None: - updates.enqueue(self) + outbox.enqueue_update(self) def run_method(self, name: str, *args: Any) -> None: if not globals.loop: return data = {'id': self.id, 'name': name, 'args': args} - background_tasks.create(globals.sio.emit('run_method', data, room=globals._socket_id or self.client.id)) + outbox.enqueue_message('run_method', data, globals._socket_id or self.client.id) def clear(self) -> None: descendants = [self.client.elements[id] for id in self.collect_descendant_ids()[1:]] diff --git a/nicegui/events.py b/nicegui/events.py index 1dd7bf547..daae11ee4 100644 --- a/nicegui/events.py +++ b/nicegui/events.py @@ -273,7 +273,7 @@ def handle_event(handler: Optional[Callable], if is_coroutine(handler): async def wait_for_result(): with sender.parent_slot: - await AsyncUpdater(result) + await result if globals.loop and globals.loop.is_running(): background_tasks.create(wait_for_result(), name=str(handler)) else: diff --git a/nicegui/functions/notify.py b/nicegui/functions/notify.py index 568d044b0..60b5a9f6c 100644 --- a/nicegui/functions/notify.py +++ b/nicegui/functions/notify.py @@ -1,6 +1,6 @@ from typing import Optional, Union -from .. import background_tasks, globals +from .. import globals, outbox def notify(message: str, *, @@ -23,4 +23,4 @@ def notify(message: str, *, Note: You can pass additional keyword arguments according to `Quasar's Notify API `_. """ options = {key: value for key, value in locals().items() if not key.startswith('_') and value is not None} - background_tasks.create(globals.sio.emit('notify', options, room=globals.get_client().id)) + outbox.enqueue_message('notify', options, globals.get_client().id) diff --git a/nicegui/functions/timer.py b/nicegui/functions/timer.py index ddad6cabd..a4bfc800a 100644 --- a/nicegui/functions/timer.py +++ b/nicegui/functions/timer.py @@ -75,7 +75,7 @@ async def _invoke_callback(self) -> None: try: result = self.callback() if is_coroutine(self.callback): - await AsyncUpdater(result) + await result except Exception: traceback.print_exc() diff --git a/nicegui/nicegui.py b/nicegui/nicegui.py index a657c26c4..0b98d3e3a 100644 --- a/nicegui/nicegui.py +++ b/nicegui/nicegui.py @@ -10,7 +10,7 @@ from fastapi.staticfiles import StaticFiles from fastapi_socketio import SocketManager -from . import background_tasks, binding, globals, updates +from . import background_tasks, binding, globals, outbox from .app import App from .client import Client from .dependencies import js_components, js_dependencies @@ -61,7 +61,7 @@ def handle_startup(with_welcome_message: bool = True) -> None: for t in globals.startup_handlers: safe_invoke(t) background_tasks.create(binding.loop()) - background_tasks.create(updates.loop()) + background_tasks.create(outbox.loop()) background_tasks.create(prune_clients()) background_tasks.create(prune_slot_stacks()) globals.state = globals.State.STARTED diff --git a/nicegui/outbox.py b/nicegui/outbox.py new file mode 100644 index 000000000..68c4f6e9d --- /dev/null +++ b/nicegui/outbox.py @@ -0,0 +1,38 @@ +import asyncio +from collections import deque +from typing import TYPE_CHECKING, Any, Deque, Literal, Tuple + +from . import globals + +if TYPE_CHECKING: + from .element import Element + ClientId = int + MessageType = Literal['update', 'run_method', 'run_javascript', 'open', 'notify'] + MessageGroup = Tuple[ClientId, MessageType, Any] + +queue: Deque['MessageGroup'] = deque() + + +def enqueue_update(element: 'Element') -> None: + if queue: + client_id, message_type, argument = queue[-1] + if client_id == element.client.id and message_type == 'update': + elements: Deque[Element] = argument + elements.append(element) + return + queue.append((element.client.id, 'update', deque([element]))) + + +def enqueue_message(message_type: 'MessageType', data: Any, client_id: 'ClientId') -> None: + queue.append((client_id, message_type, data)) + + +async def loop() -> None: + while True: + while queue: + client_id, message_type, data = queue.popleft() + if message_type == 'update': + messages: Deque[Element] = data + data = {'elements': {e.id: e.to_dict() for e in messages}} + await globals.sio.emit(message_type, data, room=client_id) + await asyncio.sleep(0.01) diff --git a/nicegui/page.py b/nicegui/page.py index 3c22c8e7b..9e462d863 100644 --- a/nicegui/page.py +++ b/nicegui/page.py @@ -65,7 +65,7 @@ async def decorated(*dec_args, **dec_kwargs) -> Response: if inspect.isawaitable(result): async def wait_for_result() -> None: with client: - await AsyncUpdater(result) + await result task = background_tasks.create(wait_for_result()) deadline = time.time() + self.response_timeout while task and not client.is_waiting_for_connection and not task.done(): diff --git a/nicegui/slot.py b/nicegui/slot.py index dbcbce6c0..2d0b0619e 100644 --- a/nicegui/slot.py +++ b/nicegui/slot.py @@ -12,19 +12,11 @@ def __init__(self, parent: 'Element', name: str) -> None: self.name = name self.parent = parent self.children: List['Element'] = [] - self.child_count = 0 def __enter__(self): - self.child_count = len(self.children) globals.get_slot_stack().append(self) return self def __exit__(self, *_): globals.get_slot_stack().pop() globals.prune_slot_stack() - self.lazy_update() - - def lazy_update(self) -> None: - if self.child_count != len(self.children): - self.child_count = len(self.children) - self.parent.update() diff --git a/nicegui/updates.py b/nicegui/updates.py deleted file mode 100644 index ae6076845..000000000 --- a/nicegui/updates.py +++ /dev/null @@ -1,41 +0,0 @@ -import asyncio -from typing import TYPE_CHECKING, Dict, List, Set - -from . import globals - -if TYPE_CHECKING: - from .element import Element - -update_queue: Dict[int, List] = {} # element id -> [element, attributes] - - -def enqueue(element: 'Element', *attributes: str) -> None: - '''Schedules a UI update for this element. - - Attributes can be 'class', 'style', 'props', or 'text'. - ''' - if element.id not in update_queue: - update_queue[element.id] = [element, list(attributes)] - else: - queued_attributes: Set[str] = update_queue[element.id][1] - if queued_attributes and attributes: - queued_attributes.update(attributes) - else: - queued_attributes.clear() - - -async def loop() -> None: - '''Repeatedly updates all elements in the update queue.''' - while True: - elements: Dict[int, 'Element'] = {} - for id, value in sorted(update_queue.items()): # NOTE: sort by element ID to process parents before children - if id in elements: - continue - element: 'Element' = value[0] - for id in element.collect_descendant_ids(): - elements[id] = element.client.elements[id].to_dict() - if elements: - await globals.sio.emit('update', {'elements': elements}, room=element.client.id) - update_queue.clear() - else: - await asyncio.sleep(0.01) diff --git a/tests/test_dialog.py b/tests/test_dialog.py index 7aa6165a5..66067175f 100644 --- a/tests/test_dialog.py +++ b/tests/test_dialog.py @@ -16,6 +16,7 @@ def test_open_close_dialog(screen: Screen): screen.open('/') screen.should_not_contain('Content') screen.click('Open') + screen.wait(0.5) screen.should_contain('Content') screen.click('Close') screen.wait(0.5) diff --git a/tests/test_element.py b/tests/test_element.py index 7e32e93ad..d53d340d4 100644 --- a/tests/test_element.py +++ b/tests/test_element.py @@ -120,16 +120,19 @@ def test_remove_and_clear(screen: Screen): screen.should_contain('Label C') screen.click('Remove B') + screen.wait(0.5) screen.should_contain('Label A') screen.should_not_contain('Label B') screen.should_contain('Label C') screen.click('Remove 0') + screen.wait(0.5) screen.should_not_contain('Label A') screen.should_not_contain('Label B') screen.should_contain('Label C') screen.click('Clear') + screen.wait(0.5) screen.should_not_contain('Label A') screen.should_not_contain('Label B') screen.should_not_contain('Label C') diff --git a/tests/test_expansion.py b/tests/test_expansion.py index 45d51e1bb..28cf4dddb 100644 --- a/tests/test_expansion.py +++ b/tests/test_expansion.py @@ -13,6 +13,7 @@ def test_open_close_expansion(screen: Screen): screen.should_contain('Expansion') screen.should_not_contain('Content') screen.click('Open') + screen.wait(0.5) screen.should_contain('Content') screen.click('Close') screen.wait(0.5) diff --git a/tests/test_input.py b/tests/test_input.py index 91aa87d05..43ac25b75 100644 --- a/tests/test_input.py +++ b/tests/test_input.py @@ -29,6 +29,7 @@ def test_password(screen: Screen): assert element.get_attribute('value') == '123456' element.send_keys('789') + screen.wait(0.5) assert element.get_attribute('value') == '123456789' @@ -44,7 +45,9 @@ def test_toggle_button(screen: Screen): assert element.get_attribute('value') == '123456' screen.click('visibility_off') + screen.wait(0.5) assert element.get_attribute('type') == 'text' screen.click('visibility') + screen.wait(0.5) assert element.get_attribute('type') == 'password'