diff --git a/CHANGELOG.md b/CHANGELOG.md index a2f9a046a..b0de8b291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### New features +* The `.output_*()` methods of the `ClientData` class (e.g., `session.clientdata.output_height()`) can now be called without an `id` inside a output renderer. (#1978) + ### Improvements * `selectize`, `remove_button`, and `options` parameters of `ui.input_select()` have been deprecated; use `ui.input_selectize()` instead. (Thanks, @ErdaradunGaztea!) (#1947) @@ -24,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.4.0] - 2025-04-08 -## New features +### New features * Added support for bookmarking Shiny applications. Bookmarking allows users to save the current state of an application and return to it later. This feature is available in both Shiny Core and Shiny Express. (#1870, #1915, #1919, #1920, #1922, #1934, #1938, #1945, #1955) * To enable bookmarking in Express mode, set `shiny.express.app_opts(bookmark_store=)` during the app's initial construction. diff --git a/shiny/render/_express.py b/shiny/render/_express.py index ab5139758..7c414929c 100644 --- a/shiny/render/_express.py +++ b/shiny/render/_express.py @@ -67,7 +67,7 @@ def __call__(self, fn: ValueFn[None]) -> Self: if fn is None: # pyright: ignore[reportUnnecessaryComparison] raise TypeError("@render.express requires a function when called") - async_fn = AsyncValueFn(fn) + async_fn = AsyncValueFn(fn, self) if async_fn.is_async(): raise TypeError( "@render.express does not support async functions. Use @render.ui instead." diff --git a/shiny/render/renderer/_renderer.py b/shiny/render/renderer/_renderer.py index b79009c91..76f65ce84 100644 --- a/shiny/render/renderer/_renderer.py +++ b/shiny/render/renderer/_renderer.py @@ -1,5 +1,6 @@ from __future__ import annotations +from contextlib import contextmanager from typing import ( TYPE_CHECKING, Any, @@ -162,7 +163,7 @@ def __call__(self, _fn: ValueFn[IT]) -> Self: raise TypeError("Value function must be callable") # Set value function with extra meta information - self.fn = AsyncValueFn(_fn) + self.fn = AsyncValueFn(_fn, self) # Copy over function name as it is consistent with how Session and Output # retrieve function names @@ -350,6 +351,7 @@ class AsyncValueFn(Generic[IT]): def __init__( self, fn: Callable[[], IT | None] | Callable[[], Awaitable[IT | None]], + renderer: Renderer[Any], ): if isinstance(fn, AsyncValueFn): raise TypeError( @@ -358,12 +360,14 @@ def __init__( self._is_async = is_async_callable(fn) self._fn = wrap_async(fn) self._orig_fn = fn + self._renderer = renderer async def __call__(self) -> IT | None: """ Call the asynchronous function. """ - return await self._fn() + with self._current_renderer(): + return await self._fn() def is_async(self) -> bool: """ @@ -404,3 +408,19 @@ def get_sync_fn(self) -> Callable[[], IT | None]: ) sync_fn = cast(Callable[[], IT], self._orig_fn) return sync_fn + + @contextmanager + def _current_renderer(self): + from ...session import get_current_session + + session = get_current_session() + if session is None: + yield + return + + old_renderer = session._current_renderer + try: + session._current_renderer = self._renderer + yield + finally: + session._current_renderer = old_renderer diff --git a/shiny/session/_session.py b/shiny/session/_session.py index dd78a6819..5aa551424 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -188,6 +188,9 @@ class Session(ABC): user: str | None groups: list[str] | None + # Internal state for current_output_id() + _current_renderer: Renderer[Any] | None = None + # TODO: not sure these should be directly exposed _outbound_message_queues: OutBoundMessageQueues _downloads: dict[str, DownloadInfo] @@ -378,6 +381,13 @@ def on_flushed( A function that can be used to cancel the registration. """ + def _current_output_id(self) -> str | None: + "Returns the id of the currently executing output renderer (if any)." + if self._current_renderer is None: + return None + else: + return self._current_renderer.output_id + @abstractmethod async def _unhandled_error(self, e: Exception) -> None: ... @@ -1556,7 +1566,7 @@ def pixelratio(self) -> float: """ return cast(int, self._read_input("pixelratio")) - def output_height(self, id: str) -> float | None: + def output_height(self, id: Optional[str] = None) -> float | None: """ Reactively read the height of an output. @@ -1573,7 +1583,7 @@ def output_height(self, id: str) -> float | None: """ return cast(float, self._read_output(id, "height")) - def output_width(self, id: str) -> float | None: + def output_width(self, id: Optional[str] = None) -> float | None: """ Reactively read the width of an output. @@ -1590,7 +1600,7 @@ def output_width(self, id: str) -> float | None: """ return cast(float, self._read_output(id, "width")) - def output_hidden(self, id: str) -> bool | None: + def output_hidden(self, id: Optional[str] = None) -> bool | None: """ Reactively read whether an output is hidden. @@ -1606,7 +1616,7 @@ def output_hidden(self, id: str) -> bool | None: """ return cast(bool, self._read_output(id, "hidden")) - def output_bg_color(self, id: str) -> str | None: + def output_bg_color(self, id: Optional[str] = None) -> str | None: """ Reactively read the background color of an output. @@ -1623,7 +1633,7 @@ def output_bg_color(self, id: str) -> str | None: """ return cast(str, self._read_output(id, "bg")) - def output_fg_color(self, id: str) -> str | None: + def output_fg_color(self, id: Optional[str] = None) -> str | None: """ Reactively read the foreground color of an output. @@ -1640,7 +1650,7 @@ def output_fg_color(self, id: str) -> str | None: """ return cast(str, self._read_output(id, "fg")) - def output_accent_color(self, id: str) -> str | None: + def output_accent_color(self, id: Optional[str] = None) -> str | None: """ Reactively read the accent color of an output. @@ -1657,7 +1667,7 @@ def output_accent_color(self, id: str) -> str | None: """ return cast(str, self._read_output(id, "accent")) - def output_font(self, id: str) -> str | None: + def output_font(self, id: Optional[str] = None) -> str | None: """ Reactively read the font(s) of an output. @@ -1685,9 +1695,18 @@ def _read_input(self, key: str) -> str: return self._session.input[id]() - def _read_output(self, id: str, key: str) -> str | None: + def _read_output(self, id: str | None, key: str) -> str | None: self._check_current_context(f"output_{key}") + if id is None: + id = self._session._current_output_id() + + if id is None: + raise ValueError( + "session.clientdata.output_*() requires an id when not called within " + "an output renderer." + ) + input_id = ResolvedId(f".clientdata_output_{id}_{key}") if input_id in self._session.input: return self._session.input[input_id]() diff --git a/tests/playwright/shiny/session/current_output_info/app.py b/tests/playwright/shiny/session/current_output_info/app.py new file mode 100644 index 000000000..58c7b338b --- /dev/null +++ b/tests/playwright/shiny/session/current_output_info/app.py @@ -0,0 +1,19 @@ +from shiny import App, Inputs, Outputs, Session, render, ui + +app_ui = ui.page_fluid( + ui.input_dark_mode(mode="light", id="dark_mode"), + ui.output_text("text1"), + ui.output_text("text2"), + ui.output_text("info").add_class("shiny-report-theme"), +) + + +def server(input: Inputs, output: Outputs, session: Session): + + @render.text + def info(): + bg_color = session.clientdata.output_bg_color() + return f"BG color: {bg_color}" + + +app = App(app_ui, server) diff --git a/tests/playwright/shiny/session/current_output_info/test_current_output_info.py b/tests/playwright/shiny/session/current_output_info/test_current_output_info.py new file mode 100644 index 000000000..6d015b510 --- /dev/null +++ b/tests/playwright/shiny/session/current_output_info/test_current_output_info.py @@ -0,0 +1,22 @@ +from playwright.sync_api import Page + +from shiny.playwright import controller +from shiny.run import ShinyAppProc + + +def test_current_output_info(page: Page, local_app: ShinyAppProc) -> None: + + page.goto(local_app.url) + + # Check that we can get background color from clientdata + info = controller.OutputText(page, "info") + info.expect_value("BG color: rgb(255, 255, 255)") + + # Click the dark mode button to change the background color + dark_mode = controller.InputDarkMode(page, "dark_mode") + dark_mode.expect_mode("light") + dark_mode.click() + dark_mode.expect_mode("dark") + + # Check that the background color has changed + info.expect_value("BG color: rgb(29, 31, 33)")