Skip to main content

teksilo_widgets/
input_dialog.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! InputDialog — a `QInputDialog`-style modal that prompts the user for
5//! a single string. Built on the same `present_modal` infrastructure as
6//! [`MessageBox`](crate::message_box::MessageBox), with a [`TextInput`]
7//! body between the prompt and the Ok / Cancel buttons.
8//!
9//! Use [`MessageBox`](crate::message_box::MessageBox) when the dialog
10//! conveys information without requiring data; use `InputDialog` when
11//! the modal needs to capture exactly one short string. Forms longer
12//! than a single field belong in a custom [`Dialog`](crate::dialog::Dialog).
13//!
14//! ```ignore
15//! InputDialog::new(tr!(rename_title()))
16//!     .prompt(tr!(rename_prompt()))
17//!     .default_text(current_name)
18//!     .placeholder("New name")
19//!     .on_result(|result, _ctx| {
20//!         if let Some(name) = result {
21//!             rename(name);
22//!         }
23//!     })
24//!     .present(ctx);
25//! ```
26//!
27//! ## Live validation
28//!
29//! [`validate`](InputDialog::validate) runs on every keystroke and both **disables OK**
30//! and shows its message under the field, so a value the caller cannot accept can never
31//! be submitted:
32//!
33//! ```ignore
34//! InputDialog::new(tr!(save_as_template_title()))
35//!     .validate(move |name| {
36//!         if name.trim().is_empty() {
37//!             Err(None)                                  // block, say nothing
38//!         } else if let Some(clash) = taken(name) {
39//!             Err(Some(tr!(duplicate(name = clash))))    // block, and explain
40//!         } else {
41//!             Ok(())
42//!         }
43//!     })
44//!     .on_result(|result, _| { /* only ever called with a valid value */ })
45//!     .present(ctx);
46//! ```
47//!
48//! `Err(None)` is the "not yet" case — it disables OK without printing anything, which
49//! is what an *untouched* empty field wants: shouting at someone before they have typed
50//! is noise, and the greyed button already says the dialog is not ready. A message is
51//! withheld until the field has been edited for the same reason, so a caller can return
52//! `Err(Some(..))` for the empty case without it flashing on open.
53
54use std::cell::RefCell;
55use std::rc::Rc;
56
57use teksilo_canvas::{Rect, SizeProposal};
58use teksilo_core::accessibility::AccessNodeBuilder;
59use teksilo_core::build_context::BuildContext;
60use teksilo_core::modal::{ModalCloseBehavior, ModalPresentation, ModalRequest};
61use teksilo_core::signal::Signal;
62use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
63use teksilo_core::widget_id::WidgetId;
64use teksilo_i18n::LocalizedString;
65use teksilo_tokens::TextStyleRole;
66
67use crate::button::{Button, ButtonVariant};
68use crate::dialog::ModalContainer;
69use crate::primitives::{HStack, Spacer, TextWidget, VStack};
70use crate::text_input::{TextInput, ValidationState};
71
72/// Verdict from an [`InputDialog::validate`] callback.
73///
74/// `Ok(())` accepts. `Err(None)` blocks silently; `Err(Some(msg))` blocks and shows
75/// `msg` beneath the field once it has been edited.
76pub type ValidateResult = Result<(), Option<LocalizedString>>;
77
78type ValidatorFn = Rc<dyn Fn(&str) -> ValidateResult>;
79
80/// A single-field input modal.
81pub struct InputDialog {
82    title: LocalizedString,
83    prompt: Option<LocalizedString>,
84    placeholder: Option<LocalizedString>,
85    default_text: String,
86    ok_label: Option<LocalizedString>,
87    cancel_label: Option<LocalizedString>,
88    on_result: Option<Box<dyn Fn(Option<String>, &mut EventContext)>>,
89    validate: Option<ValidatorFn>,
90}
91
92impl InputDialog {
93    /// Construct a new input dialog with the given title.
94    pub fn new(title: impl Into<LocalizedString>) -> Self {
95        let ls: LocalizedString = title.into();
96        Self {
97            title: ls,
98            prompt: None,
99            placeholder: None,
100            default_text: String::new(),
101            ok_label: None,
102            cancel_label: None,
103            on_result: None,
104            validate: None,
105        }
106    }
107
108    /// Prompt rendered above the input field. Optional but recommended.
109    pub fn prompt(mut self, text: impl Into<LocalizedString>) -> Self {
110        let ls: LocalizedString = text.into();
111        self.prompt = Some(ls);
112        self
113    }
114
115    /// Placeholder shown when the field is empty.
116    pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
117        let ls: LocalizedString = text.into();
118        self.placeholder = Some(ls);
119        self
120    }
121
122    /// Initial value pre-filled into the field.
123    pub fn default_text(mut self, text: impl Into<String>) -> Self {
124        self.default_text = text.into();
125        self
126    }
127
128    /// Override the OK button label (defaults to the framework's
129    /// translated "OK" string).
130    pub fn ok_label(mut self, label: impl Into<LocalizedString>) -> Self {
131        self.ok_label = Some(label.into());
132        self
133    }
134
135    /// Override the Cancel button label (defaults to the framework's
136    /// translated "Cancel" string).
137    pub fn cancel_label(mut self, label: impl Into<LocalizedString>) -> Self {
138        self.cancel_label = Some(label.into());
139        self
140    }
141
142    /// Result callback. Invoked exactly once when the user accepts
143    /// (`Some(value)`) or cancels (`None`).
144    pub fn on_result(mut self, f: impl Fn(Option<String>, &mut EventContext) + 'static) -> Self {
145        self.on_result = Some(Box::new(f));
146        self
147    }
148
149    /// Install a **live** validator, run on every keystroke.
150    ///
151    /// While it returns `Err`, the OK button is disabled and Enter does nothing, so
152    /// [`on_result`](Self::on_result) is only ever called with a value the validator
153    /// accepted (or with `None`, for Cancel). `Err(Some(msg))` shows `msg` under the
154    /// field; `Err(None)` blocks without saying anything.
155    ///
156    /// The message is withheld until the field has been edited, so a validator that
157    /// rejects the empty string does not greet the writer with an error on a dialog they
158    /// have not yet typed into. The disabled OK is what communicates "not yet" there.
159    ///
160    /// Distinct from [`TextInput::validator`](crate::text_input::TextInput::validator),
161    /// which fires on *commit* and cannot gate a dialog's accept path.
162    pub fn validate(mut self, f: impl Fn(&str) -> ValidateResult + 'static) -> Self {
163        self.validate = Some(Rc::new(f));
164        self
165    }
166
167    /// Present the dialog as a modal on top of `ctx`'s tree. Consumes
168    /// `self`.
169    pub fn present(self, ctx: &mut EventContext) {
170        let title = self.title.clone();
171        let dialog_title = self.title.clone();
172        let mut inner = Some(self);
173
174        // `None` is this dialog's cancellation payload, so a dismissal that
175        // reports nothing is indistinguishable from a dialog still sitting
176        // open. Escape and a press outside both close it through the overlay
177        // system and reached neither button, so the caller was told nothing —
178        // against this type's own contract, which promises the callback runs
179        // "exactly once when the user accepts or cancels". The `fired` latch
180        // makes this a no-op when a button already answered.
181        #[allow(clippy::type_complexity)]
182        let answered: Rc<
183            RefCell<
184                Option<(
185                    Rc<RefCell<Option<Box<dyn Fn(Option<String>, &mut EventContext)>>>>,
186                    Rc<std::cell::Cell<bool>>,
187                )>,
188            >,
189        > = Rc::new(RefCell::new(None));
190        let publish = answered.clone();
191
192        ctx.present_modal(
193            ModalRequest::deferred(move |tree| {
194                let dlg = inner
195                    .take()
196                    .expect("InputDialog present closure called twice");
197                let body = InputDialogBody::new(dlg);
198                *publish.borrow_mut() = Some((body.on_result.clone(), body.fired.clone()));
199                tree.add(ModalContainer::new(body).title(dialog_title.clone()))
200            })
201            .presentation(ModalPresentation::Auto)
202            .close_behavior(ModalCloseBehavior::EscapeOrClickOutside)
203            .title(title)
204            .size(420, 180)
205            .on_dismiss(Rc::new(move |_reason, ctx: &mut EventContext| {
206                let Some((on_result, fired)) = answered.borrow().clone() else {
207                    return;
208                };
209                InputDialogBody::report(&on_result, &fired, None, ctx);
210            })),
211        );
212    }
213}
214
215impl std::fmt::Debug for InputDialog {
216    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217        f.debug_struct("InputDialog")
218            .field("title", &self.title)
219            .field("prompt", &self.prompt)
220            .field("default_text", &self.default_text)
221            .finish()
222    }
223}
224
225// ── InputDialogBody — the actual widget that renders inside the modal ─
226
227struct InputDialogBody {
228    title: LocalizedString,
229    prompt: Option<LocalizedString>,
230    placeholder: Option<LocalizedString>,
231    text: Signal<String>,
232    ok_label: LocalizedString,
233    cancel_label: LocalizedString,
234    on_result: Rc<RefCell<Option<Box<dyn Fn(Option<String>, &mut EventContext)>>>>,
235    fired: Rc<std::cell::Cell<bool>>,
236    validate: Option<ValidatorFn>,
237    /// `true` while the current value is acceptable. Bound to OK's `enabled`, and read
238    /// by the Enter path so the two cannot disagree about what is submittable.
239    valid: Signal<bool>,
240    /// What to show under the field. Held here rather than derived, because
241    /// `TextInput::validation` wants a real `Signal` and because the message is
242    /// suppressed until `touched` — a rule a `.map()` could not express.
243    validation: Signal<ValidationState>,
244    /// Whether the field has been edited since the dialog opened.
245    touched: Rc<std::cell::Cell<bool>>,
246    root_child_id: Option<WidgetId>,
247    /// The label that paints the title, handed to the enclosing modal so
248    /// it can name itself by pointing at it.
249    title_node: Option<WidgetId>,
250}
251
252impl InputDialogBody {
253    fn new(dlg: InputDialog) -> Self {
254        let ok_label = dlg
255            .ok_label
256            .unwrap_or_else(|| teksilo_i18n::tr_widget!(messagebox_btn_ok()));
257        let cancel_label = dlg
258            .cancel_label
259            .unwrap_or_else(|| teksilo_i18n::tr_widget!(messagebox_btn_cancel()));
260        // Seeded from the default text, so a dialog that opens pre-filled with an
261        // acceptable value has OK live immediately, and one that opens empty under a
262        // reject-empty validator opens with OK already greyed.
263        let initial_valid = dlg
264            .validate
265            .as_ref()
266            .map(|f| f(&dlg.default_text).is_ok())
267            .unwrap_or(true);
268        Self {
269            title: dlg.title,
270            prompt: dlg.prompt,
271            placeholder: dlg.placeholder,
272            text: Signal::new(dlg.default_text),
273            ok_label,
274            cancel_label,
275            on_result: Rc::new(RefCell::new(dlg.on_result)),
276            fired: Rc::new(std::cell::Cell::new(false)),
277            validate: dlg.validate,
278            valid: Signal::new(initial_valid),
279            validation: Signal::new(ValidationState::None),
280            touched: Rc::new(std::cell::Cell::new(false)),
281            root_child_id: None,
282            title_node: None,
283        }
284    }
285
286    fn fire(
287        on_result: &Rc<RefCell<Option<Box<dyn Fn(Option<String>, &mut EventContext)>>>>,
288        fired: &Rc<std::cell::Cell<bool>>,
289        value: Option<String>,
290        ctx: &mut EventContext,
291    ) {
292        if Self::report(on_result, fired, value, ctx) {
293            ctx.dismiss_modal();
294        }
295    }
296
297    /// Report for a dialog that is **already being dismissed**, returning
298    /// whether this call is the one that reported.
299    ///
300    /// The `on_dismiss` path needs this: the overlay is mid-teardown, and
301    /// `ctx.dismiss_modal()` only sets a deferred flag, so asking again would
302    /// spend it on whatever modal is topmost when it is read.
303    fn report(
304        on_result: &Rc<RefCell<Option<Box<dyn Fn(Option<String>, &mut EventContext)>>>>,
305        fired: &Rc<std::cell::Cell<bool>>,
306        value: Option<String>,
307        ctx: &mut EventContext,
308    ) -> bool {
309        if fired.replace(true) {
310            return false;
311        }
312        if let Some(handler) = on_result.borrow().as_ref() {
313            handler(value, ctx);
314        }
315        true
316    }
317}
318
319impl std::fmt::Debug for InputDialogBody {
320    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321        f.debug_struct("InputDialogBody")
322            .field("title", &self.title)
323            .finish()
324    }
325}
326
327impl Widget for InputDialogBody {
328    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
329        // Kept by id so the enclosing modal can name itself by pointing at
330        // this label rather than by a second copy of its string, leaving the
331        // title reviewable by character in its own right.
332        let title_id = ctx.add(
333            TextWidget::new(self.title.clone())
334                .style(TextStyleRole::BodyBold)
335                .single_line(),
336        );
337        self.title_node = Some(title_id);
338
339        let mut column = VStack::new().spacing(10.0).child(title_id);
340        if let Some(p) = &self.prompt {
341            column = column.child(TextWidget::new(p.clone()).style(TextStyleRole::Body));
342        }
343
344        // Live validation. Pushed from an effect on the text rather than bound: the
345        // verdict feeds two places (the field's message and OK's enabled state) and one
346        // of them — the message — is additionally gated on `touched`, which no derived
347        // signal could express.
348        if let Some(validate) = self.validate.clone() {
349            let valid = self.valid.clone();
350            let validation = self.validation.clone();
351            let touched = self.touched.clone();
352            let initial = self.text.get();
353            ctx.effect(&self.text, move |typed| {
354                // The first callback fires with the seeded value, before any keystroke;
355                // only a real change counts as an edit.
356                if *typed != initial {
357                    touched.set(true);
358                }
359                match validate(typed) {
360                    Ok(()) => {
361                        valid.set(true);
362                        validation.set(ValidationState::None);
363                    }
364                    Err(msg) => {
365                        valid.set(false);
366                        validation.set(match msg {
367                            Some(m) if touched.get() => ValidationState::Error(m),
368                            // Either the caller chose to stay silent, or the writer has
369                            // not typed yet. The greyed OK carries the message instead.
370                            _ => ValidationState::None,
371                        });
372                    }
373                }
374            });
375        }
376
377        // The bound text input. Submit-on-Enter accepts the dialog — but only when the
378        // value is acceptable, or Enter would bypass the disabled OK button.
379        let text_signal = self.text.clone();
380        let on_result_for_submit = self.on_result.clone();
381        let fired_for_submit = self.fired.clone();
382        let valid_for_submit = self.valid.clone();
383        let mut input = TextInput::new(text_signal.clone()).on_submit_fn(move |ctx| {
384            if !valid_for_submit.get() {
385                return;
386            }
387            let value = text_signal.get();
388            Self::fire(&on_result_for_submit, &fired_for_submit, Some(value), ctx);
389        });
390        if let Some(ph) = &self.placeholder {
391            input = input.placeholder(ph.clone());
392        }
393        if self.validate.is_some() {
394            input = input.validation(self.validation.clone());
395        }
396        let field = input.handle();
397        column = column.child(input);
398
399        // Footer: Spacer + Cancel + OK (right-aligned).
400        let on_result_cancel = self.on_result.clone();
401        let fired_cancel = self.fired.clone();
402        let cancel_label = self.cancel_label.clone();
403        let cancel_btn = Button::new(cancel_label)
404            .variant(ButtonVariant::Plain)
405            .on_activate_fn(move |ctx| {
406                Self::fire(&on_result_cancel, &fired_cancel, None, ctx);
407            });
408
409        let on_result_ok = self.on_result.clone();
410        let fired_ok = self.fired.clone();
411        let text_for_ok = self.text.clone();
412        let ok_label = self.ok_label.clone();
413        let ok_btn = Button::new(ok_label)
414            .variant(ButtonVariant::Filled)
415            .enabled(self.valid.clone())
416            .on_activate_fn(move |ctx| {
417                let value = text_for_ok.get();
418                Self::fire(&on_result_ok, &fired_ok, Some(value), ctx);
419            });
420
421        let footer = HStack::new()
422            .spacing(8.0)
423            .child(Spacer::new())
424            .child(cancel_btn)
425            .child(ok_btn);
426        column = column.child(footer);
427
428        let root = ctx.add(column);
429        // The pre-filled value selected, so typing replaces it and an arrow
430        // key keeps it: the rename-dialog convention. The modal's opening
431        // focus is programmatic and leaves a field's text alone, so the
432        // dialog asks for it.
433        field.select_all();
434        self.root_child_id = Some(root);
435        vec![root]
436    }
437
438    fn layout_response(
439        &self,
440        proposal: SizeProposal,
441        ctx: &LayoutContext,
442    ) -> teksilo_core::widget::LayoutResponse {
443        self.root_child_id
444            .and_then(|id| ctx.child_size(id, proposal))
445            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
446            .into()
447    }
448
449    fn place_children(
450        &self,
451        bounds: Rect,
452        _proposal: SizeProposal,
453        children: &mut [WidgetPlacement],
454        _ctx: &LayoutContext,
455    ) {
456        for child in children.iter_mut() {
457            child.origin = bounds.origin();
458            child.size = bounds.size();
459        }
460    }
461
462    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
463        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
464    }
465
466    fn accessible_title_node(&self) -> Option<WidgetId> {
467        self.title_node
468    }
469
470    fn children(&self) -> Vec<WidgetId> {
471        self.root_child_id.into_iter().collect()
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use teksilo_core::widget_tree::WidgetTree;
479    use teksilo_i18n::lit;
480
481    /// Build a body and lay it out, returning it so its signals can be inspected.
482    fn built(dlg: InputDialog) -> (WidgetTree, InputDialogBody) {
483        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
484        let body = InputDialogBody::new(dlg);
485        let probe = InputDialogBody {
486            title: body.title.clone(),
487            prompt: body.prompt.clone(),
488            placeholder: body.placeholder.clone(),
489            text: body.text.clone(),
490            ok_label: body.ok_label.clone(),
491            cancel_label: body.cancel_label.clone(),
492            on_result: body.on_result.clone(),
493            fired: body.fired.clone(),
494            validate: body.validate.clone(),
495            valid: body.valid.clone(),
496            validation: body.validation.clone(),
497            touched: body.touched.clone(),
498            root_child_id: None,
499            title_node: None,
500        };
501        tree.add(body);
502        tree.layout(SizeProposal {
503            width: Some(420.0),
504            height: None,
505        });
506        (tree, probe)
507    }
508
509    #[test]
510    fn the_modal_is_named_by_the_title_the_body_paints() {
511        // The container points at the body's title label rather than
512        // announcing a copy, so the title stays a label a reader can find
513        // and review by character.
514        use crate::dialog::ModalContainer;
515        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
516        let dialog = tree.add(ModalContainer::new(InputDialogBody::new(InputDialog::new(
517            teksilo_i18n::lit!("Rename"),
518        ))));
519        tree.layout(SizeProposal {
520            width: Some(420.0),
521            height: None,
522        });
523        let update = tree.sync_accessibility();
524
525        let node_id = teksilo_core::accessibility::widget_id_to_node_id(dialog);
526        let consumer = accesskit_consumer::Tree::new(update.clone(), false);
527        let state = consumer.state();
528        let mut stack = vec![state.root()];
529        let mut announced = None;
530        while let Some(node) = stack.pop() {
531            if node.locate().0 == node_id {
532                announced = node.label();
533                break;
534            }
535            for child in node.children() {
536                stack.push(child);
537            }
538        }
539        assert_eq!(announced.as_deref(), Some("Rename"));
540
541        let copies = update
542            .nodes
543            .iter()
544            .filter(|(_, n)| {
545                n.role() == teksilo_core::accesskit::Role::Label && n.value() == Some("Rename")
546            })
547            .count();
548        assert_eq!(copies, 1, "the title is one label, not two");
549    }
550
551    fn reject_empty() -> impl Fn(&str) -> ValidateResult {
552        |v: &str| {
553            if v.trim().is_empty() {
554                Err(Some(lit!("Name it")))
555            } else {
556                Ok(())
557            }
558        }
559    }
560
561    /// Without a validator nothing changes: OK is live from the start, which is what
562    /// every existing caller relies on.
563    #[test]
564    fn no_validator_leaves_ok_enabled() {
565        let (_t, b) = built(InputDialog::new(lit!("T")));
566        assert!(b.valid.get());
567    }
568
569    /// A dialog that opens empty under a reject-empty validator opens with OK greyed —
570    /// the seed is validated, not assumed good.
571    #[test]
572    fn an_invalid_default_opens_with_ok_disabled() {
573        let (_t, b) = built(InputDialog::new(lit!("T")).validate(reject_empty()));
574        assert!(!b.valid.get());
575    }
576
577    #[test]
578    fn a_valid_default_opens_with_ok_enabled() {
579        let (_t, b) = built(
580            InputDialog::new(lit!("T"))
581                .default_text("Chapter One")
582                .validate(reject_empty()),
583        );
584        assert!(b.valid.get());
585    }
586
587    /// The pre-filled value opens selected, so typing replaces it: the rename
588    /// convention. A modal's opening focus is programmatic, which leaves a
589    /// field's text alone, so the dialog has to select it itself.
590    #[test]
591    fn the_default_text_opens_selected() {
592        let (mut t, b) = built(InputDialog::new(lit!("Rename")).default_text("Chapter One"));
593        let field = t
594            .find_by_role(teksilo_core::accesskit::Role::TextInput)
595            .expect("the dialog has a field");
596        t.focus(field);
597        t.layout(SizeProposal::exact(420.0, 300.0));
598        t.type_text(field, "Prologue");
599        t.layout(SizeProposal::exact(420.0, 300.0));
600        assert_eq!(b.text.get(), "Prologue");
601    }
602
603    /// The message is withheld until the field is edited: an error on a dialog nobody has
604    /// typed into yet is noise, and the greyed OK already says it is not ready.
605    #[test]
606    fn the_message_is_withheld_until_the_field_is_edited() {
607        let (_t, b) = built(InputDialog::new(lit!("T")).validate(reject_empty()));
608        assert!(
609            matches!(b.validation.get(), ValidationState::None),
610            "silent while untouched"
611        );
612        assert!(!b.valid.get(), "but still not submittable");
613
614        b.text.set("x".into());
615        b.text.set("".into());
616        assert!(
617            matches!(b.validation.get(), ValidationState::Error(_)),
618            "once edited, an empty value explains itself"
619        );
620    }
621
622    /// Typing something acceptable clears both the block and the message.
623    #[test]
624    fn a_valid_value_clears_the_block_and_the_message() {
625        let (_t, b) = built(InputDialog::new(lit!("T")).validate(reject_empty()));
626        b.text.set("Character sheet".into());
627        assert!(b.valid.get());
628        assert!(matches!(b.validation.get(), ValidationState::None));
629    }
630
631    /// `Err(None)` blocks without printing anything — the "not yet" case.
632    #[test]
633    fn a_silent_rejection_blocks_without_a_message() {
634        let (_t, b) = built(
635            InputDialog::new(lit!("T"))
636                .default_text("seed")
637                .validate(|_: &str| Err(None)),
638        );
639        b.text.set("anything".into());
640        assert!(!b.valid.get());
641        assert!(matches!(b.validation.get(), ValidationState::None));
642    }
643
644    #[test]
645    fn input_dialog_body_builds() {
646        // Smoke test: the body widget renders without panic when added
647        // standalone (without going through present_modal).
648        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
649        let dlg = InputDialog::new(lit!("Rename"))
650            .prompt(lit!("Choose a new name:"))
651            .default_text("untitled");
652        let body = InputDialogBody::new(dlg);
653        let id = tree.add(body);
654        tree.layout(SizeProposal {
655            width: Some(420.0),
656            height: None,
657        });
658        let b = tree.bounds(id);
659        assert!(b.width > 0.0);
660        assert!(b.height > 0.0);
661    }
662}