Files
rio/rio/debug/dev_tools/theme_picker_page.py
T
2025-04-04 19:44:49 +02:00

538 lines
18 KiB
Python

import functools
import io
import typing as t
import rio
def colors_equal(color1: rio.Color, color2: rio.Color) -> bool:
"""
Returns `True` if the two colors are equal and `False` otherwise. Since
color values are stored as floats, this function applies a small fudge
factor to account for floating point differences.
"""
epsilon = 1e-6
l1, a1, b1, opacity1 = color1.oklaba
l2, a2, b2, opacity2 = color2.oklaba
return (
abs(l1 - l2) < epsilon
and abs(a1 - a2) < epsilon
and abs(b1 - b2) < epsilon
and abs(opacity1 - opacity2) < epsilon
)
def get_minimum_theme_kwargs(theme: rio.Theme) -> dict[str, t.Any]:
"""
Given a theme, returns a dictionary with the minimum set of keyword
arguments required to recreate it.
"""
# This is more complex than it might seem at first, because many colors are
# derived from other colors. For example, the neutral color is derived from
# the primary one.
result: dict[str, t.Any] = {}
# Light / dark mode can impact some colors. Make sure to get that value
# first.
if not theme.is_light_theme:
result["mode"] = "dark"
# Some colors don't depend on anything else
reference_theme = rio.Theme.from_colors(**result)
if not colors_equal(theme.primary_color, reference_theme.primary_color):
result["primary_color"] = theme.primary_color
if not colors_equal(theme.secondary_color, reference_theme.secondary_color):
result["secondary_color"] = theme.secondary_color
if not colors_equal(theme.disabled_color, reference_theme.disabled_color):
result["disabled_color"] = theme.disabled_color
if not colors_equal(theme.success_color, reference_theme.success_color):
result["success_color"] = theme.success_color
if not colors_equal(theme.warning_color, reference_theme.warning_color):
result["warning_color"] = theme.warning_color
if not colors_equal(theme.danger_color, reference_theme.danger_color):
result["danger_color"] = theme.danger_color
if not colors_equal(theme.hud_color, reference_theme.hud_color):
result["hud_color"] = theme.hud_color
# These depend on the previously defined ones
reference_theme = rio.Theme.from_colors(**result)
if not colors_equal(
theme.background_color, reference_theme.background_color
):
result["background_color"] = theme.background_color
if not colors_equal(theme.neutral_color, reference_theme.neutral_color):
result["neutral_color"] = theme.neutral_color
# Header fill
#
# This is nontrivial, because there are many kinds of fill, and some of them
# can be hard to serialize. Only support solid colors for now.
heading_color = theme.heading1_style.fill
reference_heading_color = reference_theme.heading1_style.fill
assert isinstance(heading_color, rio.Color), heading_color
assert isinstance(reference_heading_color, rio.Color), (
reference_heading_color
)
if isinstance(heading_color, rio.Color) and not colors_equal(
heading_color, reference_heading_color
):
result["heading_fill"] = heading_color
# Corner radii
if theme.corner_radius_large != reference_theme.corner_radius_large:
result["corner_radius_large"] = theme.corner_radius_large
if theme.corner_radius_medium != reference_theme.corner_radius_medium:
result["corner_radius_medium"] = theme.corner_radius_medium
if theme.corner_radius_small != reference_theme.corner_radius_small:
result["corner_radius_small"] = theme.corner_radius_small
return result
async def update_and_apply_theme(
session: rio.Session,
theme_replacements: dict[str, t.Any],
) -> None:
"""
Overrides the session's theme with the given one, and makes sure to update
all components so they use the new theme.
"""
# Determine the kwargs to use for the theme
theme_kwargs = get_minimum_theme_kwargs(session.theme)
theme_kwargs.update(theme_replacements)
# Build the new theme
new_theme = rio.Theme.from_colors(**theme_kwargs)
# Apply it
await session._apply_theme(new_theme)
# Refresh
await session._refresh()
def get_source_for_theme(theme: rio.Theme, *, create_theme_pair: bool) -> str:
"""
Given a theme, returns a string that can be used to recreate it.
"""
# Find all parameters which must be passed to create this theme
theme_parameters = get_minimum_theme_kwargs(theme)
# Build the source
theme_or_themes = "themes" if create_theme_pair else "theme"
result = io.StringIO()
result.write(f"# Create the {theme_or_themes}\n")
if create_theme_pair:
result.write("themes = rio.Theme.pair_from_colors(")
else:
result.write("theme = rio.Theme.from_colors(")
if theme_parameters:
result.write("\n")
for key, value in theme_parameters.items():
result.write(f" {key}=")
if isinstance(value, rio.Color):
hex_value = value.hexa
if len(hex_value) == 8 and hex_value.endswith("ff"):
hex_value = hex_value[:-2]
result.write(f"rio.Color.from_hex({hex_value!r})")
elif isinstance(value, bool):
result.write("True" if value else "False")
elif isinstance(value, (int, float)):
result.write(f"{value:.2f}")
elif isinstance(value, str):
result.write(repr(value))
else:
raise NotImplementedError(f"Unsupported type: {type(value)}")
result.write(",\n")
result.write(")\n")
result.write("\n")
result.write(
f"# And apply {'them' if create_theme_pair else 'it'} to your app\n"
)
result.write("app = rio.App(\n")
result.write(" ...\n")
result.write(f" theme={theme_or_themes},\n")
result.write(" ...\n")
result.write(")")
# Done
return result.getvalue()
class PalettePicker(rio.Component): #
shared_open_key: str
palette_nicename: str
palette_slug: str
pick_opacity: bool = False
round_top: bool = False
round_bottom: bool = False
@property
def palette(self) -> rio.Palette:
return getattr(self.session.theme, f"{self.palette_slug}_palette")
async def _on_color_change(self, event: rio.ColorChangeEvent) -> None:
await update_and_apply_theme(
self.session,
{
f"{self.palette_slug}_color": event.color,
},
)
def _on_press(self, event: rio.PointerEvent) -> None:
# Toggle the popup
if self.shared_open_key == self.palette_nicename:
self.shared_open_key = ""
else:
self.shared_open_key = self.palette_nicename
def build(self) -> rio.Component:
palette = self.palette
top_radius = (
self.session.theme.corner_radius_medium if self.round_top else 0
)
bottom_radius = (
self.session.theme.corner_radius_medium if self.round_bottom else 0
)
return rio.Popup(
anchor=rio.PointerEventListener(
# Switches the color of the Rectangle's ripple effect
rio.ThemeContextSwitcher(
content=rio.Rectangle(
content=rio.Column(
rio.Text(
self.palette_nicename,
style=rio.TextStyle(
font_size=self.session.theme.heading3_style.font_size,
fill=palette.foreground,
),
selectable=False,
justify="left",
),
rio.Text(
f"#{palette.background.hexa}",
style=rio.TextStyle(
font_size=1,
fill=palette.foreground.replace(
opacity=0.5
),
),
justify="left",
),
spacing=0.2,
margin_x=1,
margin_y=0.8,
),
fill=palette.background,
corner_radius=(
top_radius,
top_radius,
bottom_radius,
bottom_radius,
),
ripple=True,
cursor="pointer",
transition_time=0.15,
),
color=palette.background,
),
on_press=self._on_press,
),
content=rio.Column(
rio.Text(
f"{self.palette_nicename} Color",
justify="center",
style="heading3",
),
rio.ColorPicker(
color=palette.background,
pick_opacity=self.pick_opacity,
on_change=self._on_color_change,
min_width=18,
min_height=16,
),
spacing=0.8,
margin=1,
),
is_open=self.shared_open_key == self.palette_nicename,
color="hud",
position="left",
gap=1,
)
class ThemePickerPage(rio.Component):
shared_open_key: str = ""
theme_variants_are_initialized: bool = False
create_light_theme: bool = True
create_dark_theme: bool = False
@rio.event.on_populate
async def _on_populate(self) -> None:
if self.theme_variants_are_initialized:
return
self.theme_variants_are_initialized = True
current_theme_is_light = self.session.theme.is_light_theme
self.create_light_theme = current_theme_is_light
self.create_dark_theme = not current_theme_is_light
async def _on_radius_change(
self,
radius_name: str,
event: rio.SliderChangeEvent,
) -> None:
await update_and_apply_theme(
self.session,
{
radius_name: event.value,
},
)
async def _toggle_create_light_theme(
self, _: rio.SwitchChangeEvent
) -> None:
self.create_light_theme = not self.create_light_theme
if not self.create_light_theme and not self.create_dark_theme:
self.create_dark_theme = True
if self.session.theme.is_light_theme and self.create_light_theme:
theme_mode = "light"
else:
theme_mode = "dark"
await update_and_apply_theme(
self.session,
{
"mode": theme_mode,
},
)
async def _toggle_create_dark_theme(self, _: rio.SwitchChangeEvent) -> None:
self.create_dark_theme = not self.create_dark_theme
if not self.create_light_theme and not self.create_dark_theme:
self.create_light_theme = True
if self.session.theme.is_light_theme and self.create_light_theme:
theme_mode = "light"
else:
theme_mode = "dark"
await update_and_apply_theme(
self.session,
{
"mode": theme_mode,
},
)
def build(self) -> rio.Component:
# Prepare the radius sliders
slider_min = 0
slider_max = 4
radius_sliders = rio.Grid(
(
rio.Text(
"Small",
justify="left",
),
rio.Slider(
value=self.session.theme.corner_radius_small,
minimum=slider_min,
maximum=slider_max,
grow_x=True,
on_change=functools.partial(
self._on_radius_change,
"corner_radius_small",
),
),
),
(
rio.Text(
"Medium",
justify="left",
),
rio.Slider(
value=self.session.theme.corner_radius_medium,
minimum=slider_min,
maximum=slider_max,
grow_x=True,
on_change=functools.partial(
self._on_radius_change,
"corner_radius_medium",
),
),
),
(
rio.Text(
"Large",
justify="left",
),
rio.Slider(
value=self.session.theme.corner_radius_large,
minimum=slider_min,
maximum=slider_max,
grow_x=True,
on_change=functools.partial(
self._on_radius_change,
"corner_radius_large",
),
),
),
row_spacing=0.5,
)
# Combine everything
return rio.ScrollContainer(
rio.Column(
# Main Colors
PalettePicker(
shared_open_key=self.bind().shared_open_key,
palette_nicename="Primary",
palette_slug="primary",
round_top=True,
),
PalettePicker(
shared_open_key=self.bind().shared_open_key,
palette_nicename="Secondary",
palette_slug="secondary",
round_bottom=True,
),
# Neutral Colors
PalettePicker(
shared_open_key=self.bind().shared_open_key,
palette_nicename="Background",
palette_slug="background",
margin_top=1,
round_top=True,
),
PalettePicker(
shared_open_key=self.bind().shared_open_key,
palette_nicename="Neutral",
palette_slug="neutral",
),
PalettePicker(
shared_open_key=self.bind().shared_open_key,
palette_nicename="HUD",
palette_slug="hud",
pick_opacity=True,
),
PalettePicker(
shared_open_key=self.bind().shared_open_key,
palette_nicename="Disabled",
palette_slug="disabled",
round_bottom=True,
),
# Semantic Colors
PalettePicker(
shared_open_key=self.bind().shared_open_key,
palette_nicename="Success",
palette_slug="success",
margin_top=1,
round_top=True,
),
PalettePicker(
shared_open_key=self.bind().shared_open_key,
palette_nicename="Warning",
palette_slug="warning",
),
PalettePicker(
shared_open_key=self.bind().shared_open_key,
palette_nicename="Danger",
palette_slug="danger",
round_bottom=True,
),
# Corner radii
rio.Text(
"Corner Radii",
style="heading3",
margin_top=1,
margin_bottom=1,
justify="left",
),
radius_sliders,
# Theme Variants
rio.Text(
"Variants",
style="heading3",
margin_top=1,
margin_bottom=1,
justify="left",
),
rio.Grid(
[
rio.Text("Light Theme", justify="left"),
rio.Switch(
is_on=self.create_light_theme,
on_change=self._toggle_create_light_theme,
grow_x=True,
),
],
[
rio.Text("Dark Theme", justify="left"),
rio.Switch(
is_on=self.create_dark_theme,
on_change=self._toggle_create_dark_theme,
grow_x=True,
),
],
row_spacing=0.5,
column_spacing=0.5,
),
# Code Sample
rio.Text(
"Code",
style="heading3",
margin_top=1,
justify="left",
),
rio.Text(
"Use this code to recreate the current theme in your app:",
justify="left",
margin_top=0.5,
),
rio.CodeBlock(
get_source_for_theme(
self.session.theme,
create_theme_pair=self.create_light_theme
and self.create_dark_theme,
),
margin_top=0.5,
),
margin=1,
align_y=0,
),
scroll_x="never",
)