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
876pub 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}