Skip to main content

gpui_rhai_cli/
gallery.rs

1use std::collections::BTreeMap;
2use std::time::Duration;
3
4use gpui_rhai::gpui::prelude::*;
5use gpui_rhai::{
6    AppManifest, AssetData, CapabilityDescriptor, CapabilityId, CapabilityMethod, ChartDataLimits,
7    ChartDataset, ChartGeoMap, EmbeddedScriptSource, EmbeddedScriptView, HostSlotRegistry,
8    ModuleId, NativeChartData, NativeCollection, NativeEvent, NativeHandlerDescriptor,
9    NativeHandlerId, NativeTextDocument, ObjectField, PreparedScriptView, RuntimeEngine,
10    ScriptViewExtension, SubscriptionCapabilityHandler, SubscriptionWork, ThemeMode,
11    ThemeTokenOverrides, ThemeVariant, UiRuntimeState, UiValue, ValueSchema, extract_imports,
12    load_theme_source,
13};
14use gpui_rhai_registry::{
15    AR_LOCALE, BUNDLED_ASSET_SOURCES, BUNDLED_CHART_SOURCES_BY_ID, BUNDLED_COMPONENT_SOURCES_BY_ID,
16    BUNDLED_MOTION_SOURCES_BY_ID, BUNDLED_STORIES, BUNDLED_THEME_SOURCES, EN_LOCALE,
17    HOST_RESIDENT_STORY_SOURCE, StoryDefinition, ZH_CN_LOCALE,
18};
19
20pub const DEFAULT_STORY: &str = "components/button";
21
22const COMPONENT_CATALOG_COLOR_TOKENS: &[&str] = &[
23    "surface",
24    "surface_raised",
25    "surface_hover",
26    "text_primary",
27    "text_muted",
28    "accent",
29    "accent_hover",
30    "on_accent",
31    "danger",
32    "on_danger",
33    "warning",
34    "on_warning",
35    "success",
36    "on_success",
37    "border",
38    "focus_ring",
39    "selection",
40    "disabled",
41];
42
43#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct GalleryLaunch {
45    pub story: String,
46    pub case: String,
47    pub theme: String,
48    pub locale: String,
49    pub motion: gpui_rhai::MotionPreference,
50}
51
52impl Default for GalleryLaunch {
53    fn default() -> Self {
54        Self {
55            story: DEFAULT_STORY.to_owned(),
56            case: "basic".to_owned(),
57            theme: "default-dark".to_owned(),
58            locale: "en".to_owned(),
59            motion: gpui_rhai::MotionPreference::Normal,
60        }
61    }
62}
63
64/// Parse a Gallery motion preference from its stable CLI spelling.
65///
66/// # Errors
67///
68/// Returns an explicit diagnostic for values other than normal, reduced, or none.
69pub fn parse_motion_preference(value: &str) -> Result<gpui_rhai::MotionPreference, String> {
70    match value {
71        "normal" => Ok(gpui_rhai::MotionPreference::Normal),
72        "reduced" => Ok(gpui_rhai::MotionPreference::Reduced),
73        "none" => Ok(gpui_rhai::MotionPreference::None),
74        _ => Err(format!(
75            "unknown Gallery motion preference `{value}`; expected normal, reduced, or none"
76        )),
77    }
78}
79
80#[must_use]
81pub fn stories() -> &'static [StoryDefinition] {
82    BUNDLED_STORIES
83}
84
85#[must_use]
86pub fn list_text() -> String {
87    BUNDLED_STORIES
88        .iter()
89        .map(|story| {
90            let cases = story
91                .cases
92                .iter()
93                .map(|case| case.id)
94                .collect::<Vec<_>>()
95                .join(",");
96            format!(
97                "{}\t{}\t{}\t{}",
98                story.id, story.category, cases, story.title
99            )
100        })
101        .collect::<Vec<_>>()
102        .join("\n")
103}
104
105pub(crate) fn resolve_story(id: &str, case: &str) -> Result<&'static StoryDefinition, String> {
106    let story = BUNDLED_STORIES
107        .iter()
108        .find(|story| story.id == id)
109        .ok_or_else(|| format!("unknown Gallery story `{id}`; run `gpui-rhai gallery --list`"))?;
110    if !story.cases.iter().any(|candidate| candidate.id == case) {
111        return Err(format!(
112            "unknown case `{case}` for Gallery story `{id}`; available: {}",
113            story
114                .cases
115                .iter()
116                .map(|candidate| candidate.id)
117                .collect::<Vec<_>>()
118                .join(", ")
119        ));
120    }
121    Ok(story)
122}
123
124fn operations_fixture_case(case: &str) -> &str {
125    match case {
126        "failure-terminal" => "failure",
127        "loading" | "empty" | "failure" | "streaming" | "large" => case,
128        _ => "normal",
129    }
130}
131
132fn materialize_story_source(
133    story: &StoryDefinition,
134    launch: &GalleryLaunch,
135    selected: &ThemeVariant,
136) -> Result<String, String> {
137    if story.id == "apps/operations" {
138        let page = match launch.case.as_str() {
139            "config-diff" | "failure" => "configurations",
140            "failure-terminal" => "deployments",
141            "theme-overrides" => "settings",
142            "large" => "hosts",
143            _ => "dashboard",
144        };
145        let fixture_case = operations_fixture_case(&launch.case);
146        let source = story.source.replace(
147            "__OPERATIONS_PAGE__",
148            &serde_json::to_string(page).map_err(|error| error.to_string())?,
149        );
150        let source = source.replace(
151            "__OPERATIONS_CASE__",
152            &serde_json::to_string(fixture_case).map_err(|error| error.to_string())?,
153        );
154        let command_open = launch.case == "command-dialog";
155        let failure_terminal = launch.case == "failure-terminal";
156        Ok(source
157            .replace("__OPERATIONS_COMMAND_OPEN__", &command_open.to_string())
158            .replace(
159                "__OPERATIONS_DEPLOYMENT_PHASE__",
160                if failure_terminal {
161                    "\"failed\""
162                } else {
163                    "\"idle\""
164                },
165            )
166            .replace(
167                "__OPERATIONS_DEPLOYMENT_PROGRESS__",
168                if failure_terminal { "48.0" } else { "0.0" },
169            )
170            .replace(
171                "__OPERATIONS_TOAST_VISIBLE__",
172                &failure_terminal.to_string(),
173            ))
174    } else if story.id == "components/table" {
175        Ok(story.source.replace(
176            "__TABLE_CASE__",
177            &serde_json::to_string(&launch.case).map_err(|error| error.to_string())?,
178        ))
179    } else if story.id == "components/catalog" {
180        let json = |value: &str| serde_json::to_string(value).map_err(|error| error.to_string());
181        let category = if launch.case == "basic" {
182            "foundations"
183        } else {
184            launch.case.as_str()
185        };
186        let mode = match selected.mode {
187            ThemeMode::Light => "light",
188            ThemeMode::Dark => "dark",
189        };
190        let mut source = story.source.to_owned();
191        for (placeholder, value) in [
192            ("__PATH__", json("")?),
193            ("__FAMILY__", json(&selected.family)?),
194            ("__VARIANT__", json(&selected.name)?),
195            ("__MODE__", json(mode)?),
196            ("__STATUS__", json("Component Catalog")?),
197            ("__PREVIEW_FAMILY__", json(&selected.family)?),
198            ("__PREVIEW_VARIANT__", json(&selected.name)?),
199            ("__GALLERY_THEME__", json(&launch.theme.replace('-', "_"))?),
200            ("__GALLERY_CATEGORY__", json(category)?),
201        ] {
202            source = source.replace(placeholder, &value);
203        }
204        for token in COMPONENT_CATALOG_COLOR_TOKENS {
205            let value = format!("#{:08x}", selected.tokens.colors[*token].as_rgba_hex());
206            source = source.replace(
207                &format!("__COLOR_{}__", token.to_ascii_uppercase()),
208                &serde_json::to_string(&value).map_err(|error| error.to_string())?,
209            );
210        }
211        Ok(source
212            .replace("__BUILTIN_OPTIONS__", "[]")
213            .replace("__VISUAL_DIALOG__", "false")
214            .replace("__VISUAL_POPOVER__", "false")
215            .replace("__VISUAL_COMMAND__", "false")
216            .replace("__VISUAL_SHEET__", "false")
217            .replace("__VISUAL_ALERT__", "false")
218            .replace("__VISUAL_MENU__", "false")
219            .replace("__VISUAL_TOAST__", "false")
220            .replace(
221                "__VISUAL_LOCALE__",
222                &serde_json::to_string(&launch.locale).map_err(|error| error.to_string())?,
223            )
224            .replace("__GALLERY_ONLY__", "true"))
225    } else {
226        Ok(story.source.to_owned())
227    }
228}
229
230pub(crate) fn story_source(launch: &GalleryLaunch) -> Result<String, String> {
231    let story = resolve_story(&launch.story, &launch.case)?;
232    let (theme_name, primary_theme) = theme_source(&launch.theme)?;
233    let engine = RuntimeEngine::new();
234    let selected = load_theme_source(engine.engine(), theme_name, primary_theme)
235        .map_err(|error| error.to_string())?;
236    materialize_story_source(story, launch, &selected)
237}
238
239fn theme_source(slug: &str) -> Result<(&'static str, &'static str), String> {
240    let normalized = slug.replace('-', "_");
241    BUNDLED_THEME_SOURCES
242        .iter()
243        .copied()
244        .find(|(name, _)| name.strip_suffix(".rhai") == Some(normalized.as_str()))
245        .ok_or_else(|| format!("unknown Gallery theme `{slug}`"))
246}
247
248fn locale_sources(locale: &str) -> Result<[(&'static str, &'static str); 3], String> {
249    if !matches!(locale, "en" | "zh-CN" | "ar") {
250        return Err(format!("unknown Gallery locale `{locale}`"));
251    }
252    Ok([
253        ("en.rhai", EN_LOCALE),
254        ("zh_cn.rhai", ZH_CN_LOCALE),
255        ("ar.rhai", AR_LOCALE),
256    ])
257}
258
259fn story_scripts(story: &StoryDefinition, source: String) -> Result<EmbeddedScriptSource, String> {
260    let mut modules = bundled_dependency_modules(&source, story.module_ids)?;
261    modules.insert(
262        ModuleId::parse(story.source_module).map_err(|error| error.to_string())?,
263        source,
264    );
265    Ok(EmbeddedScriptSource::new(modules))
266}
267
268fn bundled_module_source(id: &str) -> Option<&'static str> {
269    BUNDLED_COMPONENT_SOURCES_BY_ID
270        .iter()
271        .chain(BUNDLED_MOTION_SOURCES_BY_ID)
272        .chain(BUNDLED_CHART_SOURCES_BY_ID)
273        .find_map(|(candidate, source)| (*candidate == id).then_some(*source))
274}
275
276pub(crate) fn bundled_dependency_modules(
277    root_source: &str,
278    required: &[&str],
279) -> Result<BTreeMap<ModuleId, String>, String> {
280    let mut pending = extract_imports(root_source).map_err(|error| error.to_string())?;
281    for id in required {
282        pending.insert(ModuleId::parse(*id).map_err(|error| error.to_string())?);
283    }
284    let mut modules = BTreeMap::new();
285    while let Some(id) = pending.pop_first() {
286        if modules.contains_key(&id) {
287            continue;
288        }
289        let source = bundled_module_source(id.as_str())
290            .ok_or_else(|| format!("Gallery source imports unknown bundled module `{id}`"))?;
291        pending.extend(extract_imports(source).map_err(|error| error.to_string())?);
292        modules.insert(id, source.to_owned());
293    }
294    Ok(modules)
295}
296
297fn asset(bytes: &[u8]) -> AssetData {
298    AssetData {
299        mime_type: "image/svg+xml".to_owned(),
300        bytes: bytes.to_vec(),
301    }
302}
303
304fn story_init_source(locale: &str) -> Result<String, String> {
305    if locale == "en" {
306        Ok("fn init(ctx) {}".to_owned())
307    } else {
308        Ok(format!(
309            "fn init(ctx) {{ ctx.set_locale({}); }}",
310            serde_json::to_string(locale).map_err(|error| error.to_string())?
311        ))
312    }
313}
314
315#[derive(Clone, Debug)]
316struct OperationsFixture {
317    fixture_case: String,
318}
319
320fn operations_host_ids(fixture_case: &str) -> Vec<String> {
321    if fixture_case == "large" {
322        (1..=1_000)
323            .map(|index| format!("edge-{index:02}"))
324            .collect()
325    } else {
326        let mut hosts = vec![
327            "edge-01".to_owned(),
328            "edge-02".to_owned(),
329            "edge-03".to_owned(),
330        ];
331        if fixture_case == "failure" {
332            hosts.push("edge-04".to_owned());
333        }
334        hosts
335    }
336}
337
338fn operations_config_name(host: &str) -> String {
339    format!("operations_config_{host}")
340}
341
342fn operations_config_text(host: &str, channel: &str) -> String {
343    let port = if host == "edge-02" { 8443 } else { 443 };
344    format!("server {{\n  host = \"{host}\"\n  channel = \"{channel}\"\n  port = {port}\n}}\n")
345}
346
347fn operations_payload_field<'a>(
348    payload: &'a BTreeMap<String, UiValue>,
349    name: &str,
350) -> Result<&'a UiValue, String> {
351    payload
352        .get(name)
353        .ok_or_else(|| format!("deployment payload is missing `{name}`"))
354}
355
356fn operations_component(
357    runtime: &UiRuntimeState,
358) -> Result<gpui_rhai::ComponentInstancePath, String> {
359    runtime
360        .component_state
361        .paths()
362        .into_iter()
363        .find(|path| {
364            runtime
365                .component_state
366                .get(path, "deployment_phase")
367                .is_some()
368                && runtime.component_state.get(path, "deploy_target").is_some()
369        })
370        .ok_or_else(|| "Operations Workbench state is not mounted".to_owned())
371}
372
373fn set_operations_state(
374    runtime: &mut UiRuntimeState,
375    component: &gpui_rhai::ComponentInstancePath,
376    field: &str,
377    value: UiValue,
378) -> Result<(), String> {
379    runtime
380        .set_component_state_from_host(component, field, value)
381        .map(|_| ())
382        .map_err(|error| error.to_string())
383}
384
385fn finish_operations_deployment(
386    runtime: &mut UiRuntimeState,
387    component: &gpui_rhai::ComponentInstancePath,
388    succeeded: bool,
389) -> Result<(), String> {
390    set_operations_state(runtime, component, "confirm_open", UiValue::Bool(false))?;
391    set_operations_state(
392        runtime,
393        component,
394        "deployment_phase",
395        UiValue::String(if succeeded { "complete" } else { "failed" }.to_owned()),
396    )?;
397    set_operations_state(
398        runtime,
399        component,
400        "deployment_progress",
401        UiValue::Float(if succeeded { 100.0 } else { 0.0 }),
402    )?;
403    set_operations_state(runtime, component, "toast_visible", UiValue::Bool(true))?;
404    set_operations_state(
405        runtime,
406        component,
407        "page",
408        UiValue::String("deployments".to_owned()),
409    )
410}
411
412fn commit_operations_deployment(
413    runtime: &mut UiRuntimeState,
414    payload: UiValue,
415    fixture_case: &str,
416) -> Result<(), String> {
417    let UiValue::Map(payload) = payload else {
418        return Err("deployment payload must be an object".to_owned());
419    };
420    let UiValue::String(target) = operations_payload_field(&payload, "target")? else {
421        return Err("deployment target must be a string".to_owned());
422    };
423    let UiValue::String(channel) = operations_payload_field(&payload, "channel")? else {
424        return Err("deployment channel must be a string".to_owned());
425    };
426    let UiValue::Integer(expected_revision) =
427        operations_payload_field(&payload, "expected_revision")?
428    else {
429        return Err("deployment expected_revision must be an integer".to_owned());
430    };
431    let UiValue::Bool(requested_failure) = operations_payload_field(&payload, "fail")? else {
432        return Err("deployment fail flag must be a boolean".to_owned());
433    };
434    let component = operations_component(runtime)?;
435    if *requested_failure || fixture_case == "failure" {
436        return finish_operations_deployment(runtime, &component, false);
437    }
438    let expected_revision = u64::try_from(*expected_revision)
439        .map_err(|_| "deployment revision cannot be negative".to_owned())?;
440    let name = operations_config_name(target);
441    let current = runtime
442        .native_documents
443        .get(&name)
444        .map_err(|error| error.to_string())?;
445    if current.revision() != expected_revision {
446        return finish_operations_deployment(runtime, &component, false);
447    }
448    let next = NativeTextDocument::new(
449        &name,
450        current.revision().saturating_add(1),
451        operations_config_text(target, channel),
452    )
453    .map_err(|error| error.to_string())?;
454    runtime
455        .replace_native_text_document_from_host(&name, next)
456        .map_err(|error| error.to_string())?;
457    finish_operations_deployment(runtime, &component, true)
458}
459
460impl ScriptViewExtension for OperationsFixture {
461    fn configure_engine(&self, engine: &mut RuntimeEngine) -> Result<(), String> {
462        let fixture_case = self.fixture_case.clone();
463        engine
464            .register_native_handler(
465                NativeHandlerDescriptor::new(
466                    NativeHandlerId::parse("gallery.operations_deploy")
467                        .map_err(|error| error.to_string())?,
468                    BTreeMap::from([(
469                        "click".to_owned(),
470                        ValueSchema::Object {
471                            fields: BTreeMap::from([
472                                (
473                                    "target".to_owned(),
474                                    ObjectField::required(ValueSchema::string()),
475                                ),
476                                (
477                                    "channel".to_owned(),
478                                    ObjectField::required(ValueSchema::string()),
479                                ),
480                                (
481                                    "expected_revision".to_owned(),
482                                    ObjectField::required(ValueSchema::integer()),
483                                ),
484                                ("fail".to_owned(), ObjectField::required(ValueSchema::Bool)),
485                            ]),
486                            allow_unknown: false,
487                        },
488                    )]),
489                )
490                .map_err(|error| error.to_string())?,
491                move |event: NativeEvent, runtime, _, _| {
492                    commit_operations_deployment(runtime, event.payload, &fixture_case)?;
493                    Ok(gpui_rhai::EventResponse::new().stop())
494                },
495            )
496            .map_err(|error| error.to_string())
497    }
498
499    fn configure_runtime(&self, runtime: &mut UiRuntimeState) -> Result<(), String> {
500        runtime
501            .native_collections
502            .register("operations_hosts", operations_hosts(&self.fixture_case)?)
503            .map_err(|error| error.to_string())?;
504        runtime
505            .native_collections
506            .register("operations_events", operations_events(&self.fixture_case)?)
507            .map_err(|error| error.to_string())?;
508        for host in operations_host_ids(&self.fixture_case) {
509            let name = operations_config_name(&host);
510            let channel = if host == "edge-02" {
511                "candidate"
512            } else {
513                "stable"
514            };
515            runtime
516                .native_documents
517                .register(
518                    &name,
519                    NativeTextDocument::new(&name, 1, operations_config_text(&host, channel))
520                        .map_err(|error| error.to_string())?,
521                )
522                .map_err(|error| error.to_string())?;
523        }
524        runtime.native_chart_data.insert(
525            "operations_metrics".to_owned(),
526            operations_metrics(&self.fixture_case)?,
527        );
528        let capability =
529            CapabilityId::parse("gallery.operations").map_err(|error| error.to_string())?;
530        runtime
531            .capabilities
532            .register_subscription(
533                CapabilityDescriptor {
534                    id: capability,
535                    version: semver::Version::new(1, 0, 0),
536                    methods: BTreeMap::from([(
537                        "stream".to_owned(),
538                        CapabilityMethod {
539                            input: ValueSchema::Null,
540                            output: ValueSchema::integer(),
541                        },
542                    )]),
543                },
544                OperationsSubscription,
545            )
546            .map_err(|error| error.to_string())?;
547        Ok(())
548    }
549}
550
551#[derive(Clone, Copy, Debug)]
552struct OperationsSubscription;
553
554impl SubscriptionCapabilityHandler for OperationsSubscription {
555    fn subscribe(&mut self, method: &str, input: UiValue) -> Result<SubscriptionWork, String> {
556        let (name, values) = match (method, input) {
557            ("stream", UiValue::Null) => ("gpui-rhai-gallery-stream", vec![1, 2, 3, 4]),
558            ("stream", _) => return Err("stream expects null input".to_owned()),
559            _ => return Err(format!("unknown operations subscription `{method}`")),
560        };
561        let (sender, receiver) = std::sync::mpsc::channel();
562        std::thread::Builder::new()
563            .name(name.to_owned())
564            .spawn(move || {
565                for value in values {
566                    std::thread::sleep(Duration::from_millis(45));
567                    if sender.send(UiValue::Integer(value)).is_err() {
568                        break;
569                    }
570                }
571            })
572            .map_err(|error| error.to_string())?;
573        Ok(SubscriptionWork::from_receiver(receiver))
574    }
575}
576
577#[derive(Clone, Debug)]
578struct ChartCatalogFixture {
579    stream: NativeChartData,
580}
581
582impl ScriptViewExtension for ChartCatalogFixture {
583    fn configure_engine(&self, engine: &mut RuntimeEngine) -> Result<(), String> {
584        let map = ChartGeoMap::from_geojson(
585            "demo_map",
586            r#"{"type":"FeatureCollection","features":[
587              {"type":"Feature","id":"north","properties":{"name":"north"},"geometry":{"type":"Polygon","coordinates":[[[0,5],[10,5],[10,10],[0,10],[0,5]]]}},
588              {"type":"Feature","id":"south","properties":{"name":"south"},"geometry":{"type":"Polygon","coordinates":[[[0,0],[10,0],[10,5],[0,5],[0,0]]]}}
589            ]}"#,
590        )
591        .map_err(|error| error.to_string())?;
592        engine
593            .register_chart_map(map)
594            .map_err(|error| error.to_string())
595    }
596
597    fn configure_runtime(&self, runtime: &mut UiRuntimeState) -> Result<(), String> {
598        runtime
599            .register_native_chart_data("gallery_stream", self.stream.clone())
600            .map_err(|error| error.to_string())
601    }
602}
603
604fn operations_host_row(
605    host: impl Into<String>,
606    region: impl Into<String>,
607    version: impl Into<String>,
608    status: impl Into<String>,
609    variant: impl Into<String>,
610) -> BTreeMap<String, UiValue> {
611    let host = host.into();
612    BTreeMap::from([
613        ("id".to_owned(), UiValue::String(host.clone())),
614        ("host".to_owned(), UiValue::String(host)),
615        ("region".to_owned(), UiValue::String(region.into())),
616        ("version".to_owned(), UiValue::String(version.into())),
617        ("status".to_owned(), UiValue::String(status.into())),
618        ("status_variant".to_owned(), UiValue::String(variant.into())),
619    ])
620}
621
622fn operations_hosts(fixture_case: &str) -> Result<NativeCollection, String> {
623    let rows = if matches!(fixture_case, "loading" | "empty") {
624        Vec::new()
625    } else if fixture_case == "large" {
626        let regions = ["Tokyo", "Berlin", "Virginia", "Singapore"];
627        (1..=1_000)
628            .map(|index| {
629                let drift = index % 97 == 0;
630                operations_host_row(
631                    format!("edge-{index:02}"),
632                    regions[(index - 1) % regions.len()],
633                    if drift { "1.7.9" } else { "1.8.4" },
634                    if drift { "Drift" } else { "Healthy" },
635                    if drift { "warning" } else { "success" },
636                )
637            })
638            .collect()
639    } else {
640        let mut rows = vec![
641            operations_host_row("edge-01", "Tokyo", "1.8.4", "Healthy", "success"),
642            operations_host_row("edge-02", "Berlin", "1.8.3", "Deploying", "accent"),
643            operations_host_row("edge-03", "Virginia", "1.7.9", "Drift", "warning"),
644        ];
645        if fixture_case == "failure" {
646            rows.push(operations_host_row(
647                "edge-04",
648                "Singapore",
649                "unknown",
650                "Unreachable",
651                "danger",
652            ));
653        }
654        rows
655    };
656    NativeCollection::new("id", rows).map_err(|error| error.to_string())
657}
658
659fn operations_metrics(fixture_case: &str) -> Result<NativeChartData, String> {
660    let count = match fixture_case {
661        "streaming" => 24,
662        "large" => 120,
663        _ => 5,
664    };
665    let rows = (0..count)
666        .map(|index| {
667            let minute = format!("{:02}:{:02}", 9 + index / 12, (index % 12) * 5);
668            let latency = 36 + i64::from((index * 7) % 17);
669            BTreeMap::from([
670                ("id".to_owned(), UiValue::String(format!("m{}", index + 1))),
671                ("minute".to_owned(), UiValue::String(minute)),
672                ("latency".to_owned(), UiValue::Integer(latency)),
673            ])
674        })
675        .collect::<Vec<_>>();
676    let dataset = ChartDataset::from_rows(
677        "main",
678        &rows,
679        Some("id".to_owned()),
680        ChartDataLimits::default(),
681    )
682    .map_err(|error| error.to_string())?;
683    NativeChartData::new([dataset], ChartDataLimits::default()).map_err(|error| error.to_string())
684}
685
686fn operations_events(fixture_case: &str) -> Result<NativeCollection, String> {
687    let rows = if matches!(fixture_case, "loading" | "empty") {
688        Vec::new()
689    } else {
690        let mut rows = vec![
691            ("e1", "09:20", "Configuration verified", "Info", "neutral"),
692            (
693                "e2",
694                "09:18",
695                "edge-02 deployment started",
696                "Active",
697                "accent",
698            ),
699            (
700                "e3",
701                "09:12",
702                "edge-03 drift detected",
703                "Warning",
704                "warning",
705            ),
706        ];
707        if fixture_case == "failure" {
708            rows.push(("e4", "09:22", "edge-04 is unreachable", "Failed", "danger"));
709        }
710        if fixture_case == "streaming" {
711            rows.push((
712                "e5",
713                "09:25",
714                "Metric stream revision advanced",
715                "Live",
716                "success",
717            ));
718        }
719        rows.into_iter()
720            .map(|(id, time, event, severity, variant)| {
721                BTreeMap::from([
722                    ("id".to_owned(), UiValue::String(id.to_owned())),
723                    ("time".to_owned(), UiValue::String(time.to_owned())),
724                    ("event".to_owned(), UiValue::String(event.to_owned())),
725                    ("severity".to_owned(), UiValue::String(severity.to_owned())),
726                    (
727                        "severity_variant".to_owned(),
728                        UiValue::String(variant.to_owned()),
729                    ),
730                ])
731            })
732            .collect()
733    };
734    NativeCollection::new("id", rows).map_err(|error| error.to_string())
735}
736
737#[allow(clippy::cast_precision_loss)]
738fn chart_catalog_stream(rows: usize) -> Result<NativeChartData, String> {
739    let values = (0..rows)
740        .map(|index| {
741            let x = index as f64;
742            BTreeMap::from([
743                ("id".to_owned(), UiValue::String(format!("p{index}"))),
744                ("x".to_owned(), UiValue::Float(x)),
745                (
746                    "y".to_owned(),
747                    UiValue::Float((x / 270.0).sin() * 20.0 + (x / 67.0).cos() * 4.0),
748                ),
749            ])
750        })
751        .collect::<Vec<_>>();
752    let dataset = ChartDataset::from_rows(
753        "main",
754        &values,
755        Some("id".to_owned()),
756        ChartDataLimits::default(),
757    )
758    .map_err(|error| error.to_string())?;
759    NativeChartData::new([dataset], ChartDataLimits::default()).map_err(|error| error.to_string())
760}
761
762fn build_view(
763    launch: &GalleryLaunch,
764    chart_stream: Option<NativeChartData>,
765    host_slots: Option<HostSlotRegistry>,
766) -> Result<EmbeddedScriptView, String> {
767    let story = resolve_story(&launch.story, &launch.case)?;
768    let (theme_name, primary_theme) = theme_source(&launch.theme)?;
769    let locale_sources = locale_sources(&launch.locale)?;
770    let engine = RuntimeEngine::new();
771    let selected = load_theme_source(engine.engine(), theme_name, primary_theme)
772        .map_err(|error| error.to_string())?;
773    let story_source = materialize_story_source(story, launch, &selected)?;
774    let source = if story.fixture == Some("component-catalog") {
775        story_source
776    } else {
777        format!("{}\n{}\n", story_source, story_init_source(&launch.locale)?)
778    };
779    let entry = ModuleId::parse(story.source_module).map_err(|error| error.to_string())?;
780    let mut view =
781        EmbeddedScriptView::new(entry.clone(), story_scripts(story, source)?, primary_theme)
782            .theme_sources(
783                BUNDLED_THEME_SOURCES
784                    .iter()
785                    .filter(|(name, _)| *name != theme_name)
786                    .map(|(name, source)| ((*name).to_owned(), (*source).to_owned())),
787            )
788            .locale_sources(
789                locale_sources.map(|(name, source)| (name.to_owned(), source.to_owned())),
790            )
791            .asset_sources(BUNDLED_ASSET_SOURCES.iter().map(|(path, source)| {
792                (
793                    path.strip_suffix(".svg").unwrap_or(path).to_owned(),
794                    asset(source.as_bytes()),
795                )
796            }));
797    if story.fixture == Some("operations") {
798        let manifest = AppManifest::new(entry)
799            .with_capability("gallery.operations", "*")
800            .map_err(|error| error.to_string())?;
801        view = view.manifest(manifest).extension(OperationsFixture {
802            fixture_case: operations_fixture_case(&launch.case).to_owned(),
803        });
804    } else if story.fixture == Some("chart-catalog") {
805        view = view.extension(ChartCatalogFixture {
806            stream: match chart_stream {
807                Some(stream) => stream,
808                None => chart_catalog_stream(4_096)?,
809            },
810        });
811    } else if story.fixture == Some("host-embedding") {
812        let slots = match host_slots {
813            Some(slots) => slots,
814            None => HostSlotRegistry::new()
815                .with_slot("resident-form", |_, _| {
816                    Ok(gpui_rhai::gpui::div()
817                        .size_full()
818                        .p_4()
819                        .child("Rust Host supplies the resident script view in the Gallery shell")
820                        .into_any_element())
821                })
822                .map_err(|error| error.to_string())?,
823        };
824        view = view.extension(slots);
825    }
826    if story.id == "apps/operations" && launch.case == "theme-overrides" {
827        view = view.theme_token_overrides(ThemeTokenOverrides {
828            radii: BTreeMap::from([
829                ("sm".to_owned(), gpui_rhai::Length::Pixels(4.0)),
830                ("md".to_owned(), gpui_rhai::Length::Pixels(7.0)),
831                ("lg".to_owned(), gpui_rhai::Length::Pixels(10.0)),
832            ]),
833            ..ThemeTokenOverrides::default()
834        });
835    }
836    Ok(view)
837}
838
839/// Build one exact bundled story without opening a window.
840///
841/// # Errors
842///
843/// Returns a catalog, theme, locale, source, or runtime assembly error.
844pub fn view(launch: &GalleryLaunch) -> Result<EmbeddedScriptView, String> {
845    build_view(launch, None, None)
846}
847
848/// Build a Gallery story with a caller-supplied HostSlot registry.
849#[doc(hidden)]
850pub fn view_with_host_slots(
851    launch: &GalleryLaunch,
852    slots: HostSlotRegistry,
853) -> Result<EmbeddedScriptView, String> {
854    build_view(launch, None, Some(slots))
855}
856
857/// Build the resident form used by the HostSlot acceptance story.
858#[doc(hidden)]
859pub fn host_resident_view(launch: &GalleryLaunch) -> Result<EmbeddedScriptView, String> {
860    let (theme_name, primary_theme) = theme_source(&launch.theme)?;
861    let source = format!(
862        "{}\n{}\n",
863        HOST_RESIDENT_STORY_SOURCE,
864        story_init_source(&launch.locale)?
865    );
866    let entry = ModuleId::parse("stories/apps/host_resident").map_err(|error| error.to_string())?;
867    let mut modules =
868        bundled_dependency_modules(&source, &["components/input", "components/textarea"])?;
869    modules.insert(entry.clone(), source);
870    Ok(
871        EmbeddedScriptView::new(entry, EmbeddedScriptSource::new(modules), primary_theme)
872            .theme_sources(
873                BUNDLED_THEME_SOURCES
874                    .iter()
875                    .filter(|(name, _)| *name != theme_name)
876                    .map(|(name, source)| ((*name).to_owned(), (*source).to_owned())),
877            )
878            .locale_sources(
879                locale_sources(&launch.locale)?
880                    .map(|(name, source)| (name.to_owned(), source.to_owned())),
881            )
882            .asset_sources(BUNDLED_ASSET_SOURCES.iter().map(|(path, source)| {
883                (
884                    path.strip_suffix(".svg").unwrap_or(path).to_owned(),
885                    asset(source.as_bytes()),
886                )
887            })),
888    )
889}
890
891/// Build the source-identical streaming Chart story with benchmark-owned data.
892///
893/// This is public for the independent performance workspace; applications
894/// should use [`view`] or [`prepare`] instead.
895///
896/// # Errors
897///
898/// Returns a catalog, theme, locale, source, or runtime assembly error.
899#[doc(hidden)]
900pub fn chart_streaming_view_with_data(
901    stream: NativeChartData,
902) -> Result<EmbeddedScriptView, String> {
903    build_view(
904        &GalleryLaunch {
905            story: "charts/streaming".to_owned(),
906            ..GalleryLaunch::default()
907        },
908        Some(stream),
909        None,
910    )
911}
912
913/// Prepare one exact bundled story without opening a window.
914///
915/// # Errors
916///
917/// Returns a catalog, theme, locale, source, or runtime preparation error.
918pub fn prepare(launch: &GalleryLaunch) -> Result<PreparedScriptView, String> {
919    view(launch)?.prepare().map_err(|error| error.to_string())
920}
921
922/// Run one exact bundled story in a standalone GPUI window.
923///
924/// # Errors
925///
926/// Returns a preparation or platform application error.
927pub fn run(launch: &GalleryLaunch) -> Result<(), String> {
928    // Platform application callbacks cannot unwind safely. Validate every
929    // user-controlled launch selector and compile the exact story before
930    // entering GPUI, so CLI mistakes return a normal non-zero error instead
931    // of reaching GalleryApp::new and aborting inside did_finish_launching.
932    let _ = prepare(launch)?;
933    super::gallery_app::run(launch.clone())
934}
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939
940    #[test]
941    fn list_is_deterministic_and_does_not_prepare_a_window() {
942        assert_eq!(list_text().lines().count(), BUNDLED_STORIES.len());
943        assert!(list_text().starts_with(
944            "components/catalog\tcomponents\tbasic,forms,navigation,documents,overlays\t"
945        ));
946    }
947
948    #[test]
949    fn every_bundled_story_case_prepares() {
950        for story in BUNDLED_STORIES {
951            for case in story.cases {
952                prepare(&GalleryLaunch {
953                    story: story.id.to_owned(),
954                    case: case.id.to_owned(),
955                    ..GalleryLaunch::default()
956                })
957                .unwrap_or_else(|error| panic!("{} / {}: {error}", story.id, case.id));
958            }
959        }
960    }
961
962    #[test]
963    fn locale_matrix_prepares_editing_documents_and_workbench() {
964        for locale in ["en", "zh-CN", "ar"] {
965            for (story, case) in [
966                ("components/input", "basic"),
967                ("components/catalog", "documents"),
968                ("apps/operations", "empty"),
969            ] {
970                prepare(&GalleryLaunch {
971                    story: story.to_owned(),
972                    case: case.to_owned(),
973                    locale: locale.to_owned(),
974                    ..GalleryLaunch::default()
975                })
976                .unwrap_or_else(|error| panic!("{locale} / {story} / {case}: {error}"));
977            }
978        }
979    }
980
981    #[test]
982    fn invalid_story_case_theme_and_locale_are_explicit() {
983        let launch = GalleryLaunch {
984            story: "missing".to_owned(),
985            ..GalleryLaunch::default()
986        };
987        let Err(error) = prepare(&launch) else {
988            panic!("missing story must fail")
989        };
990        assert!(error.contains("unknown Gallery story"));
991        let launch = GalleryLaunch {
992            case: "missing".to_owned(),
993            ..GalleryLaunch::default()
994        };
995        let Err(error) = prepare(&launch) else {
996            panic!("missing case must fail")
997        };
998        assert!(error.contains("unknown case"));
999        let launch = GalleryLaunch {
1000            theme: "missing".to_owned(),
1001            ..GalleryLaunch::default()
1002        };
1003        let Err(error) = prepare(&launch) else {
1004            panic!("missing theme must fail")
1005        };
1006        assert!(error.contains("unknown Gallery theme"));
1007        let launch = GalleryLaunch {
1008            locale: "missing".to_owned(),
1009            ..GalleryLaunch::default()
1010        };
1011        let Err(error) = prepare(&launch) else {
1012            panic!("missing locale must fail")
1013        };
1014        assert!(error.contains("unknown Gallery locale"));
1015        assert_eq!(
1016            parse_motion_preference("reduced").unwrap(),
1017            gpui_rhai::MotionPreference::Reduced
1018        );
1019        assert!(parse_motion_preference("missing").is_err());
1020    }
1021
1022    #[test]
1023    fn invalid_run_is_rejected_before_platform_startup() {
1024        let error = run(&GalleryLaunch {
1025            story: "missing/story".to_owned(),
1026            ..GalleryLaunch::default()
1027        })
1028        .unwrap_err();
1029        assert!(error.contains("unknown Gallery story `missing/story`"));
1030
1031        let error = run(&GalleryLaunch {
1032            case: "missing-case".to_owned(),
1033            ..GalleryLaunch::default()
1034        })
1035        .unwrap_err();
1036        assert!(error.contains("unknown case `missing-case`"));
1037    }
1038}