makepad/apps/storybook/src/theme.rs
Admin 922a61736d apps: Code, Translator, Weather, Amp and the rest
Makepad Code (editor layout, selection, sidebar, early yield), the EN/ZH
Translator with voice, Weather with a generated offline forecast, Amp
playing its offline library, and fixes
across the other apps and examples.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:26:21 +02:00

200 lines
7.6 KiB
Rust

//! Which theme the catalogue runs under, and switching it while it runs.
//!
//! The library binds `theme` into the widget prelude between `theme_mod` and
//! `widgets_mod`, so the choice has to be made between those two calls. The
//! app's `script_mod` does exactly that, reading a process-wide choice. A
//! switch stores the choice and asks the platform for a live edit: the whole
//! `script_mod` runs again and every widget is re-applied from its template
//! under the new theme, in about ten milliseconds. Typed text and running
//! animations do not survive that, which is the price of a real switch.
use crate::makepad_widgets::desktop_style::{self, SheetEntry, StyleSheet};
use crate::makepad_widgets::*;
use std::sync::atomic::{AtomicU8, Ordering};
/// The three themes the library is written in. Everything after them in the
/// list is a style sheet laid over one of these.
const BASE: &[&str] = &["dark", "light", "skeleton"];
static CHOICE: AtomicU8 = AtomicU8::new(0);
/// One thing the picker offers: a base theme, or a sheet of the catalogue.
#[derive(Clone, Copy, Debug)]
pub enum Choice {
Base(usize),
Sheet(&'static SheetEntry),
}
impl PartialEq for Choice {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Choice::Base(a), Choice::Base(b)) => a == b,
(Choice::Sheet(a), Choice::Sheet(b)) => std::ptr::eq(*a, *b),
_ => false,
}
}
}
/// Everything the picker offers, in the order it offers it: the base themes,
/// then every sheet of the catalogue, in its order (each dark variant after
/// its light one).
///
/// Read off the library's own list rather than written out here, so a sheet
/// added there turns up in the catalogue without anybody remembering to add
/// it, and so the catalogue names none of them itself.
/// The sheets whose design frames the catalogue's panes as windows: the
/// segment display's windows set into its black plate, the handheld's
/// screens in its shell, the frosted sheet's panes of glass over its ground,
/// the liquid sheet's panes of light glass over its blobs. Under every other sheet the panes are clear and the window's own ground
/// shows through them, as it always did; a sheet whose ground is its design
/// (the futuristic metal hull, the cyberpunk board) would lose it under a
/// pane of panel.
const FRAMING_SHEETS: &[&str] = &["segment-display", "handheld", "frosted-glass", "liquid-glass"];
/// Whether the catalogue's panes are drawn as the sheet's panels.
pub fn frames_panes() -> bool {
match choices().get(choice()) {
Some(Choice::Sheet(entry)) => FRAMING_SHEETS.contains(&entry.id),
_ => false,
}
}
pub fn choices() -> Vec<Choice> {
let mut out: Vec<Choice> = (0..BASE.len()).map(Choice::Base).collect();
out.extend(desktop_style::catalogue().into_iter().map(Choice::Sheet));
out
}
impl Choice {
/// What a settings file and the remote surface call it.
pub fn name(self) -> String {
match self {
Choice::Base(i) => BASE[i].to_string(),
Choice::Sheet(entry) => entry.id.to_string(),
}
}
/// What the picker shows.
pub fn label(self) -> String {
match self {
Choice::Base(i) => {
let mut c = BASE[i].chars();
c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default()
}
Choice::Sheet(entry) => entry.label.to_string(),
}
}
}
pub fn names() -> Vec<String> {
choices().into_iter().map(Choice::name).collect()
}
pub fn labels() -> Vec<String> {
choices().into_iter().map(Choice::label).collect()
}
pub fn choice() -> usize {
CHOICE.load(Ordering::Relaxed) as usize
}
pub fn index_of(name: &str) -> Option<usize> {
names().iter().position(|n| n == name)
}
/// Set the choice without applying it, for startup.
pub fn set_choice(index: usize) {
CHOICE.store(index.min(choices().len() - 1) as u8, Ordering::Relaxed);
}
/// The library's `script_mod` split around the theme choice.
/// Write a pick into the library, which is where it is read back from.
///
/// The base theme and the installed sheet both live in the library now, so
/// this list and the developer panel's own picker write to one place and
/// cannot disagree. This used to be done on every `script_mod` run, which
/// made whichever list ran last the winner: the panel's pick was undone a
/// tick after it was made.
pub fn apply_choice(vm: &mut ScriptVm, picked: Choice) {
// A sheet is laid over the base its own token half starts from
// (`mod.theme = mod.themes.light`): what the sheet does not restyle --
// the catalogue's own panels, which keep the stock widgets on purpose --
// then reads on the sheet's ground. Every sheet went over the dark base,
// so under a light sheet those panels drew near-white text on a light
// ground.
let sheet = match picked {
Choice::Sheet(entry) => Some(StyleSheet::load(entry)),
Choice::Base(_) => None,
};
let base = match (picked, &sheet) {
(Choice::Base(1), _) => BaseTheme::Light,
(Choice::Base(2), _) => BaseTheme::Skeleton,
(_, Some(sheet)) if sheet_base_is_light(&sheet.theme) => BaseTheme::Light,
_ => BaseTheme::Dark,
};
set_base_theme(vm.cx_mut(), base);
// A sheet goes on BEFORE `widgets_mod`, which reads it for its token
// half as its first act; and comes off again for a base theme, or the
// last sheet tried would stay under every theme picked after it.
match sheet {
Some(sheet) => desktop_style::install(vm, sheet),
None => desktop_style::uninstall(vm),
}
}
/// Whether a sheet's token half starts from the light base: its first
/// `mod.theme = mod.themes.<base>` assignment names `light`.
fn sheet_base_is_light(theme: &str) -> bool {
theme
.lines()
.map(str::trim)
.find_map(|line| line.strip_prefix("mod.theme = mod.themes."))
.is_some_and(|base| base.trim_end().starts_with("light"))
}
pub fn widgets_script_mod(vm: &mut ScriptVm) {
crate::makepad_widgets::theme_mod(vm);
crate::makepad_widgets::widgets_mod(vm);
// The widget half of a sheet. The library's own `script_mod` does this
// and this function stands in for it, so without the call a sheet's
// tokens arrived and its buttons, fields and panels stayed as they were:
// half a style, which is the trap `desktop_style` warns of by name.
desktop_style::apply_widgets(vm);
}
/// Switch the running app to another theme.
pub fn select(cx: &mut Cx, index: usize) {
let names = names();
if index >= names.len() || index == choice() {
return;
}
set_choice(index);
crate::settings::set(crate::settings::THEME, &names[index]);
log!("storybook: theme {}", names[index]);
let picked = choices()[index];
cx.with_vm(|vm| apply_choice(vm, picked));
// A style reload re-runs `script_mod` and then applies with
// `ScriptReapply` rather than `Reload`, so typed text and running
// animations survive the switch. `request_script_reapply` alone would
// not do: it does not re-run `script_mod`, and both halves of a switch
// are emitted there.
cx.request_style_reload();
}
#[cfg(test)]
mod tests {
use super::sheet_base_is_light;
#[test]
fn a_sheet_starts_from_the_base_its_token_half_names() {
assert!(sheet_base_is_light("// porcelain
mod.theme = mod.themes.light
mod.theme.color_bg_app = #fff
"));
assert!(!sheet_base_is_light("mod.theme = mod.themes.dark
"));
assert!(!sheet_base_is_light(" mod.theme = mod.themes.skeleton
"));
assert!(!sheet_base_is_light("mod.theme.color_bg_app = #fff
"));
}
}