Skip to main content

gpui_rhai_cli/
theme_studio.rs

1use std::cell::RefCell;
2use std::collections::BTreeMap;
3use std::fmt::Write as _;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::rc::Rc;
7
8use gpui_rhai::{
9    AssetData, ColorValue, ComponentInstancePath, EmbeddedScriptSource, EmbeddedScriptView,
10    EventResponse, Length, ModuleId, NativeEvent, NativeHandlerDescriptor, NativeHandlerId, Rgba8,
11    RuntimeEngine, ScriptApplication, ScriptViewExtension, ThemeMode, ThemeMotion, ThemeTokenValue,
12    ThemeTypography, ThemeVariant, UiRuntimeState, UiValue, ValueSchema, load_theme_source,
13};
14
15use super::{
16    AR_LOCALE, BUNDLED_ASSET_SOURCES, BUNDLED_COMPONENT_SOURCES_BY_ID, BUNDLED_THEME_SOURCES,
17    DEFAULT_THEME, EN_LOCALE, STUDIO_SOURCE, ZH_CN_LOCALE,
18};
19const COLOR_TOKENS: &[&str] = &[
20    "surface",
21    "surface_raised",
22    "surface_hover",
23    "text_primary",
24    "text_muted",
25    "accent",
26    "accent_hover",
27    "on_accent",
28    "danger",
29    "on_danger",
30    "warning",
31    "on_warning",
32    "success",
33    "on_success",
34    "border",
35    "focus_ring",
36    "selection",
37    "disabled",
38];
39
40#[derive(Clone)]
41struct StudioSession {
42    root: PathBuf,
43    path: Option<PathBuf>,
44    document: ThemeVariant,
45    attribution: Vec<String>,
46    preview_family: String,
47    preview_variant: String,
48}
49
50impl StudioSession {
51    fn preview(&self) -> ThemeVariant {
52        ThemeVariant {
53            family: self.preview_family.clone(),
54            name: self.preview_variant.clone(),
55            mode: self.document.mode,
56            tokens: self.document.tokens.clone(),
57        }
58    }
59
60    fn path_text(&self) -> String {
61        self.path
62            .as_ref()
63            .map_or_else(String::new, |path| path.to_string_lossy().into_owned())
64    }
65
66    fn resolve_path(&self, path: &Path) -> PathBuf {
67        if path.is_absolute() {
68            path.to_owned()
69        } else {
70            self.root.join(path)
71        }
72    }
73}
74
75#[derive(Clone)]
76struct ThemeStudioExtension {
77    session: Rc<RefCell<StudioSession>>,
78    builtins: Rc<BTreeMap<String, String>>,
79}
80
81impl ScriptViewExtension for ThemeStudioExtension {
82    fn configure_engine(&self, engine: &mut RuntimeEngine) -> Result<(), String> {
83        for field in ["path", "family", "variant"] {
84            register_handler(
85                engine,
86                &format!("theme_studio.set_{field}"),
87                "change",
88                ValueSchema::string(),
89                field_handler(Rc::clone(&self.session), field.to_owned()),
90            )?;
91        }
92        for &token in COLOR_TOKENS {
93            register_handler(
94                engine,
95                &format!("theme_studio.set_{token}"),
96                "change",
97                ValueSchema::string(),
98                color_handler(Rc::clone(&self.session), token.to_owned()),
99            )?;
100        }
101        for mode in ["dark", "light"] {
102            register_handler(
103                engine,
104                &format!("theme_studio.mode_{mode}"),
105                "click",
106                ValueSchema::UiValue,
107                mode_handler(Rc::clone(&self.session), mode.to_owned()),
108            )?;
109        }
110        for action in ["new", "open", "import", "save", "derive"] {
111            let session = Rc::clone(&self.session);
112            let builtins = Rc::clone(&self.builtins);
113            register_handler(
114                engine,
115                &format!("theme_studio.{action}"),
116                "click",
117                ValueSchema::UiValue,
118                Box::new(move |event, runtime, window, app| {
119                    match handle_action(action, &session, &builtins, event, runtime, window, app) {
120                        Ok(response) => Ok(response),
121                        Err(error) => {
122                            set_status(runtime, error)?;
123                            Ok(EventResponse::new().stop())
124                        }
125                    }
126                }),
127            )?;
128        }
129        let session = Rc::clone(&self.session);
130        let builtins = Rc::clone(&self.builtins);
131        register_handler(
132            engine,
133            "theme_studio.builtin",
134            "change",
135            ValueSchema::Array {
136                items: Box::new(ValueSchema::string()),
137                max_items: Some(1),
138            },
139            Box::new(move |event, runtime, _, _| {
140                let key = match event.payload {
141                    UiValue::Array(values) => {
142                        values.into_iter().next().and_then(|value| match value {
143                            UiValue::String(value) => Some(value),
144                            _ => None,
145                        })
146                    }
147                    _ => None,
148                }
149                .ok_or_else(|| "bundled theme selection is empty".to_owned())?;
150                let source = builtins
151                    .get(&key)
152                    .ok_or_else(|| format!("unknown bundled theme `{key}`"))?;
153                load_into_session(&session, source, None, true, runtime)?;
154                Ok(EventResponse::new().stop())
155            }),
156        )?;
157        Ok(())
158    }
159}
160
161type Handler = Box<
162    dyn FnMut(
163        NativeEvent,
164        &mut UiRuntimeState,
165        &mut gpui_rhai::gpui::Window,
166        &mut gpui_rhai::gpui::App,
167    ) -> Result<EventResponse, String>,
168>;
169
170fn register_handler(
171    engine: &RuntimeEngine,
172    id: &str,
173    event: &str,
174    schema: ValueSchema,
175    handler: Handler,
176) -> Result<(), String> {
177    engine
178        .register_native_handler(
179            NativeHandlerDescriptor::new(
180                NativeHandlerId::parse(id).map_err(|error| error.to_string())?,
181                BTreeMap::from([(event.to_owned(), schema)]),
182            )
183            .map_err(|error| error.to_string())?,
184            handler,
185        )
186        .map_err(|error| error.to_string())
187}
188
189fn string_payload(event: NativeEvent) -> Result<String, String> {
190    match event.payload {
191        UiValue::String(value) => Ok(value),
192        value => Err(format!("expected string payload, received {value:?}")),
193    }
194}
195
196fn field_handler(session: Rc<RefCell<StudioSession>>, field: String) -> Handler {
197    Box::new(move |event, runtime, _, _| {
198        let value = string_payload(event)?;
199        {
200            let mut session = session.borrow_mut();
201            match field.as_str() {
202                "path" => {
203                    session.path = (!value.trim().is_empty()).then(|| PathBuf::from(value.trim()));
204                }
205                "family" => session.document.family.clone_from(&value),
206                "variant" => session.document.name.clone_from(&value),
207                _ => return Err(format!("unknown studio field `{field}`")),
208            }
209        }
210        set_root_field(runtime, &field, UiValue::String(value))?;
211        set_status(runtime, validation_status(&session.borrow().document))?;
212        Ok(EventResponse::new().stop())
213    })
214}
215
216fn color_handler(session: Rc<RefCell<StudioSession>>, token: String) -> Handler {
217    Box::new(move |event, runtime, _, _| {
218        let value = string_payload(event)?;
219        set_root_field(
220            runtime,
221            &format!("color_{token}"),
222            UiValue::String(value.clone()),
223        )?;
224        match parse_color(&value) {
225            Ok(color) => {
226                let preview = {
227                    let mut session = session.borrow_mut();
228                    session.document.tokens.colors.insert(token.clone(), color);
229                    session.preview()
230                };
231                runtime
232                    .replace_theme_variant_from_host(preview)
233                    .map_err(|error| error.to_string())?;
234                set_status(runtime, validation_status(&session.borrow().document))?;
235            }
236            Err(error) => set_status(runtime, format!("{token}: {error}"))?,
237        }
238        Ok(EventResponse::new().stop())
239    })
240}
241
242fn mode_handler(session: Rc<RefCell<StudioSession>>, mode: String) -> Handler {
243    Box::new(move |_, runtime, _, _| {
244        let preview = {
245            let mut session = session.borrow_mut();
246            session.document.mode = if mode == "light" {
247                ThemeMode::Light
248            } else {
249                ThemeMode::Dark
250            };
251            session.preview()
252        };
253        set_root_field(runtime, "mode", UiValue::String(mode.clone()))?;
254        runtime
255            .replace_theme_variant_from_host(preview)
256            .map_err(|error| error.to_string())?;
257        set_status(runtime, validation_status(&session.borrow().document))?;
258        Ok(EventResponse::new().stop())
259    })
260}
261
262fn handle_action(
263    action: &str,
264    session: &Rc<RefCell<StudioSession>>,
265    _builtins: &BTreeMap<String, String>,
266    _event: NativeEvent,
267    runtime: &mut UiRuntimeState,
268    _window: &mut gpui_rhai::gpui::Window,
269    _app: &mut gpui_rhai::gpui::App,
270) -> Result<EventResponse, String> {
271    match action {
272        "new" => {
273            let mut draft = default_draft()?;
274            "Untitled".clone_into(&mut draft.family);
275            {
276                let mut session = session.borrow_mut();
277                session.document = draft;
278                session.path = None;
279                session.attribution.clear();
280            }
281            sync_document(runtime, &session.borrow(), "New unsaved theme")?;
282        }
283        "open" | "import" => {
284            let path = session
285                .borrow()
286                .path
287                .clone()
288                .ok_or_else(|| "Enter a .rhai path first".to_owned())?;
289            let resolved = session.borrow().resolve_path(&path);
290            let source = fs::read_to_string(&resolved)
291                .map_err(|error| format!("failed to read {}: {error}", resolved.display()))?;
292            load_into_session(session, &source, Some(path), action == "import", runtime)?;
293        }
294        "save" => {
295            let borrowed = session.borrow();
296            let path = borrowed
297                .path
298                .as_ref()
299                .ok_or_else(|| "Set a .rhai path before saving".to_owned())?;
300            if path.extension().and_then(|extension| extension.to_str()) != Some("rhai") {
301                return Err("Theme Studio saves only .rhai files".to_owned());
302            }
303            borrowed
304                .document
305                .validate()
306                .map_err(|error| error.to_string())?;
307            let resolved = borrowed.resolve_path(path);
308            if let Some(parent) = resolved.parent() {
309                fs::create_dir_all(parent)
310                    .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
311            }
312            fs::write(
313                &resolved,
314                canonical_source(&borrowed.document, &borrowed.attribution),
315            )
316            .map_err(|error| format!("failed to write {}: {error}", resolved.display()))?;
317            set_status(runtime, format!("Saved {}", resolved.display()))?;
318        }
319        "derive" => {
320            let preview = {
321                let mut borrowed = session.borrow_mut();
322                derive_semantic_colors(&mut borrowed.document);
323                borrowed.preview()
324            };
325            runtime
326                .replace_theme_variant_from_host(preview)
327                .map_err(|error| error.to_string())?;
328            sync_color_fields(runtime, &session.borrow().document)?;
329            set_status(runtime, validation_status(&session.borrow().document))?;
330        }
331        _ => return Err(format!("unknown Theme Studio action `{action}`")),
332    }
333    Ok(EventResponse::new().stop())
334}
335
336fn load_into_session(
337    session: &Rc<RefCell<StudioSession>>,
338    source: &str,
339    path: Option<PathBuf>,
340    imported: bool,
341    runtime: &mut UiRuntimeState,
342) -> Result<(), String> {
343    let engine = RuntimeEngine::new();
344    let theme = load_theme_source(engine.engine(), "<theme-studio-import>", source)
345        .map_err(|error| error.to_string())?;
346    {
347        let mut session = session.borrow_mut();
348        session.document = theme;
349        session.path = if imported { None } else { path };
350        session.attribution = leading_attribution(source);
351    }
352    sync_document(
353        runtime,
354        &session.borrow(),
355        if imported {
356            "Imported as an unsaved copy"
357        } else {
358            "Opened theme"
359        },
360    )
361}
362
363fn sync_document(
364    runtime: &mut UiRuntimeState,
365    session: &StudioSession,
366    status: &str,
367) -> Result<(), String> {
368    set_root_field(runtime, "path", UiValue::String(session.path_text()))?;
369    set_root_field(
370        runtime,
371        "family",
372        UiValue::String(session.document.family.clone()),
373    )?;
374    set_root_field(
375        runtime,
376        "variant",
377        UiValue::String(session.document.name.clone()),
378    )?;
379    set_root_field(
380        runtime,
381        "mode",
382        UiValue::String(
383            match session.document.mode {
384                ThemeMode::Light => "light",
385                ThemeMode::Dark => "dark",
386            }
387            .to_owned(),
388        ),
389    )?;
390    sync_color_fields(runtime, &session.document)?;
391    runtime
392        .replace_theme_variant_from_host(session.preview())
393        .map_err(|error| error.to_string())?;
394    set_status(
395        runtime,
396        format!("{status}. {}", validation_status(&session.document)),
397    )
398}
399
400fn sync_color_fields(runtime: &mut UiRuntimeState, theme: &ThemeVariant) -> Result<(), String> {
401    for &token in COLOR_TOKENS {
402        set_root_field(
403            runtime,
404            &format!("color_{token}"),
405            UiValue::String(format_color(theme.tokens.colors[token])),
406        )?;
407    }
408    Ok(())
409}
410
411fn root_path(runtime: &UiRuntimeState) -> Result<ComponentInstancePath, String> {
412    runtime
413        .component_state
414        .inspect()
415        .into_iter()
416        .find(|snapshot| snapshot.fields.contains_key("color_surface"))
417        .map(|snapshot| snapshot.path)
418        .ok_or_else(|| "Theme Studio root state is not mounted".to_owned())
419}
420
421fn set_root_field(runtime: &mut UiRuntimeState, field: &str, value: UiValue) -> Result<(), String> {
422    let path = root_path(runtime)?;
423    runtime
424        .set_component_state_from_host(&path, field, value)
425        .map(|_| ())
426        .map_err(|error| error.to_string())
427}
428
429fn set_status(runtime: &mut UiRuntimeState, status: String) -> Result<(), String> {
430    set_root_field(runtime, "status", UiValue::String(status))
431}
432
433fn parse_color(value: &str) -> Result<Rgba8, String> {
434    match ColorValue::parse(value).map_err(|error| error.to_string())? {
435        ColorValue::Literal(color) => Ok(color),
436        ColorValue::Token(_) => Err("theme token references are not colors".to_owned()),
437    }
438}
439
440fn format_color(color: Rgba8) -> String {
441    let value = color.as_rgba_hex();
442    if value & 0xff == 0xff {
443        format!("#{:06x}", value >> 8)
444    } else {
445        format!("#{value:08x}")
446    }
447}
448
449fn validation_status(theme: &ThemeVariant) -> String {
450    if let Err(error) = theme.validate() {
451        return format!("Invalid theme: {error}");
452    }
453    let mut warnings = Vec::new();
454    for (foreground, background) in [
455        ("text_primary", "surface"),
456        ("text_muted", "surface"),
457        ("on_accent", "accent"),
458        ("on_danger", "danger"),
459        ("on_warning", "warning"),
460        ("on_success", "success"),
461    ] {
462        let ratio = contrast(
463            theme.tokens.colors[foreground],
464            theme.tokens.colors[background],
465        );
466        if ratio < 4.5 {
467            warnings.push(format!("{foreground}/{background} {ratio:.2}:1"));
468        }
469    }
470    let focus = contrast(
471        theme.tokens.colors["focus_ring"],
472        theme.tokens.colors["surface"],
473    );
474    if focus < 3.0 {
475        warnings.push(format!("focus_ring/surface {focus:.2}:1"));
476    }
477    if warnings.is_empty() {
478        "Valid · required contrast pairs pass".to_owned()
479    } else {
480        format!("Valid with contrast warnings: {}", warnings.join(" · "))
481    }
482}
483
484fn contrast(first: Rgba8, second: Rgba8) -> f64 {
485    let first = luminance(first);
486    let second = luminance(second);
487    (first.max(second) + 0.05) / (first.min(second) + 0.05)
488}
489
490fn luminance(color: Rgba8) -> f64 {
491    let [red, green, blue, _] = color.as_rgba_hex().to_be_bytes();
492    0.2126 * linear_channel(red) + 0.7152 * linear_channel(green) + 0.0722 * linear_channel(blue)
493}
494
495fn linear_channel(channel: u8) -> f64 {
496    let channel = f64::from(channel) / 255.0;
497    if channel <= 0.040_45 {
498        channel / 12.92
499    } else {
500        ((channel + 0.055) / 1.055).powf(2.4)
501    }
502}
503
504fn mix(first: Rgba8, second: Rgba8, second_weight: u16) -> Rgba8 {
505    let first = first.as_rgba_hex().to_be_bytes();
506    let second = second.as_rgba_hex().to_be_bytes();
507    let blend = |index: usize| {
508        let first = u16::from(first[index]);
509        let second = u16::from(second[index]);
510        u8::try_from((first * (100 - second_weight) + second * second_weight) / 100)
511            .unwrap_or(u8::MAX)
512    };
513    Rgba8::from_rgba_hex(u32::from_be_bytes([blend(0), blend(1), blend(2), 0xff]))
514}
515
516fn readable_on(color: Rgba8) -> Rgba8 {
517    let dark = Rgba8::from_rgb_hex(0x0000_0000);
518    let light = Rgba8::from_rgb_hex(0x00ff_ffff);
519    if contrast(dark, color) >= contrast(light, color) {
520        dark
521    } else {
522        light
523    }
524}
525
526fn derive_semantic_colors(theme: &mut ThemeVariant) {
527    let surface = theme.tokens.colors["surface"];
528    let text = theme.tokens.colors["text_primary"];
529    let accent = theme.tokens.colors["accent"];
530    theme
531        .tokens
532        .colors
533        .insert("surface_raised".to_owned(), mix(surface, text, 6));
534    theme
535        .tokens
536        .colors
537        .insert("surface_hover".to_owned(), mix(surface, text, 12));
538    theme
539        .tokens
540        .colors
541        .insert("text_muted".to_owned(), mix(surface, text, 68));
542    theme
543        .tokens
544        .colors
545        .insert("disabled".to_owned(), mix(surface, text, 48));
546    theme
547        .tokens
548        .colors
549        .insert("border".to_owned(), mix(surface, text, 24));
550    theme.tokens.colors.insert(
551        "accent_hover".to_owned(),
552        mix(accent, readable_on(accent), 14),
553    );
554    theme
555        .tokens
556        .colors
557        .insert("on_accent".to_owned(), readable_on(accent));
558    theme.tokens.colors.insert("focus_ring".to_owned(), accent);
559    for (fill, foreground) in [
560        ("danger", "on_danger"),
561        ("warning", "on_warning"),
562        ("success", "on_success"),
563    ] {
564        theme.tokens.colors.insert(
565            foreground.to_owned(),
566            readable_on(theme.tokens.colors[fill]),
567        );
568    }
569}
570
571fn leading_attribution(source: &str) -> Vec<String> {
572    source
573        .lines()
574        .take_while(|line| line.trim().is_empty() || line.trim_start().starts_with("//"))
575        .filter(|line| line.trim_start().starts_with("//"))
576        .map(ToOwned::to_owned)
577        .collect()
578}
579
580fn canonical_source(theme: &ThemeVariant, attribution: &[String]) -> String {
581    let mut output = String::new();
582    for line in attribution {
583        let _ = writeln!(output, "{line}");
584    }
585    if !attribution.is_empty() {
586        output.push('\n');
587    }
588    output.push_str("fn theme() {\n    #{\n");
589    let _ = writeln!(output, "        family: {},", json_string(&theme.family));
590    let _ = writeln!(output, "        name: {},", json_string(&theme.name));
591    let _ = writeln!(
592        output,
593        "        mode: \"{}\",",
594        match theme.mode {
595            ThemeMode::Light => "light",
596            ThemeMode::Dark => "dark",
597        }
598    );
599    output.push_str("        tokens: #{\n            colors: #{\n");
600    for &token in COLOR_TOKENS {
601        let _ = writeln!(
602            output,
603            "                {token}: 0x{:08x},",
604            theme.tokens.colors[token].as_rgba_hex()
605        );
606    }
607    output.push_str("            },\n");
608    write_length_map(&mut output, "spacing", &theme.tokens.spacing);
609    write_length_map(&mut output, "radii", &theme.tokens.radii);
610    write_typography(&mut output, &theme.tokens.typography);
611    write_motion(&mut output, &theme.tokens.motion);
612    if !theme.tokens.namespaces.is_empty() {
613        output.push_str("            namespaces: #{\n");
614        for (namespace, tokens) in &theme.tokens.namespaces {
615            let _ = writeln!(output, "                {namespace}: #{{");
616            for (name, value) in tokens {
617                let encoded = match value {
618                    ThemeTokenValue::Color(color) => {
619                        format!(
620                            "#{{ type: \"color\", value: 0x{:08x} }}",
621                            color.as_rgba_hex()
622                        )
623                    }
624                    ThemeTokenValue::Length(length) => format!(
625                        "#{{ type: \"length\", value: {} }}",
626                        encoded_length(*length)
627                    ),
628                    ThemeTokenValue::Number(number) => {
629                        format!("#{{ type: \"number\", value: {number:?} }}")
630                    }
631                    ThemeTokenValue::String(value) => {
632                        format!("#{{ type: \"string\", value: {} }}", json_string(value))
633                    }
634                };
635                let _ = writeln!(output, "                    {name}: {encoded},");
636            }
637            output.push_str("                },\n");
638        }
639        output.push_str("            },\n");
640    }
641    output.push_str("        },\n    }\n}\n");
642    output
643}
644
645fn write_motion(output: &mut String, motion: &ThemeMotion) {
646    output.push_str("            motion: #{\n");
647    output.push_str("                durations_ms: #{\n");
648    for (name, value) in &motion.durations_ms {
649        let _ = writeln!(output, "                    {name}: {value},");
650    }
651    output.push_str("                },\n                easings: #{\n");
652    for (name, value) in &motion.easings {
653        let _ = writeln!(
654            output,
655            "                    {name}: {},",
656            json_string(value.as_str())
657        );
658    }
659    output.push_str("                },\n                springs: #{\n");
660    for (name, value) in &motion.springs {
661        let _ = writeln!(
662            output,
663            "                    {name}: #{{ stiffness: {:?}, damping: {:?}, mass: {:?} }},",
664            value.stiffness, value.damping, value.mass
665        );
666    }
667    output.push_str("                },\n                distances: #{\n");
668    for (name, value) in &motion.distances {
669        let _ = writeln!(output, "                    {name}: {value:?},");
670    }
671    output.push_str("                },\n                staggers_ms: #{\n");
672    for (name, value) in &motion.staggers_ms {
673        let _ = writeln!(output, "                    {name}: {value},");
674    }
675    output.push_str("                },\n            },\n");
676}
677
678fn write_typography(output: &mut String, typography: &ThemeTypography) {
679    output.push_str("            typography: #{\n");
680    if let Some(family) = &typography.family {
681        let _ = writeln!(output, "                family: {},", json_string(family));
682    }
683    if !typography.fallbacks.is_empty() {
684        let fallbacks = typography
685            .fallbacks
686            .iter()
687            .map(|family| json_string(family))
688            .collect::<Vec<_>>()
689            .join(", ");
690        let _ = writeln!(output, "                fallbacks: [{fallbacks}],");
691    }
692    output.push_str("                roles: #{\n");
693    for role in gpui_rhai::REQUIRED_TYPOGRAPHY {
694        let token = &typography.roles[*role];
695        let _ = writeln!(
696            output,
697            "                    {role}: #{{ size: {}, line_height: {}, weight: {} }},",
698            encoded_length(token.size),
699            encoded_length(token.line_height),
700            token.weight
701        );
702    }
703    output.push_str("                },\n            },\n");
704}
705
706fn write_length_map(output: &mut String, name: &str, values: &BTreeMap<String, Length>) {
707    let _ = writeln!(output, "            {name}: #{{");
708    for (token, value) in values {
709        let _ = writeln!(
710            output,
711            "                {token}: {},",
712            encoded_length(*value)
713        );
714    }
715    output.push_str("            },\n");
716}
717
718fn encoded_length(length: Length) -> String {
719    match length {
720        Length::Pixels(value) => {
721            format!("#{{ unit: \"pixels\", value: {value:?} }}")
722        }
723        Length::Rems(value) => format!("#{{ unit: \"rems\", value: {value:?} }}"),
724        Length::Relative(value) => {
725            format!("#{{ unit: \"relative\", value: {value:?} }}")
726        }
727        Length::ThemeSpacing(_) | Length::ThemeRadius(_) => {
728            unreachable!("validated theme documents cannot nest length tokens")
729        }
730    }
731}
732
733fn default_draft() -> Result<ThemeVariant, String> {
734    let engine = RuntimeEngine::new();
735    load_theme_source(engine.engine(), "default_dark.rhai", DEFAULT_THEME)
736        .map_err(|error| error.to_string())
737}
738
739fn json_string(value: &str) -> String {
740    serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_owned())
741}
742
743fn source_with_state(theme: &ThemeVariant, path: &str, status: &str) -> String {
744    let mut source = STUDIO_SOURCE.to_owned();
745    for (placeholder, value) in [
746        ("__PATH__", json_string(path)),
747        ("__FAMILY__", json_string(&theme.family)),
748        ("__VARIANT__", json_string(&theme.name)),
749        (
750            "__MODE__",
751            json_string(match theme.mode {
752                ThemeMode::Light => "light",
753                ThemeMode::Dark => "dark",
754            }),
755        ),
756        ("__STATUS__", json_string(status)),
757        ("__PREVIEW_FAMILY__", json_string(&theme.family)),
758        ("__PREVIEW_VARIANT__", json_string(&theme.name)),
759    ] {
760        source = source.replace(placeholder, &value);
761    }
762    for &token in COLOR_TOKENS {
763        source = source.replace(
764            &format!("__COLOR_{}__", token.to_ascii_uppercase()),
765            &json_string(&format_color(theme.tokens.colors[token])),
766        );
767    }
768    let visual_state = std::env::var("GPUI_RHAI_VISUAL_STATE").unwrap_or_default();
769    let visual_locale = std::env::var("GPUI_RHAI_VISUAL_LOCALE")
770        .ok()
771        .filter(|locale| matches!(locale.as_str(), "en" | "zh-CN" | "ar"))
772        .unwrap_or_else(|| "en".to_owned());
773    source
774        .replace("__BUILTIN_OPTIONS__", &builtin_options_source())
775        .replace("__GALLERY_ONLY__", "false")
776        .replace("__GALLERY_THEME__", &json_string("default_dark"))
777        .replace("__GALLERY_CATEGORY__", &json_string("all"))
778        .replace(
779            "__VISUAL_DIALOG__",
780            if visual_state == "dialog" {
781                "true"
782            } else {
783                "false"
784            },
785        )
786        .replace(
787            "__VISUAL_POPOVER__",
788            if visual_state == "popover" {
789                "true"
790            } else {
791                "false"
792            },
793        )
794        .replace(
795            "__VISUAL_COMMAND__",
796            if visual_state == "command-dialog" {
797                "true"
798            } else {
799                "false"
800            },
801        )
802        .replace(
803            "__VISUAL_SHEET__",
804            if visual_state == "sheet" {
805                "true"
806            } else {
807                "false"
808            },
809        )
810        .replace(
811            "__VISUAL_ALERT__",
812            if visual_state == "alert-dialog" {
813                "true"
814            } else {
815                "false"
816            },
817        )
818        .replace(
819            "__VISUAL_MENU__",
820            if visual_state == "menu" {
821                "true"
822            } else {
823                "false"
824            },
825        )
826        .replace(
827            "__VISUAL_TOAST__",
828            if visual_state == "toast" {
829                "true"
830            } else {
831                "false"
832            },
833        )
834        .replace("__VISUAL_LOCALE__", &json_string(&visual_locale))
835}
836
837fn builtin_options_source() -> String {
838    let engine = RuntimeEngine::new();
839    let mut options = Vec::new();
840    for &(file, source) in BUNDLED_THEME_SOURCES {
841        if let Ok(theme) = load_theme_source(engine.engine(), file, source) {
842            options.push(format!(
843                "#{{ value: {}, label: {} }}",
844                json_string(file.trim_end_matches(".rhai")),
845                json_string(&format!("{} {}", theme.family, theme.name))
846            ));
847        }
848    }
849    format!("[{}]", options.join(", "))
850}
851
852fn module(id: &str, source: &str) -> (ModuleId, String) {
853    (
854        ModuleId::parse(id).expect("static Theme Studio module ID"),
855        source.to_owned(),
856    )
857}
858
859fn asset(bytes: &[u8]) -> AssetData {
860    AssetData {
861        mime_type: "image/svg+xml".to_owned(),
862        bytes: bytes.to_vec(),
863    }
864}
865
866fn studio_scripts(main: &str) -> EmbeddedScriptSource {
867    let mut modules = BTreeMap::from([module("main", main)]);
868    modules.extend(
869        BUNDLED_COMPONENT_SOURCES_BY_ID
870            .iter()
871            .map(|(id, source)| module(id, source)),
872    );
873    EmbeddedScriptSource::new(modules)
874}
875
876/// Launch the first-party gpui-rhai theme editor.
877///
878/// # Errors
879///
880/// Returns file, theme, compilation, or application errors.
881pub fn run(root: PathBuf, path: Option<PathBuf>) -> Result<(), String> {
882    let visual_theme = std::env::var("GPUI_RHAI_VISUAL_THEME").ok();
883    let (document, attribution, path, status) = if let Some(path) = path {
884        let resolved = if path.is_absolute() {
885            path.clone()
886        } else {
887            root.join(&path)
888        };
889        let source = fs::read_to_string(&resolved)
890            .map_err(|error| format!("failed to read {}: {error}", resolved.display()))?;
891        let engine = RuntimeEngine::new();
892        let theme = load_theme_source(engine.engine(), &resolved.to_string_lossy(), &source)
893            .map_err(|error| error.to_string())?;
894        (
895            theme,
896            leading_attribution(&source),
897            Some(path),
898            format!("Opened {}", resolved.display()),
899        )
900    } else if let Some(key) = visual_theme {
901        let normalized_key = key.replace('-', "_");
902        let source = BUNDLED_THEME_SOURCES
903            .iter()
904            .find(|(file, _)| file.trim_end_matches(".rhai") == normalized_key)
905            .map(|(_, source)| *source)
906            .ok_or_else(|| format!("unknown visual Theme Studio theme `{key}`"))?;
907        let engine = RuntimeEngine::new();
908        let theme = load_theme_source(engine.engine(), "<visual-theme>", source)
909            .map_err(|error| error.to_string())?;
910        (
911            theme,
912            leading_attribution(source),
913            None,
914            format!("Visual theme {key}"),
915        )
916    } else {
917        (
918            default_draft()?,
919            Vec::new(),
920            None,
921            "New theme from Default Dark".to_owned(),
922        )
923    };
924    let session = Rc::new(RefCell::new(StudioSession {
925        root,
926        path,
927        preview_family: document.family.clone(),
928        preview_variant: document.name.clone(),
929        document,
930        attribution,
931    }));
932    let builtins = Rc::new(
933        BUNDLED_THEME_SOURCES
934            .iter()
935            .map(|(file, source)| {
936                (
937                    file.trim_end_matches(".rhai").to_owned(),
938                    (*source).to_owned(),
939                )
940            })
941            .collect::<BTreeMap<_, _>>(),
942    );
943    launch(session, builtins, &status)
944}
945
946fn launch(
947    session: Rc<RefCell<StudioSession>>,
948    builtins: Rc<BTreeMap<String, String>>,
949    status: &str,
950) -> Result<(), String> {
951    let borrowed = session.borrow();
952    let main = source_with_state(
953        &borrowed.document,
954        &borrowed.path_text(),
955        &format!("{status}. {}", validation_status(&borrowed.document)),
956    );
957    let primary = canonical_source(&borrowed.preview(), &borrowed.attribution);
958    let preview_identity = (
959        borrowed.preview_family.clone(),
960        borrowed.preview_variant.clone(),
961    );
962    drop(borrowed);
963    let scripts = studio_scripts(&main);
964    let engine = RuntimeEngine::new();
965    let additional_themes = BUNDLED_THEME_SOURCES
966        .iter()
967        .filter_map(|(file, source)| {
968            load_theme_source(engine.engine(), file, source)
969                .ok()
970                .filter(|theme| {
971                    (theme.family.as_str(), theme.name.as_str())
972                        != (preview_identity.0.as_str(), preview_identity.1.as_str())
973                })
974                .map(|_| ((*file).to_owned(), (*source).to_owned()))
975        })
976        .collect::<Vec<_>>();
977    let entry = ModuleId::parse("main").map_err(|error| error.to_string())?;
978    EmbeddedScriptView::new(entry, scripts, primary)
979        .theme_sources(additional_themes)
980        .locale_sources([
981            ("en.rhai".to_owned(), EN_LOCALE.to_owned()),
982            ("zh_cn.rhai".to_owned(), ZH_CN_LOCALE.to_owned()),
983            ("ar.rhai".to_owned(), AR_LOCALE.to_owned()),
984        ])
985        .asset_sources(BUNDLED_ASSET_SOURCES.iter().map(|(path, source)| {
986            (
987                path.strip_suffix(".svg").unwrap_or(path).to_owned(),
988                asset(source.as_bytes()),
989            )
990        }))
991        .extension(ThemeStudioExtension { session, builtins })
992        .development(true)
993        .prepare()
994        .and_then(|prepared| {
995            ScriptApplication::new(prepared)
996                .window_size(1280.0, 820.0)
997                .run()
998        })
999        .map_err(|error| error.to_string())
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004    use super::*;
1005    use gpui_rhai::{RestrictedModuleResolver, ScriptLifecycle};
1006
1007    #[test]
1008    fn canonical_theme_round_trips_and_preserves_attribution() {
1009        let mut theme = default_draft().unwrap();
1010        theme
1011            .tokens
1012            .spacing
1013            .insert("xs".to_owned(), Length::Rems(0.25));
1014        theme.tokens.namespaces.insert(
1015            "charts".to_owned(),
1016            BTreeMap::from([
1017                (
1018                    "series_a".to_owned(),
1019                    ThemeTokenValue::Color(Rgba8::from_rgb_hex(0x0033_66ff)),
1020                ),
1021                (
1022                    "stroke".to_owned(),
1023                    ThemeTokenValue::Length(Length::Pixels(2.0)),
1024                ),
1025                ("alpha".to_owned(), ThemeTokenValue::Number(0.6)),
1026                (
1027                    "label".to_owned(),
1028                    ThemeTokenValue::String("Primary".to_owned()),
1029                ),
1030            ]),
1031        );
1032        let theme = gpui_rhai::ThemeManager::from_variants(
1033            [theme],
1034            gpui_rhai::ThemeSelection::new("Default", "Dark"),
1035        )
1036        .unwrap()
1037        .resolve(None, None, gpui_rhai::SystemAppearance::Dark)
1038        .unwrap()
1039        .variant()
1040        .clone();
1041        let source = canonical_source(&theme, &["// Attribution".to_owned()]);
1042        assert!(source.starts_with("// Attribution\n\n"));
1043        let engine = RuntimeEngine::new();
1044        assert_eq!(
1045            load_theme_source(engine.engine(), "roundtrip.rhai", &source).unwrap(),
1046            theme
1047        );
1048    }
1049
1050    #[test]
1051    fn studio_source_contains_every_official_component() {
1052        for (component, _) in BUNDLED_COMPONENT_SOURCES_BY_ID {
1053            assert!(
1054                STUDIO_SOURCE.contains(&format!("import \"{component}\"")),
1055                "{component}"
1056            );
1057        }
1058    }
1059
1060    #[test]
1061    fn studio_executes_the_real_component_specimen() {
1062        let document = default_draft().unwrap();
1063        let session = Rc::new(RefCell::new(StudioSession {
1064            root: PathBuf::new(),
1065            path: None,
1066            preview_family: document.family.clone(),
1067            preview_variant: document.name.clone(),
1068            document: document.clone(),
1069            attribution: Vec::new(),
1070        }));
1071        let builtins = Rc::new(
1072            BUNDLED_THEME_SOURCES
1073                .iter()
1074                .map(|(file, source)| {
1075                    (
1076                        file.trim_end_matches(".rhai").to_owned(),
1077                        (*source).to_owned(),
1078                    )
1079                })
1080                .collect(),
1081        );
1082        let main = source_with_state(&document, "", &validation_status(&document));
1083        let scripts = studio_scripts(&main);
1084        let mut engine = RuntimeEngine::new();
1085        ThemeStudioExtension {
1086            session: Rc::clone(&session),
1087            builtins: Rc::clone(&builtins),
1088        }
1089        .configure_engine(&mut engine)
1090        .unwrap();
1091        engine.set_module_resolver(RestrictedModuleResolver::from_source(&scripts).unwrap());
1092        let compiled = engine
1093            .compile_self_contained_named("studio/main.rhai", &main)
1094            .unwrap();
1095        let schema = engine.root_state_schema(&compiled).unwrap();
1096        let mut runtime_state = UiRuntimeState::new();
1097        runtime_state.theme = Some(
1098            gpui_rhai::ThemeManager::from_variants(
1099                [document.clone()],
1100                gpui_rhai::ThemeSelection::new(&document.family, &document.name),
1101            )
1102            .unwrap(),
1103        );
1104        let locale = gpui_rhai::load_locale_source(engine.engine(), "en.rhai", EN_LOCALE).unwrap();
1105        runtime_state.locale = Some(gpui_rhai::LocaleManager::new([locale], "en", "en").unwrap());
1106        let runtime = Rc::new(RefCell::new(runtime_state));
1107        let mut lifecycle = ScriptLifecycle::new(
1108            compiled,
1109            Rc::clone(&runtime),
1110            ComponentInstancePath::root("ThemeStudio", "root"),
1111            Some("main".to_owned()),
1112            BTreeMap::new(),
1113            &schema,
1114        )
1115        .unwrap();
1116        lifecycle.start(&mut engine).unwrap();
1117        assert!(lifecycle.root().is_some());
1118
1119        load_into_session(
1120            &session,
1121            super::super::ETHEREAL_THEME,
1122            Some(PathBuf::from("source.rhai")),
1123            true,
1124            &mut runtime.borrow_mut(),
1125        )
1126        .unwrap();
1127        assert_eq!(session.borrow().document.family, "Ethereal");
1128        assert!(session.borrow().path.is_none(), "Import must create a copy");
1129        let root = runtime
1130            .borrow()
1131            .component_state
1132            .inspect()
1133            .into_iter()
1134            .find(|snapshot| snapshot.fields.contains_key("color_surface"))
1135            .unwrap();
1136        assert_eq!(
1137            root.fields["family"].value,
1138            UiValue::String("Ethereal".to_owned())
1139        );
1140    }
1141}