From 238e09ade0a258eb8eca3d107df78ebed7e47c76 Mon Sep 17 00:00:00 2001 From: Iisakki Rotko Date: Fri, 23 Feb 2024 17:06:52 +0100 Subject: [PATCH] feat: support dark, light and auto theming (#494) Requires ipyvuetify 1.9.0 for proper detection of dark_effective Co-authored-by: Maarten Breddels --- packages/solara-widget-manager/src/manager.ts | 5 +- solara/__main__.py | 21 ++++-- solara/lab/components/__init__.py | 1 + solara/lab/components/theming.py | 62 +++++++++++++++ solara/lab/components/theming.vue | 73 ++++++++++++++++++ solara/server/app.py | 13 +++- solara/server/server.py | 8 +- solara/server/settings.py | 1 - solara/server/static/main-vuetify.js | 2 +- solara/server/templates/solara.html.j2 | 75 +++++++------------ solara/website/pages/api/__init__.py | 1 + solara/website/pages/api/theming.py | 69 +++++++++++++++++ tests/unit/patch_test.py | 26 ++++--- 13 files changed, 282 insertions(+), 75 deletions(-) create mode 100644 solara/lab/components/theming.py create mode 100644 solara/lab/components/theming.vue create mode 100644 solara/website/pages/api/theming.py diff --git a/packages/solara-widget-manager/src/manager.ts b/packages/solara-widget-manager/src/manager.ts index af76c28b3..bdebbcb4e 100644 --- a/packages/solara-widget-manager/src/manager.ts +++ b/packages/solara-widget-manager/src/manager.ts @@ -149,7 +149,8 @@ export class WidgetManager extends JupyterLabManager { await this._loadFromKernel(); } - async run(appName: string, path: string) { + async run(appName: string, args: any) { + let { path } = args; // used for routing // should be similar to what we do in navigator.vue if (typeof path === 'undefined') { @@ -173,7 +174,7 @@ export class WidgetManager extends JupyterLabManager { } }; }); - this.controlComm.send({ method: 'run', path, appName: appName || null }); + this.controlComm.send({ method: 'run', args: { ...args, appName: appName || null } }); const widget_id = await widget_id_promise; return widget_id; } diff --git a/solara/__main__.py b/solara/__main__.py index cea8074f3..172f6d47e 100644 --- a/solara/__main__.py +++ b/solara/__main__.py @@ -11,12 +11,13 @@ import rich import rich_click as click -import solara import uvicorn from rich import print as rprint -from solara.server import settings from uvicorn.main import LEVEL_CHOICES, LOOP_CHOICES +import solara +from solara.server import settings + from .server import telemetry try: @@ -216,11 +217,17 @@ def cli(): default=settings.theme.variant.name, help=f"Use light or dark variant, or auto detect (auto). [default: {settings.theme.variant.name}", ) +@click.option( + "--dark", + type=bool, + default=settings.theme.variant == settings.ThemeVariant.dark, + help="Use dark theme. Shorthand for --theme-variant=dark", +) @click.option( "--theme-variant-user-selectable/--no-theme-variant-user-selectable", type=bool, - default=settings.theme.variant_user_selectable, - help=f"Can the user select the theme variant from the UI. [default: {settings.theme.variant_user_selectable}", + hidden=True, + help="Deprecated.", ) @click.option("--pdb/--no-pdb", "use_pdb", default=False, help="Enter debugger on error") @click.argument("app") @@ -273,6 +280,7 @@ def run( use_pdb: bool, theme_loader: str, theme_variant: settings.ThemeVariant, + dark: bool, theme_variant_user_selectable: bool, ssg: bool, search: bool, @@ -375,12 +383,13 @@ def open_browser(): kwargs["loop"] = loop settings.main.use_pdb = use_pdb settings.theme.loader = theme_loader + if dark: + theme_variant = settings.ThemeVariant.dark settings.theme.variant = theme_variant - settings.theme.variant_user_selectable = theme_variant_user_selectable settings.main.tracer = tracer settings.main.timing = timing items = ( - "theme_variant_user_selectable theme_variant theme_loader use_pdb server open_browser open url failed dev tracer" + "theme_variant_user_selectable dark theme_variant theme_loader use_pdb server open_browser open url failed dev tracer" " timing ssg search check_version production".split() ) for item in items: diff --git a/solara/lab/components/__init__.py b/solara/lab/components/__init__.py index 3061ca0db..f00c9df75 100644 --- a/solara/lab/components/__init__.py +++ b/solara/lab/components/__init__.py @@ -3,3 +3,4 @@ from .input_date import InputDate, InputDateRange # noqa: F401 from .menu import ClickMenu, ContextMenu, Menu # noqa: F401 F403 from .tabs import Tab, Tabs # noqa: F401 +from .theming import ThemeToggle, theme # noqa: F401 diff --git a/solara/lab/components/theming.py b/solara/lab/components/theming.py new file mode 100644 index 000000000..4ee79f116 --- /dev/null +++ b/solara/lab/components/theming.py @@ -0,0 +1,62 @@ +from typing import Callable, cast + +import ipyvuetify.Themes +from ipyvuetify.Themes import Theme + +import solara +from solara.components.component_vue import component_vue +from solara.tasks import Proxy + +theme = Proxy(Theme) +ipyvuetify.Themes.theme = cast(ipyvuetify.Themes.Theme, theme) + + +@component_vue("theming.vue") +def _ThemeToggle( + theme_dark: str, + event_sync_themes: Callable[[str], None], + enable_auto: bool, + on_icon: str, + off_icon: str, + auto_icon: str, + clicks: int = 1, +): + pass + + +@solara.component +def ThemeToggle( + on_icon: str = "mdi-weather-night", + off_icon: str = "mdi-weather-sunny", + auto_icon: str = "mdi-brightness-auto", + enable_auto: bool = True, +): + """ + Insert a toggle switch for user to switch between light and dark themes. + + ```solara + import solara.lab + + @solara.component + def Page(): + solara.lab.ThemeToggle() + ``` + + ## Arguments + - `on_icon`: The icon to display when the dark theme is enabled. + - `off_icon`: The icon to display when the dark theme is disabled. + - `auto_icon`: The icon to display when the theme is set to auto. Only visible if `enable_auto` is `True`. + - `enable_auto`: Whether to enable the auto detection of dark mode. + """ + + def sync_themes(selected_theme: str): + theme.dark = selected_theme + + return _ThemeToggle( + theme_dark=theme.dark, + event_sync_themes=sync_themes, + enable_auto=enable_auto, + on_icon="mdi-weather-night", + off_icon="mdi-weather-sunny", + auto_icon="mdi-brightness-auto", + ) diff --git a/solara/lab/components/theming.vue b/solara/lab/components/theming.vue new file mode 100644 index 000000000..c33f79395 --- /dev/null +++ b/solara/lab/components/theming.vue @@ -0,0 +1,73 @@ + + diff --git a/solara/server/app.py b/solara/server/app.py index 7ce3447f0..4b2138798 100644 --- a/solara/server/app.py +++ b/solara/server/app.py @@ -363,12 +363,21 @@ def on_msg(msg): data = msg["content"]["data"] method = data["method"] if method == "run": - path = data.get("path", "") - app_name = data.get("appName") or "__default__" + args = data["args"] + path = args.get("path", "") + app_name = args.get("appName") or "__default__" app = apps[app_name] context = kernel_context.get_current_context() + dark = args.get("dark", False) import ipyvuetify + from solara.lab import theme + + # While this usually gets set from the frontend, in solara (server) we want to know this directly at the first + # render. Also, using the same trait allows us to write code which works on all widgets platforms, instead + # or using something different when running under solara server + theme.dark_effective = dark + container = ipyvuetify.Html(tag="div") context.container = container load_app_widget(None, app, path) diff --git a/solara/server/server.py b/solara/server/server.py index 737b737c6..64da8a532 100644 --- a/solara/server/server.py +++ b/solara/server/server.py @@ -12,6 +12,7 @@ import ipywidgets import jinja2 import requests + import solara import solara.routing import solara.settings @@ -293,7 +294,12 @@ def include_css(path: str) -> Markup: url = f"{root_path}{path}?v={hash}" # when < 10k we embed, also when we use a url, it can be relative, which can break the url embed = len(content) < 1024 * 10 and b"url" not in content - if embed: + # Always embed the jupyterlab theme CSS to make theme switching possible (see solara.html.j2 template) + # TODO: Prevent browser from caching the theme CSS files + if path.endswith("theme-dark.css") or path.endswith("theme-light.css"): + content_utf8 = content.decode("utf-8") + code = content_utf8 + elif embed: content_utf8 = content.decode("utf-8") code = f"" else: diff --git a/solara/server/settings.py b/solara/server/settings.py index e1c7cf634..e5f68558a 100644 --- a/solara/server/settings.py +++ b/solara/server/settings.py @@ -32,7 +32,6 @@ class ThemeVariant(str, Enum): class ThemeSettings(BaseSettings): variant: ThemeVariant = ThemeVariant.light - variant_user_selectable: bool = True loader: str = "solara" class Config: diff --git a/solara/server/static/main-vuetify.js b/solara/server/static/main-vuetify.js index 08b59bfeb..d1f9c8c6d 100644 --- a/solara/server/static/main-vuetify.js +++ b/solara/server/static/main-vuetify.js @@ -230,7 +230,7 @@ async function solaraInit(mountId, appName) { if (kernelId && widgetModelId) { await widgetManager.fetchAll(); } else { - widgetModelId = await widgetManager.run(appName, path); + widgetModelId = await widgetManager.run(appName, {path, dark: inDarkMode()}); } await solaraMount(widgetManager, mountId || 'content', widgetModelId); skipReconnectedCheck = false; diff --git a/solara/server/templates/solara.html.j2 b/solara/server/templates/solara.html.j2 index 6b6d70481..87a0fb460 100644 --- a/solara/server/templates/solara.html.j2 +++ b/solara/server/templates/solara.html.j2 @@ -27,11 +27,9 @@ {{ resources.include_css("/static/highlight.css") }} {{ resources.include_css("/static/highlight-dark.css") }} {{ resources.include_css("/static/assets/style.css") }} - {% if theme.variant == "light" %} - {{ resources.include_css("/static/assets/theme-light.css") }} - {% elif theme.variant == "dark" %} - {{ resources.include_css("/static/assets/theme-dark.css") }} - {% endif %} + {{ resources.include_css("/static/assets/custom.css") }}