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
64pub 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
839pub fn view(launch: &GalleryLaunch) -> Result<EmbeddedScriptView, String> {
845 build_view(launch, None, None)
846}
847
848#[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#[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#[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
913pub fn prepare(launch: &GalleryLaunch) -> Result<PreparedScriptView, String> {
919 view(launch)?.prepare().map_err(|error| error.to_string())
920}
921
922pub fn run(launch: &GalleryLaunch) -> Result<(), String> {
928 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}