Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support theming solara #494

Merged
merged 9 commits into from
Feb 23, 2024
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'));
iisakkirotko marked this conversation as resolved.
Show resolved Hide resolved
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));}
iisakkirotko marked this conversation as resolved.
Show resolved Hide resolved
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
iisakkirotko marked this conversation as resolved.
Show resolved Hide resolved

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
Loading