Skip to content

Commit

Permalink
feat: support dark, light and auto theming (#494)
Browse files Browse the repository at this point in the history
Requires ipyvuetify 1.9.0 for proper detection of dark_effective

Co-authored-by: Maarten Breddels <[email protected]>
  • Loading branch information
iisakkirotko and maartenbreddels authored Feb 23, 2024
1 parent 3a6caf3 commit 238e09a
Show file tree
Hide file tree
Showing 13 changed files with 282 additions and 75 deletions.
5 changes: 3 additions & 2 deletions packages/solara-widget-manager/src/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -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;
}
Expand Down
21 changes: 15 additions & 6 deletions solara/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions solara/lab/components/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
62 changes: 62 additions & 0 deletions solara/lab/components/theming.py
Original file line number Diff line number Diff line change
@@ -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",
)
73 changes: 73 additions & 0 deletions solara/lab/components/theming.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<template>
<v-btn
icon
@click="countClicks"
>
<v-icon
:color="theme_dark ? 'primary' : null">
{{ this.clicks === 1 ? this.on_icon : this.clicks === 2 ? this.off_icon : this.auto_icon }}
</v-icon>
</v-btn>
</template>
<script>
module.exports = {
mounted() {
if (window.solara) {
if (localStorage.getItem(':solara:theme.variant')) {
this.theme_dark = this.initTheme();
}
}
if ( this.theme_dark === false ) {
this.clicks = 2;
} else if ( this.theme_dark === null ) {
this.clicks = 3;
}
this.lim = this.enable_auto ? 3 : 2;
},
methods: {
countClicks() {
if ( this.clicks < this.lim ) {
this.clicks++;
} else {
this.clicks = 1;
}
this.theme_dark = this.get_theme_bool( this.clicks );
},
get_theme_bool( clicks ) {
if ( clicks === 3 ) {
return null;
} else if ( clicks === 2 ) {
return false;
} else {
return true;
}
},
stringifyTheme() {
return this.theme_dark === true ? 'dark' : this.theme_dark === false ? 'light' : 'auto';
},
initTheme() {
storedTheme = JSON.parse(localStorage.getItem(':solara:theme.variant'));
return storedTheme === 'dark' ? true : storedTheme === 'light' ? false : null;
},
setTheme() {
if ( window.solara && this.theme_dark === null ) {
this.$vuetify.theme.dark = this.prefersDarkScheme();
return;
}
this.$vuetify.theme.dark = this.theme_dark;
},
prefersDarkScheme() {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
},
},
watch: {
clicks (val) {
if ( window.solara ) {theme.variant = this.stringifyTheme();}
this.setTheme();
if ( window.solara ) {localStorage.setItem(':solara:theme.variant', JSON.stringify(theme.variant));}
this.sync_themes(this.theme_dark);
},
}
}
</script>
13 changes: 11 additions & 2 deletions solara/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion solara/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import ipywidgets
import jinja2
import requests

import solara
import solara.routing
import solara.settings
Expand Down Expand Up @@ -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"<style>/*\npath={path}\n*/\n{content_utf8}</style>"
else:
Expand Down
1 change: 0 additions & 1 deletion solara/server/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion solara/server/static/main-vuetify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading

0 comments on commit 238e09a

Please sign in to comment.