Skip to main content

ravenrs_widgets/
playground.rs

1//! A place to write code and see what it makes: a [`CodeEditor`] beside the result,
2//! examples to start from, and the problems the code has, marked where they are.
3
4use 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
21/// Below this width the code and the result take turns, as two tabs.
22const TABS_BREAKPOINT: f32 = 768.0;
23const CODE_MIN_WIDTH: f32 = 320.0;
24const RESULT_MIN_WIDTH: f32 = 320.0;
25/// The share of the width the result starts with; the code, with its longer lines, gets the rest.
26const RESULT_SHARE: f32 = 0.4;
27
28/// What turns the code of a [`Playground`] into something to show.
29///
30/// It runs when the person asks, never on every frame, so it may take its time and
31/// build whatever it needs anew.
32pub trait Runner {
33    /// The view `source` makes, or where and why it cannot make one.
34    fn run(&self, source: &str) -> Result<AnyView, Vec<CodeDiagnostic>>;
35
36    /// What the code printed, and the problems of what it runs later, such as a closure on a
37    /// click, since the last time this was asked. Nothing, unless the runner keeps them.
38    fn take_output(&self) -> RunOutput {
39        RunOutput::default()
40    }
41
42    /// Calls `wake` whenever there is something new for [`Self::take_output`], so the
43    /// playground shows it as it comes. Nothing, unless the runner can tell.
44    fn watch(&self, wake: Rc<dyn Fn()>) {
45        let _ = wake;
46    }
47}
48
49/// What a [`Runner`] heard from the code since it was last asked.
50#[derive(Clone, Debug, Default, PartialEq)]
51#[non_exhaustive]
52#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
53pub struct RunOutput {
54    /// Lines it printed, in order.
55    pub lines: Vec<String>,
56    /// Problems of what it ran after the run itself, with their places.
57    pub problems: Vec<CodeDiagnostic>,
58}
59
60#[cfg_attr(feature = "live", ravenrs_live::expose)]
61impl RunOutput {
62    /// Output with these lines and problems.
63    pub fn new(lines: Vec<String>, problems: Vec<CodeDiagnostic>) -> Self {
64        Self { lines, problems }
65    }
66
67    /// Whether there is nothing in it.
68    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/// A view kept between builds, shown again until the next run.
80#[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
89/// A button the app adds to the bar, which gets the code as it is.
90type Extra = (String, IconName, Rc<dyn Fn(&str)>);
91type Download = Rc<dyn Fn(&str) -> Result<(String, Vec<u8>), Vec<CodeDiagnostic>>>;
92/// An extra button with the code already bound to it.
93type Bound = (String, IconName, Rc<dyn Fn()>);
94
95/// A place to write code and see what it makes, for documentation, tutorials and tools.
96///
97/// The code is on one side and the result on the other; below 768 pixels they become two
98/// tabs. Run (or Ctrl/Cmd+Enter) hands the code to the [`Runner`]. When it has problems,
99/// they are listed under the editor and marked on their lines, and the last result that
100/// worked stays, labeled as such. Examples fill the editor, Restore puts the chosen example
101/// back, and the app can add buttons that receive the code, such as one that copies a link.
102///
103/// ```
104/// use ravenrs_widgets::{AnyView, CodeDiagnostic, Playground, Text, Ui};
105///
106/// let runner = |source: &str| -> Result<AnyView, Vec<CodeDiagnostic>> {
107///     match source.trim().strip_prefix("say ") {
108///         Some(words) => Ok(Box::new(Text::new(words.to_owned()))),
109///         None => Err(vec![CodeDiagnostic::error(1, 1, 3, "Start with `say`")]),
110///     }
111/// };
112/// let playground = Playground::new(runner).example("Hello", "say hello").height(400.0);
113/// let mut ui = Ui::new(playground, ravenrs_style::Theme::raven_dark());
114/// ui.update();
115/// ```
116#[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    /// A playground whose code `runner` runs.
140    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    /// An example to start from, listed in the bar; the first one fills the editor.
157    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    /// Keeps the code in `source`, so the app can save it, share it or start from it.
163    /// While it is empty, or without it, the playground starts from the first example.
164    pub fn source(mut self, source: Binding<String>) -> Self {
165        self.source = Some(source);
166        self
167    }
168
169    /// Keeps which example is chosen in `example`.
170    pub fn chosen_example(mut self, example: Binding<usize>) -> Self {
171        self.example = Some(example);
172        self
173    }
174
175    /// The language the editor colors; Rust by default.
176    pub fn language(mut self, language: CodeLanguage) -> Self {
177        self.language = language;
178        self
179    }
180
181    /// A title at the start of the bar.
182    pub fn title(mut self, title: impl Into<String>) -> Self {
183        self.title = Some(title.into());
184        self
185    }
186
187    /// A line under the bar, such as what this playground can and cannot run yet.
188    pub fn note(mut self, note: impl Into<String>) -> Self {
189        self.note = Some(note.into());
190        self
191    }
192
193    /// A button in the bar that gets the code as it is.
194    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    /// A button that saves what `make` turns the code into, such as a project to build, with
200    /// the system's dialog on a desktop or as a download in a browser. `make` gives the file's
201    /// name and bytes, or the problems to mark in the code.
202    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    /// Whether the output starts open (it does by default). Folded, the result keeps the
211    /// room, as on a small page about one example; a problem opens it all the same.
212    pub fn output_open(mut self, open: bool) -> Self {
213        self.output_open = open;
214        self
215    }
216
217    /// A fixed height; by default it takes the room its parent gives.
218    pub fn height(mut self, height: f32) -> Self {
219        self.height = Some(height);
220        self
221    }
222}
223
224/// What the code said since the last run: the lines it printed and the problems of what ran
225/// later. Kept apart from the reactive state, so reading it while building changes nothing.
226#[derive(Default)]
227struct Log {
228    lines: Vec<String>,
229    problems: Vec<CodeDiagnostic>,
230}
231
232/// The most lines the console keeps, the oldest going first.
233const 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        // 0 code, 1 result, 2 output: the tabs of a narrow playground.
258        let tab = cx.use_state(|| 0usize);
259        // 0 console, 1 problems.
260        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        // Bumped on each request, so the bar redraws even where the screen does not resize.
267        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                // What earlier runs left is not this run's.
278                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            // What the code prints, or gets wrong later, shows as it comes.
305            let wake = heard.clone();
306            self.runner.watch(Rc::new(move || wake.update(|n| *n += 1)));
307            // An empty kept code starts from the first example too.
308            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        // Read so that what is heard builds the playground again; then taken in.
317        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        // The examples: picking one puts its code in and runs it.
330        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        // Full screen, where the platform can give it.
379        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        // The bar. Wide, every action is a button; narrow, the ones besides Run go in a menu.
405        let mut bar: Vec<AnyView> = Vec::new();
406        // Narrow, the examples take the title's place.
407        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        // How the code stands, under the editor and in the narrow footer.
492        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        // The result: the last one that worked, faded and labeled when the code has changed since.
504        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        // The output: what the code printed, and its problems with their places.
538        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        // The stack fills its box, so the body and the narrow footer reach its bottom.
692        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
700/// The height of the open output panel.
701const 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}