1use std::cell::RefCell;
5use std::fmt;
6use std::rc::Rc;
7
8use ravenrs_core::Key;
9use ravenrs_layout::Align;
10use ravenrs_reactive::{Binding, State};
11use ravenrs_style::{Semantic, TextStyle};
12
13use crate::code_editor::{CodeDiagnostic, CodeEditor, CodeLanguage};
14use crate::context::{BuildCx, NodeId};
15use crate::{
16 Alert, AnyView, Badge, Button, ButtonStyle, Divider, Dot, DropdownMenu, HStack, IconButton, IconName,
17 Keyed, MenuItem, ScrollView, SegmentedControl, Select, Spacer, SplitPane, SplitPanes, Text, Tone, VStack,
18 View, ViewExt,
19};
20
21const TABS_BREAKPOINT: f32 = 768.0;
23const CODE_MIN_WIDTH: f32 = 320.0;
24const RESULT_MIN_WIDTH: f32 = 320.0;
25const RESULT_SHARE: f32 = 0.4;
27
28pub trait Runner {
33 fn run(&self, source: &str) -> Result<AnyView, Vec<CodeDiagnostic>>;
35
36 fn take_output(&self) -> RunOutput {
39 RunOutput::default()
40 }
41
42 fn watch(&self, wake: Rc<dyn Fn()>) {
45 let _ = wake;
46 }
47}
48
49#[derive(Clone, Debug, Default, PartialEq)]
51#[non_exhaustive]
52#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
53pub struct RunOutput {
54 pub lines: Vec<String>,
56 pub problems: Vec<CodeDiagnostic>,
58}
59
60#[cfg_attr(feature = "live", ravenrs_live::expose)]
61impl RunOutput {
62 pub fn new(lines: Vec<String>, problems: Vec<CodeDiagnostic>) -> Self {
64 Self { lines, problems }
65 }
66
67 pub fn is_empty(&self) -> bool {
69 self.lines.is_empty() && self.problems.is_empty()
70 }
71}
72
73impl<F: Fn(&str) -> Result<AnyView, Vec<CodeDiagnostic>>> Runner for F {
74 fn run(&self, source: &str) -> Result<AnyView, Vec<CodeDiagnostic>> {
75 self(source)
76 }
77}
78
79#[derive(Clone)]
81struct Shown(Rc<dyn View>);
82
83impl View for Shown {
84 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
85 self.0.build(cx)
86 }
87}
88
89type Extra = (String, IconName, Rc<dyn Fn(&str)>);
91type Download = Rc<dyn Fn(&str) -> Result<(String, Vec<u8>), Vec<CodeDiagnostic>>>;
92type Bound = (String, IconName, Rc<dyn Fn()>);
94
95#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
117pub struct Playground {
118 runner: Rc<dyn Runner>,
119 examples: Vec<(String, String)>,
120 source: Option<Binding<String>>,
121 example: Option<Binding<usize>>,
122 language: CodeLanguage,
123 title: Option<String>,
124 note: Option<String>,
125 extras: Vec<Extra>,
126 download: Option<Download>,
127 height: Option<f32>,
128 output_open: bool,
129}
130
131impl fmt::Debug for Playground {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 f.debug_struct("Playground").field("examples", &self.examples.len()).finish_non_exhaustive()
134 }
135}
136
137#[cfg_attr(feature = "live", ravenrs_live::expose)]
138impl Playground {
139 pub fn new(runner: impl Runner + 'static) -> Self {
141 Self {
142 runner: Rc::new(runner),
143 examples: Vec::new(),
144 source: None,
145 example: None,
146 language: CodeLanguage::Rust,
147 title: None,
148 note: None,
149 extras: Vec::new(),
150 download: None,
151 height: None,
152 output_open: true,
153 }
154 }
155
156 pub fn example(mut self, name: impl Into<String>, source: impl Into<String>) -> Self {
158 self.examples.push((name.into(), source.into()));
159 self
160 }
161
162 pub fn source(mut self, source: Binding<String>) -> Self {
165 self.source = Some(source);
166 self
167 }
168
169 pub fn chosen_example(mut self, example: Binding<usize>) -> Self {
171 self.example = Some(example);
172 self
173 }
174
175 pub fn language(mut self, language: CodeLanguage) -> Self {
177 self.language = language;
178 self
179 }
180
181 pub fn title(mut self, title: impl Into<String>) -> Self {
183 self.title = Some(title.into());
184 self
185 }
186
187 pub fn note(mut self, note: impl Into<String>) -> Self {
189 self.note = Some(note.into());
190 self
191 }
192
193 pub fn action(mut self, label: impl Into<String>, icon: IconName, then: impl Fn(&str) + 'static) -> Self {
195 self.extras.push((label.into(), icon, Rc::new(then)));
196 self
197 }
198
199 pub fn download(
203 mut self,
204 make: impl Fn(&str) -> Result<(String, Vec<u8>), Vec<CodeDiagnostic>> + 'static,
205 ) -> Self {
206 self.download = Some(Rc::new(make));
207 self
208 }
209
210 pub fn output_open(mut self, open: bool) -> Self {
213 self.output_open = open;
214 self
215 }
216
217 pub fn height(mut self, height: f32) -> Self {
219 self.height = Some(height);
220 self
221 }
222}
223
224#[derive(Default)]
227struct Log {
228 lines: Vec<String>,
229 problems: Vec<CodeDiagnostic>,
230}
231
232const MOST_LINES: usize = 500;
234
235#[cfg_attr(feature = "live", ravenrs_live::expose)]
236impl Log {
237 fn add(&mut self, output: RunOutput) {
238 self.lines.extend(output.lines);
239 let over = self.lines.len().saturating_sub(MOST_LINES);
240 self.lines.drain(..over);
241 self.problems.extend(output.problems);
242 }
243}
244
245impl View for Playground {
246 #[allow(clippy::too_many_lines, reason = "one screen, built top to bottom")]
247 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
248 let labels = cx.labels();
249 let first = self.examples.first().map(|(_, code)| code.clone()).unwrap_or_default();
250 let own_source = cx.use_state(|| first);
251 let source = self.source.clone().unwrap_or_else(|| own_source.binding());
252 let own_example = cx.use_state(|| 0usize);
253 let example = self.example.clone().unwrap_or_else(|| own_example.binding());
254 let result: State<Option<Shown>> = cx.use_state(|| None);
255 let version: State<u64> = cx.use_state(|| 0);
256 let problems: State<Vec<CodeDiagnostic>> = cx.use_state(Vec::new);
257 let tab = cx.use_state(|| 0usize);
259 let output_tab = cx.use_state(|| 0usize);
261 let open_at_first = self.output_open;
262 let output_open = cx.use_state(move || open_at_first);
263 let log: State<Rc<RefCell<Log>>> = cx.use_state(Rc::default);
264 let heard: State<u64> = cx.use_state(|| 0);
265 let started = cx.use_state(|| false);
266 let asked_screen = cx.use_state(|| 0u32);
268 asked_screen.get();
269 let log = log.get();
270
271 let run: Rc<dyn Fn()> = {
272 let (runner, source, result, version, problems) =
273 (self.runner.clone(), source.clone(), result.clone(), version.clone(), problems.clone());
274 let (tab, output_tab, output_open, log, heard) =
275 (tab.clone(), output_tab.clone(), output_open.clone(), log.clone(), heard.clone());
276 Rc::new(move || {
277 let _ = runner.take_output();
279 *log.borrow_mut() = Log::default();
280 match runner.run(&source.get()) {
281 Ok(view) => {
282 result.set(Some(Shown(Rc::from(view))));
283 version.update(|v| *v += 1);
284 problems.set(Vec::new());
285 if tab.get() == 0 {
286 tab.set(1);
287 }
288 if output_tab.get() == 1 {
289 output_tab.set(0);
290 }
291 }
292 Err(found) => {
293 problems.set(found);
294 output_tab.set(1);
295 output_open.set(true);
296 }
297 }
298 log.borrow_mut().add(runner.take_output());
299 heard.update(|n| *n += 1);
300 })
301 };
302 if !started.get() {
303 started.set(true);
304 let wake = heard.clone();
306 self.runner.watch(Rc::new(move || wake.update(|n| *n += 1)));
307 if source.get().is_empty()
309 && let Some((_, code)) = self.examples.first()
310 {
311 source.set(code.clone());
312 }
313 run();
314 }
315 cx.shortcut(Key::Enter, true, run.clone());
316 let _ = heard.get();
318 let fresh = self.runner.take_output();
319 if !fresh.is_empty() {
320 log.borrow_mut().add(fresh);
321 }
322
323 let narrow = cx.viewport().width < TABS_BREAKPOINT;
324 let found = problems.get();
325 let late = log.borrow().problems.clone();
326 let printed = log.borrow().lines.clone();
327 let all_problems: Vec<CodeDiagnostic> = found.iter().chain(late.iter()).cloned().collect();
328
329 let picker: Option<AnyView> = (self.examples.len() > 1).then(|| {
331 let (examples, chosen, fill, go) =
332 (self.examples.clone(), example.clone(), source.clone(), run.clone());
333 let read = example.clone();
334 let pick = Binding::new(
335 move || read.get(),
336 move |i: usize| {
337 chosen.set(i);
338 if let Some((_, code)) = examples.get(i) {
339 fill.set(code.clone());
340 go();
341 }
342 },
343 );
344 Box::new(
345 Select::new(pick, self.examples.iter().map(|(name, _)| name.clone()))
346 .label(labels.playground_example.clone()),
347 ) as AnyView
348 });
349 let restore: Option<Rc<dyn Fn()>> = (!self.examples.is_empty()).then(|| {
350 let (examples, chosen, fill) = (self.examples.clone(), example.clone(), source.clone());
351 Rc::new(move || {
352 if let Some((_, code)) = examples.get(chosen.get()) {
353 fill.set(code.clone());
354 }
355 }) as Rc<dyn Fn()>
356 });
357 let download: Option<Rc<dyn Fn()>> = self.download.as_ref().map(|make| {
358 let (make, code, problems, picker) =
359 (make.clone(), source.clone(), problems.clone(), cx.file_picker());
360 let (output_tab, output_open) = (output_tab.clone(), output_open.clone());
361 Rc::new(move || match make(&code.get()) {
362 Ok((name, bytes)) => picker.save(&name, &bytes),
363 Err(found) => {
364 problems.set(found);
365 output_tab.set(1);
366 output_open.set(true);
367 }
368 }) as Rc<dyn Fn()>
369 });
370 let extras: Vec<Bound> = self
371 .extras
372 .iter()
373 .map(|(label, icon, then)| {
374 let (then, code) = (then.clone(), source.clone());
375 (label.clone(), *icon, Rc::new(move || then(&code.get())) as Rc<dyn Fn()>)
376 })
377 .collect();
378 let screen = cx.full_screen();
380 let toggle_screen: Option<Bound> = screen.available().then(|| {
381 let (icon, label) = if screen.is_on() {
382 (IconName::Minimize2, labels.playground_exit_full_screen.clone())
383 } else {
384 (IconName::Maximize2, labels.playground_full_screen.clone())
385 };
386 let asked = asked_screen.clone();
387 (
388 label,
389 icon,
390 Rc::new(move || {
391 screen.toggle();
392 asked.update(|n| *n = n.wrapping_add(1));
393 }) as Rc<dyn Fn()>,
394 )
395 });
396 let run_button = || {
397 let go = run.clone();
398 Button::new(labels.playground_run.clone())
399 .icon(IconName::Play)
400 .shortcut(if cfg!(target_os = "macos") { "⌘↵" } else { "Ctrl+↵" })
401 .on_click(move || go())
402 };
403
404 let mut bar: Vec<AnyView> = Vec::new();
406 if let Some(title) = self.title.as_ref().filter(|_| !narrow || self.examples.len() < 2) {
408 bar.push(Box::new(Text::new(title.clone()).style(TextStyle::Headline)));
409 }
410 if narrow {
411 if let Some(picker) = picker {
412 bar.push(Box::new(VStack::new((picker,)).align(Align::Stretch).grow(1.0)));
413 } else {
414 bar.push(Box::new(Spacer::new()));
415 }
416 let mut menu = DropdownMenu::new(labels.playground_more.clone())
417 .icon(IconName::Ellipsis)
418 .icon_only()
419 .style(ButtonStyle::Ghost);
420 for (label, icon, then) in &extras {
421 let then = then.clone();
422 menu = menu.item(MenuItem::new(label.clone()).icon(*icon).on_select(move || then()));
423 }
424 if let Some(save) = &download {
425 let save = save.clone();
426 menu = menu.item(
427 MenuItem::new(labels.playground_download.clone())
428 .icon(IconName::Download)
429 .on_select(move || save()),
430 );
431 }
432 if let Some(restore) = &restore {
433 let restore = restore.clone();
434 menu = menu.item(
435 MenuItem::new(labels.playground_restore.clone())
436 .icon(IconName::RotateCcw)
437 .on_select(move || restore()),
438 );
439 }
440 if let Some((label, icon, toggle)) = &toggle_screen {
441 let toggle = toggle.clone();
442 menu = menu.item(MenuItem::new(label.clone()).icon(*icon).on_select(move || toggle()));
443 }
444 bar.push(Box::new(menu));
445 } else {
446 if let Some(picker) = picker {
447 bar.push(picker);
448 }
449 bar.push(Box::new(Spacer::new()));
450 for (label, icon, then) in &extras {
451 let then = then.clone();
452 bar.push(Box::new(
453 Button::new(label.clone()).icon(*icon).style(ButtonStyle::Ghost).on_click(move || then()),
454 ));
455 }
456 if let Some(restore) = &restore {
457 let restore = restore.clone();
458 bar.push(Box::new(
459 Button::new(labels.playground_restore.clone())
460 .icon(IconName::RotateCcw)
461 .style(ButtonStyle::Ghost)
462 .on_click(move || restore()),
463 ));
464 }
465 if let Some(save) = &download {
466 let save = save.clone();
467 bar.push(Box::new(
468 Button::new(labels.playground_download.clone())
469 .icon(IconName::Download)
470 .style(ButtonStyle::Ghost)
471 .on_click(move || save()),
472 ));
473 }
474 if let Some((label, icon, toggle)) = &toggle_screen {
475 let toggle = toggle.clone();
476 bar.push(Box::new(
477 IconButton::new(*icon)
478 .label(label.clone())
479 .style(ButtonStyle::Ghost)
480 .on_click(move || toggle()),
481 ));
482 }
483 bar.push(Box::new(run_button()));
484 }
485 let bar = HStack::new(bar)
486 .spacing(8.0)
487 .align(Align::Center)
488 .padding((8.0, 12.0))
489 .background(Semantic::Surface);
490
491 let status = status_line(&labels, all_problems.len());
493
494 let code = VStack::new((
495 CodeEditor::new(source.clone()).language(self.language).diagnostics(found.clone()).fill(),
496 HStack::new((status,)).padding((6.0, 12.0)),
497 ))
498 .align(Align::Stretch)
499 .grow(1.0)
500 .background(Semantic::Surface)
501 .grow(1.0);
502
503 let shown = result.get();
505 let mut head: Vec<AnyView> = vec![Box::new(
506 Text::new(labels.playground_result.to_uppercase())
507 .style(TextStyle::Caption)
508 .foreground(Semantic::InkMuted),
509 )];
510 if shown.is_some() && !found.is_empty() {
511 head.push(Box::new(Badge::new(labels.playground_last_good.clone()).tone(Tone::Warning)));
512 }
513 let outcome: AnyView = match shown {
514 Some(view) => {
515 let view = ScrollView::new((Keyed::new(version.get(), view),)).grow(1.0);
516 if found.is_empty() { Box::new(view) } else { Box::new(view.opacity(0.45)) }
517 }
518 None => Box::new(Spacer::new()),
519 };
520 let radius = cx.theme().radii.lg;
521 let outcome = VStack::new((
522 HStack::new(head).spacing(8.0).align(Align::Center),
523 VStack::new((outcome,))
524 .align(Align::Stretch)
525 .grow(1.0)
526 .padding(16.0)
527 .background(Semantic::SurfaceElevated)
528 .corner_radius(radius)
529 .grow(1.0),
530 ))
531 .spacing(8.0)
532 .align(Align::Stretch)
533 .grow(1.0)
534 .padding(12.0)
535 .grow(1.0);
536
537 let console: AnyView = if printed.is_empty() {
539 Box::new(
540 Text::new(labels.playground_nothing_printed.clone())
541 .style(TextStyle::Callout)
542 .foreground(Semantic::InkMuted),
543 )
544 } else {
545 let text = printed.join("\n");
546 let shown = Binding::new(move || text.clone(), |_: String| {});
547 Box::new(
548 CodeEditor::new(shown)
549 .language(CodeLanguage::Plain)
550 .line_numbers(false)
551 .read_only(true)
552 .fill(),
553 )
554 };
555 let problem_list: AnyView = if all_problems.is_empty() {
556 Box::new(
557 Text::new(labels.playground_no_problems.clone())
558 .style(TextStyle::Callout)
559 .foreground(Semantic::InkMuted),
560 )
561 } else {
562 let rows: Vec<AnyView> = all_problems
563 .iter()
564 .map(|problem| {
565 let place = format!(
566 "{} {}, {} {}",
567 labels.playground_line, problem.line, labels.playground_column, problem.column
568 );
569 let text = match &problem.hint {
570 Some(hint) => format!("{}. {hint}", problem.message),
571 None => problem.message.clone(),
572 };
573 let tone = if problem.tone == Tone::Warning { Tone::Warning } else { Tone::Danger };
574 Box::new(Alert::new(place, text).tone(tone)) as AnyView
575 })
576 .collect();
577 Box::new(ScrollView::new((VStack::new(rows).spacing(8.0).align(Align::Stretch),)).grow(1.0))
578 };
579 let output_tabs = SegmentedControl::new(
580 output_tab.binding(),
581 [
582 format!("{} {}", labels.playground_console, printed.len()),
583 format!("{} {}", labels.playground_problems, all_problems.len()),
584 ],
585 )
586 .label(labels.playground_output.clone());
587 let output_body: AnyView = if output_tab.get() == 0 { console } else { problem_list };
588
589 let clear = {
590 let (log, problems, heard) = (log.clone(), problems.clone(), heard.clone());
591 IconButton::new(IconName::Trash2)
592 .label(labels.playground_clear_output.clone())
593 .style(ButtonStyle::Ghost)
594 .on_click(move || {
595 *log.borrow_mut() = Log::default();
596 problems.set(Vec::new());
597 heard.update(|n| *n += 1);
598 })
599 };
600
601 let body: AnyView = if narrow {
602 let tabs = SegmentedControl::new(
603 tab.binding(),
604 [
605 labels.playground_code.clone(),
606 labels.playground_result.clone(),
607 format!("{} {}", labels.playground_output, printed.len() + all_problems.len()),
608 ],
609 );
610 let page: AnyView = match tab.get() {
611 0 => Box::new(code),
612 1 => Box::new(outcome),
613 _ => Box::new(
614 VStack::new((output_tabs, output_body))
615 .spacing(8.0)
616 .align(Align::Stretch)
617 .grow(1.0)
618 .padding(12.0)
619 .grow(1.0),
620 ),
621 };
622 let footer = HStack::new((
623 VStack::new((status_line(&labels, all_problems.len()),)).grow(1.0),
624 run_button(),
625 ))
626 .spacing(8.0)
627 .align(Align::Center)
628 .padding((8.0, 12.0))
629 .background(Semantic::Surface);
630 Box::new(
631 VStack::new((VStack::new((tabs,)).align(Align::Stretch).padding((8.0, 12.0)), page, footer))
632 .spacing(8.0)
633 .align(Align::Stretch)
634 .grow(1.0),
635 )
636 } else {
637 let open = output_open.get();
638 let toggle = {
639 let open_now = output_open.clone();
640 IconButton::new(if open { IconName::ChevronDown } else { IconName::ChevronUp })
641 .label(if open {
642 labels.playground_hide_output.clone()
643 } else {
644 labels.playground_show_output.clone()
645 })
646 .style(ButtonStyle::Ghost)
647 .on_click(move || open_now.update(|o| *o = !*o))
648 };
649 let output_head =
650 HStack::new((output_tabs, Spacer::new(), clear, toggle)).spacing(4.0).align(Align::Center);
651 let output: AnyView = if open {
652 Box::new(
653 VStack::new((output_head, output_body))
654 .spacing(8.0)
655 .align(Align::Stretch)
656 .height(OUTPUT_HEIGHT)
657 .padding(12.0)
658 .background(Semantic::Surface),
659 )
660 } else {
661 Box::new(
662 VStack::new((output_head,))
663 .align(Align::Stretch)
664 .padding((6.0, 12.0))
665 .background(Semantic::Surface),
666 )
667 };
668 let right = VStack::new((outcome, Divider::horizontal(), output)).align(Align::Stretch).grow(1.0);
669 Box::new(
670 SplitPanes::horizontal()
671 .pane(SplitPane::new(code).range(CODE_MIN_WIDTH, f32::MAX))
672 .pane(
673 SplitPane::new(right)
674 .size((cx.viewport().width * RESULT_SHARE).max(RESULT_MIN_WIDTH))
675 .range(RESULT_MIN_WIDTH, f32::MAX),
676 )
677 .grow(1.0),
678 )
679 };
680
681 let mut parts: Vec<AnyView> = vec![Box::new(bar), Box::new(Divider::horizontal())];
682 if let Some(note) = &self.note {
683 parts.push(Box::new(
684 Text::new(note.clone())
685 .style(TextStyle::Callout)
686 .foreground(Semantic::InkMuted)
687 .padding((8.0, 12.0)),
688 ));
689 }
690 parts.push(body);
691 let whole = VStack::new(parts).align(Align::Stretch).grow(1.0);
693 match self.height {
694 Some(height) => cx.child(&whole.background(Semantic::Canvas).height(height)),
695 None => cx.child(&whole.background(Semantic::Canvas).grow(1.0)),
696 }
697 }
698}
699
700const OUTPUT_HEIGHT: f32 = 200.0;
702
703fn status_line(labels: &crate::Labels, count: usize) -> AnyView {
704 if count == 0 {
705 Box::new(
706 HStack::new((
707 Dot::new().tone(Tone::Success),
708 Text::new(labels.playground_no_problems.clone()).style(TextStyle::Callout),
709 ))
710 .spacing(6.0)
711 .align(Align::Center),
712 )
713 } else {
714 Box::new(
715 HStack::new((
716 Dot::new().tone(Tone::Danger),
717 Text::new(format!("{}: {count}", labels.playground_problems)).style(TextStyle::Callout),
718 ))
719 .spacing(6.0)
720 .align(Align::Center),
721 )
722 }
723}