From 2336ab95ca5baaf2a8d94983b45c06099cf7b57e Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 22 Feb 2024 14:38:33 -0500 Subject: [PATCH 1/4] feat(input_dark_mode): Add dark mode switcher and update function --- .../api-examples/input_dark_mode/app-core.py | 67 ++++++++++++++ .../input_dark_mode/app-express.py | 60 ++++++++++++ shiny/express/ui/__init__.py | 4 + shiny/ui/__init__.py | 4 + shiny/ui/_input_dark_mode.py | 92 +++++++++++++++++++ 5 files changed, 227 insertions(+) create mode 100644 shiny/api-examples/input_dark_mode/app-core.py create mode 100644 shiny/api-examples/input_dark_mode/app-express.py create mode 100644 shiny/ui/_input_dark_mode.py diff --git a/shiny/api-examples/input_dark_mode/app-core.py b/shiny/api-examples/input_dark_mode/app-core.py new file mode 100644 index 000000000..f836d0169 --- /dev/null +++ b/shiny/api-examples/input_dark_mode/app-core.py @@ -0,0 +1,67 @@ +import matplotlib.pyplot as plt +import numpy as np + +from shiny import App, Inputs, Outputs, Session, reactive, render, ui + +app_ui = ui.page_navbar( + ui.nav_panel( + "One", + ui.layout_sidebar( + ui.sidebar( + ui.input_slider("n", "N", min=0, max=100, value=20), + ), + ui.output_plot("plot"), + ), + ), + ui.nav_panel( + "Two", + ui.layout_column_wrap( + ui.card("Second page content."), + ui.card( + ui.card_header("Server-side color mode setting"), + ui.input_action_button("make_light", "Switch to light mode"), + ui.input_action_button("make_dark", "Switch to dark mode"), + ), + ), + ), + ui.nav_spacer(), + ui.nav_control(ui.input_dark_mode(id="mode")), + title="Shiny Dark Mode", + fillable="One", +) + + +def server(input: Inputs, output: Outputs, session: Session): + @reactive.effect + @reactive.event(input.make_light) + async def _(): + await ui.update_dark_mode("light") + + @reactive.effect + @reactive.event(input.make_dark) + async def _(): + await ui.update_dark_mode("dark") + + @render.plot(alt="A histogram") + def plot() -> object: + np.random.seed(19680801) + x = 100 + 15 * np.random.randn(437) + + fig, ax = plt.subplots() + ax.hist(x, input.n(), density=True) + + # Theme the plot to match light/dark mode + fig.patch.set_facecolor("none") + ax.set_facecolor("none") + + color_fg = "black" if input.mode() == "light" else "silver" + ax.tick_params(axis="both", colors=color_fg) + ax.spines["bottom"].set_color(color_fg) + ax.spines["top"].set_color(color_fg) + ax.spines["left"].set_color(color_fg) + ax.spines["right"].set_color(color_fg) + + return fig + + +app = App(app_ui, server) diff --git a/shiny/api-examples/input_dark_mode/app-express.py b/shiny/api-examples/input_dark_mode/app-express.py new file mode 100644 index 000000000..a6e05380a --- /dev/null +++ b/shiny/api-examples/input_dark_mode/app-express.py @@ -0,0 +1,60 @@ +import matplotlib.pyplot as plt +import numpy as np + +from shiny import reactive +from shiny.express import input, render, ui + +ui.page_opts(title="Shiny Dark Mode", fillable="One") + +with ui.nav_panel("One"): + with ui.layout_sidebar(): + with ui.sidebar(): + ui.input_slider("n", "N", min=0, max=100, value=20) + + @render.plot(alt="A histogram") + def plot() -> object: + np.random.seed(19680801) + x = 100 + 15 * np.random.randn(437) + + fig, ax = plt.subplots() + ax.hist(x, input.n(), density=True) + + # Theme the plot to match light/dark mode + fig.patch.set_facecolor("none") + ax.set_facecolor("none") + + color_fg = "black" if input.mode() == "light" else "silver" + ax.tick_params(axis="both", colors=color_fg) + ax.spines["bottom"].set_color(color_fg) + ax.spines["top"].set_color(color_fg) + ax.spines["left"].set_color(color_fg) + ax.spines["right"].set_color(color_fg) + + return fig + + +with ui.nav_panel("Two"): + with ui.layout_column_wrap(): + with ui.card(): + "Second page content." + + with ui.card(): + ui.card_header("More content on the second page.") + ui.input_action_button("make_light", "Switch to light mode") + ui.input_action_button("make_dark", "Switch to dark mode") + +ui.nav_spacer() +with ui.nav_control(): + ui.input_dark_mode(id="mode") + + +@reactive.effect +@reactive.event(input.make_light) +async def _(): + await ui.update_dark_mode("light") + + +@reactive.effect +@reactive.event(input.make_dark) +async def _(): + await ui.update_dark_mode("dark") diff --git a/shiny/express/ui/__init__.py b/shiny/express/ui/__init__.py index 12f215930..14853187f 100644 --- a/shiny/express/ui/__init__.py +++ b/shiny/express/ui/__init__.py @@ -55,6 +55,7 @@ input_checkbox_group, input_switch, input_radio_buttons, + input_dark_mode, input_date, input_date_range, input_file, @@ -79,6 +80,7 @@ update_switch, update_checkbox_group, update_radio_buttons, + update_dark_mode, update_date, update_date_range, update_numeric, @@ -197,6 +199,7 @@ "input_checkbox_group", "input_switch", "input_radio_buttons", + "input_dark_mode", "input_date", "input_date_range", "input_file", @@ -221,6 +224,7 @@ "update_switch", "update_checkbox_group", "update_radio_buttons", + "update_dark_mode", "update_date", "update_date_range", "update_numeric", diff --git a/shiny/ui/__init__.py b/shiny/ui/__init__.py index 3142e3fc2..7d2a47487 100644 --- a/shiny/ui/__init__.py +++ b/shiny/ui/__init__.py @@ -71,6 +71,7 @@ input_radio_buttons, input_switch, ) +from ._input_dark_mode import input_dark_mode, update_dark_mode from ._input_date import input_date, input_date_range from ._input_file import input_file from ._input_numeric import input_numeric @@ -219,6 +220,9 @@ "input_checkbox_group", "input_switch", "input_radio_buttons", + # _input_dark_mode + "input_dark_mode", + "update_dark_mode", # _input_date "input_date", "input_date_range", diff --git a/shiny/ui/_input_dark_mode.py b/shiny/ui/_input_dark_mode.py new file mode 100644 index 000000000..0c8523b3e --- /dev/null +++ b/shiny/ui/_input_dark_mode.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +__all__ = ("input_dark_mode", "update_dark_mode") + +from typing import Literal, Optional + +from htmltools import Tag, TagAttrValue, css + +from .._docstring import add_example, no_example +from .._namespaces import resolve_id +from ..session import Session, require_active_session +from ._web_component import web_component + +BootstrapColorMode = Literal["light", "dark"] + + +@add_example() +def input_dark_mode( + *, + id: Optional[str] = None, + mode: Optional[BootstrapColorMode] = None, + **kwargs: TagAttrValue, +) -> Tag: + """ + Creates a dark mode switch input that toggles the app between dark and light modes. + + Parameters + ---------- + id + An optional ID for the dark mode switch. When included, the current color mode + is reported in the value of the input with this ID. + mode + The initial mode of the dark mode switch. By default or when set to `None`, the + user's system settings for the preferred color scheme will be used. Otherwise, + set to `"light"` or `"dark"` to force the initial mode. + **kwargs + Additional attributes to be added to the dark mode switch, such as `class_` or + `style`. + + Returns + ------- + : + A dark mode toggle switch UI element. + + References + ---------- + * + """ + + if mode is not None: + mode = validate_dark_mode_option(mode) + + if id is not None: + id = resolve_id(id) + + return web_component( + "bslib-input-dark-mode", + id=id, + attribute="data-bs-theme", + mode=mode, + style=css( + **{ + "--text-1": "var(--bs-emphasis-color)", + "--text-2": "var(--bs-tertiary-color)", + # TODO: Fix the vertical correction to work better with Bootstrap + "--vertical-correction": " ", + } + ), + **kwargs, + ) + + +def validate_dark_mode_option(mode: BootstrapColorMode) -> BootstrapColorMode: + if mode not in ("light", "dark"): + raise ValueError("`mode` must be either 'light' or 'dark'.") + return mode + + +@no_example() +async def update_dark_mode( + mode: BootstrapColorMode, *, session: Optional[Session] = None +) -> None: + session = require_active_session(session) + + mode = validate_dark_mode_option(mode) + + msg: dict[str, object] = { + "method": "toggle", + "value": mode, + } + + await session.send_custom_message("bslib.toggle-dark-mode", msg) From 7bea70d16cb4d9d4c302ad0e7438bd301e9cff38 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 22 Feb 2024 17:18:58 -0500 Subject: [PATCH 2/4] tests(input_dark_mode): Add tests for `ui.input_dark_mode()` --- .../api-examples/input_dark_mode/app-core.py | 1 + tests/playwright/controls.py | 34 +++++++++++++++ .../shiny/inputs/test_input_dark_mode.py | 43 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 tests/playwright/shiny/inputs/test_input_dark_mode.py diff --git a/shiny/api-examples/input_dark_mode/app-core.py b/shiny/api-examples/input_dark_mode/app-core.py index f836d0169..0e298bd3b 100644 --- a/shiny/api-examples/input_dark_mode/app-core.py +++ b/shiny/api-examples/input_dark_mode/app-core.py @@ -27,6 +27,7 @@ ui.nav_spacer(), ui.nav_control(ui.input_dark_mode(id="mode")), title="Shiny Dark Mode", + id="page", fillable="One", ) diff --git a/tests/playwright/controls.py b/tests/playwright/controls.py index 21d36f005..9a92db482 100644 --- a/tests/playwright/controls.py +++ b/tests/playwright/controls.py @@ -791,6 +791,40 @@ def __init__( ) +class InputDarkMode(_InputBase): + def __init__( + self, + page: Page, + id: Optional[str] | None, + ) -> None: + id_selector = "" if id is None else f"#{id}" + + super().__init__( + page, + id="" if id is None else id, + loc=f"bslib-input-dark-mode{id_selector}", + ) + + def click(self, *, timeout: Timeout = None): + self.loc.click(timeout=timeout) + return self + + def expect_mode(self, value: str, *, timeout: Timeout = None): + expect_attr(self.loc, "mode", value=value, timeout=timeout) + self.expect_page_mode(value, timeout=timeout) + return self + + def expect_page_mode(self, value: str, *, timeout: Timeout = None): + expect_attr( + self.page.locator("html"), "data-bs-theme", value=value, timeout=timeout + ) + return self + + def expect_wc_attribute(self, value: str, *, timeout: Timeout = None): + expect_attr(self.loc, "attribute", value=value, timeout=timeout) + return self + + class InputTaskButton( _WidthLocM, _InputActionBase, diff --git a/tests/playwright/shiny/inputs/test_input_dark_mode.py b/tests/playwright/shiny/inputs/test_input_dark_mode.py new file mode 100644 index 000000000..d5c793566 --- /dev/null +++ b/tests/playwright/shiny/inputs/test_input_dark_mode.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from conftest import ShinyAppProc, create_doc_example_core_fixture +from controls import InputActionButton, InputDarkMode, LayoutNavSetBar +from playwright.sync_api import Page + +app = create_doc_example_core_fixture("input_dark_mode") + + +def test_input_dark_mode_follows_system_setting(page: Page, app: ShinyAppProc) -> None: + page.emulate_media(color_scheme="light") + page.goto(app.url) + + mode_switch = InputDarkMode(page, "mode") + mode_switch.expect_mode("light") + mode_switch.expect_wc_attribute("data-bs-theme") + + page.emulate_media(color_scheme="dark") + mode_switch = InputDarkMode(page, "mode") + mode_switch.expect_mode("dark") + mode_switch.expect_wc_attribute("data-bs-theme") + + +def test_input_dark_mode_switch(page: Page, app: ShinyAppProc) -> None: + page.emulate_media(color_scheme="light") + page.goto(app.url) + + mode_switch = InputDarkMode(page, "mode") + navbar = LayoutNavSetBar(page, "page") + make_light = InputActionButton(page, "make_light") + make_dark = InputActionButton(page, "make_dark") + + # Test clicking the dark mode switch + mode_switch.expect_mode("light").click().expect_mode("dark") + + # Change to nav panel two and trigger server-side changes + navbar.set("Two") + + make_light.click() + mode_switch.expect_mode("light") + + make_dark.click() + mode_switch.expect_mode("dark") From b913e33a444451f6f3c2e1f1307524a5bdb1a023 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 22 Feb 2024 17:21:06 -0500 Subject: [PATCH 3/4] docs(input_dark_mode): Add to docs --- docs/_quartodoc-core.yml | 2 ++ docs/_quartodoc-express.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs/_quartodoc-core.yml b/docs/_quartodoc-core.yml index 73f0f2471..0d21414b4 100644 --- a/docs/_quartodoc-core.yml +++ b/docs/_quartodoc-core.yml @@ -44,6 +44,7 @@ quartodoc: - ui.input_select - ui.input_selectize - ui.input_slider + - ui.input_dark_mode - ui.input_date - ui.input_date_range - ui.input_checkbox @@ -122,6 +123,7 @@ quartodoc: dynamic: true - name: ui.update_slider dynamic: true + - ui.update_dark_mode - ui.update_date - name: ui.update_date_range dynamic: true diff --git a/docs/_quartodoc-express.yml b/docs/_quartodoc-express.yml index b54e76a1e..0a95d79f8 100644 --- a/docs/_quartodoc-express.yml +++ b/docs/_quartodoc-express.yml @@ -16,6 +16,7 @@ quartodoc: - express.ui.input_select - express.ui.input_selectize - express.ui.input_slider + - express.ui.input_dark_mode - express.ui.input_date - express.ui.input_date_range - express.ui.input_checkbox @@ -103,6 +104,7 @@ quartodoc: dynamic: true - name: express.ui.update_slider dynamic: true + - express.ui.update_dark_mode - ui.update_date - name: express.ui.update_date_range dynamic: true From 1b02dd64f7232e67f4402c172eee8c5244f30b03 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 23 Feb 2024 10:10:48 -0500 Subject: [PATCH 4/4] docs(changelog): Add item for input_dark_mode --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e324ced87..f084f2f6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### New features +* Added `ui.input_dark_mode()`, a toggle switch that allows users to switch between light and dark mode. By default, when `ui.input_dark_mode()` is added to an app, the app's color mode follows the users's system preferences, unless the app author sets the `mode` argument. When `ui.input_dark_mode(id=)` is set, the color mode is reported to the server, and server-side color mode updating is possible using `ui.update_dark_mode()`. (#1149) + * `ui.sidebar(open=)` now accepts a dictionary with keys `desktop` and `mobile`, allowing you to independently control the initial state of the sidebar at desktop and mobile screen sizes. (#1129) ### Other changes