diff --git a/nicegui/functions/refreshable.py b/nicegui/functions/refreshable.py index 495f5ea57..474f2e4e4 100644 --- a/nicegui/functions/refreshable.py +++ b/nicegui/functions/refreshable.py @@ -1,5 +1,7 @@ -from dataclasses import dataclass -from typing import Any, Awaitable, Callable, Dict, List, Tuple, Union +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable, ClassVar, Dict, List, Optional, Tuple, Union from typing_extensions import Self @@ -11,13 +13,20 @@ @dataclass(**KWONLY_SLOTS) class RefreshableTarget: - container: Element + container: RefreshableContainer + refreshable: refreshable instance: Any args: Tuple[Any, ...] kwargs: Dict[str, Any] + current_target: ClassVar[Optional[RefreshableTarget]] = None + locals: List[Any] = field(default_factory=list) + next_index: int = 0 + def run(self, func: Callable[..., Any]) -> Union[None, Awaitable]: """Run the function and return the result.""" + RefreshableTarget.current_target = self + self.next_index = 0 # pylint: disable=no-else-return if is_coroutine_function(func): async def wait_for_result() -> None: @@ -67,7 +76,8 @@ def refresh(*args: Any, _instance=self.instance, **kwargs: Any) -> None: def __call__(self, *args: Any, **kwargs: Any) -> Union[None, Awaitable]: self.prune() - target = RefreshableTarget(container=RefreshableContainer(), instance=self.instance, args=args, kwargs=kwargs) + target = RefreshableTarget(container=RefreshableContainer(), refreshable=self, instance=self.instance, + args=args, kwargs=kwargs) self.targets.append(target) return target.run(self.func) @@ -106,3 +116,21 @@ def prune(self) -> None: for target in self.targets if target.container.client.id in globals.clients and target.container.id in target.container.client.elements ] + + +def state(value: Any) -> Tuple[Any, Callable[[Any], None]]: + target = RefreshableTarget.current_target + assert target is not None + + if target.next_index >= len(target.locals): + target.locals.append(value) + else: + value = target.locals[target.next_index] + + def set_value(new_value: Any, index=target.next_index) -> None: + target.locals[index] = new_value + target.refreshable.refresh() + + target.next_index += 1 + + return value, set_value diff --git a/nicegui/ui.py b/nicegui/ui.py index 53ba2a172..837ae61e0 100644 --- a/nicegui/ui.py +++ b/nicegui/ui.py @@ -85,6 +85,7 @@ 'notify', 'open', 'refreshable', + 'state', 'update', 'page', 'drawer', @@ -181,7 +182,7 @@ from .functions.javascript import run_javascript from .functions.notify import notify from .functions.open import open # pylint: disable=redefined-builtin -from .functions.refreshable import refreshable +from .functions.refreshable import refreshable, state from .functions.update import update from .page import page from .page_layout import Drawer as drawer diff --git a/tests/test_refreshable.py b/tests/test_refreshable.py index cea618b8a..dc24cd997 100644 --- a/tests/test_refreshable.py +++ b/tests/test_refreshable.py @@ -180,3 +180,26 @@ def ui(self): screen.should_contain('Refreshing A') screen.click('B') screen.should_contain('Refreshing B') + + +def test_refreshable_with_state(screen: Screen): + @ui.refreshable + def counter(title: str): + count, set_count = ui.state(0) + ui.label(f'{title}: {count}') + ui.button(f'Increment {title}', on_click=lambda: set_count(count + 1)) + + counter('A') + counter('B') + + screen.open('/') + screen.should_contain('A: 0') + screen.should_contain('B: 0') + + screen.click('Increment A') + screen.should_contain('A: 1') + screen.should_contain('B: 0') + + screen.click('Increment B') + screen.should_contain('A: 1') + screen.should_contain('B: 1') diff --git a/website/more_documentation/refreshable_documentation.py b/website/more_documentation/refreshable_documentation.py index a2f373350..54574525b 100644 --- a/website/more_documentation/refreshable_documentation.py +++ b/website/more_documentation/refreshable_documentation.py @@ -67,3 +67,23 @@ def show_info(): ui.label(rule).classes('text-xs text-red') show_info() + + @text_demo('Refreshable UI with reactive state', ''' + You can create reactive state variables with the `ui.state` function, like `count` and `color` in this demo. + They can be used like normal variables for creating UI elements like the `ui.label`. + Their corresponding setter functions can be used to set new values, which will automatically refresh the UI. + ''') + def reactive_state(): + @ui.refreshable + def counter(name: str): + with ui.card(): + count, set_count = ui.state(0) + color, set_color = ui.state('black') + ui.label(f'{name} = {count}').classes(f'text-{color}') + ui.button(f'{name} += 1', on_click=lambda: set_count(count + 1)) + ui.select(['black', 'red', 'green', 'blue'], + value=color, on_change=lambda e: set_color(e.value)) + + with ui.row(): + counter('A') + counter('B')