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        ctx.present_modal(
174            ModalRequest::deferred(move |tree| {
175                let dlg = inner
176                    .take()
177                    .expect("InputDialog present closure called twice");
178                tree.add(ModalContainer::new(InputDialogBody::new(dlg)).title(dialog_title.clone()))
179            })
180            .presentation(ModalPresentation::Auto)
181            .close_behavior(ModalCloseBehavior::EscapeOrClickOutside)
182            .title(title)
183            .size(420, 180),
184        );
185    }
186}
187
188impl std::fmt::Debug for InputDialog {
189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190        f.debug_struct("InputDialog")
191            .field("title", &self.title)
192            .field("prompt", &self.prompt)
193            .field("default_text", &self.default_text)
194            .finish()
195    }
196}
197
198// ── InputDialogBody — the actual widget that renders inside the modal ─
199
200struct InputDialogBody {
201    title: LocalizedString,
202    prompt: Option<LocalizedString>,
203    placeholder: Option<LocalizedString>,
204    text: Signal<String>,
205    ok_label: LocalizedString,
206    cancel_label: LocalizedString,
207    on_result: Rc<RefCell<Option<Box<dyn Fn(Option<String>, &mut EventContext)>>>>,
208    fired: Rc<std::cell::Cell<bool>>,
209    validate: Option<ValidatorFn>,
210    /// `true` while the current value is acceptable. Bound to OK's `enabled`, and read
211    /// by the Enter path so the two cannot disagree about what is submittable.
212    valid: Signal<bool>,
213    /// What to show under the field. Held here rather than derived, because
214    /// `TextInput::validation` wants a real `Signal` and because the message is
215    /// suppressed until `touched` — a rule a `.map()` could not express.
216    validation: Signal<ValidationState>,
217    /// Whether the field has been edited since the dialog opened.
218    touched: Rc<std::cell::Cell<bool>>,
219    root_child_id: Option<WidgetId>,
220}
221
222impl InputDialogBody {
223    fn new(dlg: InputDialog) -> Self {
224        let ok_label = dlg
225            .ok_label
226            .unwrap_or_else(|| teksilo_i18n::tr_widget!(messagebox_btn_ok()));
227        let cancel_label = dlg
228            .cancel_label
229            .unwrap_or_else(|| teksilo_i18n::tr_widget!(messagebox_btn_cancel()));
230        // Seeded from the default text, so a dialog that opens pre-filled with an
231        // acceptable value has OK live immediately, and one that opens empty under a
232        // reject-empty validator opens with OK already greyed.
233        let initial_valid = dlg
234            .validate
235            .as_ref()
236            .map(|f| f(&dlg.default_text).is_ok())
237            .unwrap_or(true);
238        Self {
239            title: dlg.title,
240            prompt: dlg.prompt,
241            placeholder: dlg.placeholder,
242            text: Signal::new(dlg.default_text),
243            ok_label,
244            cancel_label,
245            on_result: Rc::new(RefCell::new(dlg.on_result)),
246            fired: Rc::new(std::cell::Cell::new(false)),
247            validate: dlg.validate,
248            valid: Signal::new(initial_valid),
249            validation: Signal::new(ValidationState::None),
250            touched: Rc::new(std::cell::Cell::new(false)),
251            root_child_id: None,
252        }
253    }
254
255    fn fire(
256        on_result: &Rc<RefCell<Option<Box<dyn Fn(Option<String>, &mut EventContext)>>>>,
257        fired: &Rc<std::cell::Cell<bool>>,
258        value: Option<String>,
259        ctx: &mut EventContext,
260    ) {
261        if fired.replace(true) {
262            return;
263        }
264        if let Some(handler) = on_result.borrow().as_ref() {
265            handler(value, ctx);
266        }
267        ctx.dismiss_modal();
268    }
269}
270
271impl std::fmt::Debug for InputDialogBody {
272    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273        f.debug_struct("InputDialogBody")
274            .field("title", &self.title)
275            .finish()
276    }
277}
278
279impl Widget for InputDialogBody {
280    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
281        let title = TextWidget::new(self.title.clone())
282            .style(TextStyleRole::BodyBold)
283            .single_line();
284
285        let mut column = VStack::new().spacing(10.0).child(title);
286        if let Some(p) = &self.prompt {
287            column = column.child(TextWidget::new(p.clone()).style(TextStyleRole::Body));
288        }
289
290        // Live validation. Pushed from an effect on the text rather than bound: the
291        // verdict feeds two places (the field's message and OK's enabled state) and one
292        // of them — the message — is additionally gated on `touched`, which no derived
293        // signal could express.
294        if let Some(validate) = self.validate.clone() {
295            let valid = self.valid.clone();
296            let validation = self.validation.clone();
297            let touched = self.touched.clone();
298            let initial = self.text.get();
299            ctx.effect(&self.text, move |typed| {
300                // The first callback fires with the seeded value, before any keystroke;
301                // only a real change counts as an edit.
302                if *typed != initial {
303                    touched.set(true);
304                }
305                match validate(typed) {
306                    Ok(()) => {
307                        valid.set(true);
308                        validation.set(ValidationState::None);
309                    }
310                    Err(msg) => {
311                        valid.set(false);
312                        validation.set(match msg {
313                            Some(m) if touched.get() => ValidationState::Error(m),
314                            // Either the caller chose to stay silent, or the writer has
315                            // not typed yet. The greyed OK carries the message instead.
316                            _ => ValidationState::None,
317                        });
318                    }
319                }
320            });
321        }
322
323        // The bound text input. Submit-on-Enter accepts the dialog — but only when the
324        // value is acceptable, or Enter would bypass the disabled OK button.
325        let text_signal = self.text.clone();
326        let on_result_for_submit = self.on_result.clone();
327        let fired_for_submit = self.fired.clone();
328        let valid_for_submit = self.valid.clone();
329        let mut input = TextInput::new(text_signal.clone()).on_submit_fn(move |ctx| {
330            if !valid_for_submit.get() {
331                return;
332            }
333            let value = text_signal.get();
334            Self::fire(&on_result_for_submit, &fired_for_submit, Some(value), ctx);
335        });
336        if let Some(ph) = &self.placeholder {
337            input = input.placeholder(ph.clone());
338        }
339        if self.validate.is_some() {
340            input = input.validation(self.validation.clone());
341        }
342        column = column.child(input);
343
344        // Footer: Spacer + Cancel + OK (right-aligned).
345        let on_result_cancel = self.on_result.clone();
346        let fired_cancel = self.fired.clone();
347        let cancel_label = self.cancel_label.clone();
348        let cancel_btn = Button::new(cancel_label)
349            .variant(ButtonVariant::Plain)
350            .on_activate_fn(move |ctx| {
351                Self::fire(&on_result_cancel, &fired_cancel, None, ctx);
352            });
353
354        let on_result_ok = self.on_result.clone();
355        let fired_ok = self.fired.clone();
356        let text_for_ok = self.text.clone();
357        let ok_label = self.ok_label.clone();
358        let ok_btn = Button::new(ok_label)
359            .variant(ButtonVariant::Filled)
360            .enabled(self.valid.clone())
361            .on_activate_fn(move |ctx| {
362                let value = text_for_ok.get();
363                Self::fire(&on_result_ok, &fired_ok, Some(value), ctx);
364            });
365
366        let footer = HStack::new()
367            .spacing(8.0)
368            .child(Spacer::new())
369            .child(cancel_btn)
370            .child(ok_btn);
371        column = column.child(footer);
372
373        let root = ctx.add(column);
374        self.root_child_id = Some(root);
375        vec![root]
376    }
377
378    fn layout_response(
379        &self,
380        proposal: SizeProposal,
381        ctx: &LayoutContext,
382    ) -> teksilo_core::widget::LayoutResponse {
383        self.root_child_id
384            .and_then(|id| ctx.child_size(id, proposal))
385            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
386            .into()
387    }
388
389    fn place_children(
390        &self,
391        bounds: Rect,
392        _proposal: SizeProposal,
393        children: &mut [WidgetPlacement],
394        _ctx: &LayoutContext,
395    ) {
396        for child in children.iter_mut() {
397            child.origin = bounds.origin();
398            child.size = bounds.size();
399        }
400    }
401
402    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
403        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
404    }
405
406    fn children(&self) -> Vec<WidgetId> {
407        self.root_child_id.into_iter().collect()
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use teksilo_core::widget_tree::WidgetTree;
415    use teksilo_i18n::lit;
416
417    /// Build a body and lay it out, returning it so its signals can be inspected.
418    fn built(dlg: InputDialog) -> (WidgetTree, InputDialogBody) {
419        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
420        let body = InputDialogBody::new(dlg);
421        let probe = InputDialogBody {
422            title: body.title.clone(),
423            prompt: body.prompt.clone(),
424            placeholder: body.placeholder.clone(),
425            text: body.text.clone(),
426            ok_label: body.ok_label.clone(),
427            cancel_label: body.cancel_label.clone(),
428            on_result: body.on_result.clone(),
429            fired: body.fired.clone(),
430            validate: body.validate.clone(),
431            valid: body.valid.clone(),
432            validation: body.validation.clone(),
433            touched: body.touched.clone(),
434            root_child_id: None,
435        };
436        tree.add(body);
437        tree.layout(SizeProposal {
438            width: Some(420.0),
439            height: None,
440        });
441        (tree, probe)
442    }
443
444    fn reject_empty() -> impl Fn(&str) -> ValidateResult {
445        |v: &str| {
446            if v.trim().is_empty() {
447                Err(Some(lit!("Name it")))
448            } else {
449                Ok(())
450            }
451        }
452    }
453
454    /// Without a validator nothing changes: OK is live from the start, which is what
455    /// every existing caller relies on.
456    #[test]
457    fn no_validator_leaves_ok_enabled() {
458        let (_t, b) = built(InputDialog::new(lit!("T")));
459        assert!(b.valid.get());
460    }
461
462    /// A dialog that opens empty under a reject-empty validator opens with OK greyed —
463    /// the seed is validated, not assumed good.
464    #[test]
465    fn an_invalid_default_opens_with_ok_disabled() {
466        let (_t, b) = built(InputDialog::new(lit!("T")).validate(reject_empty()));
467        assert!(!b.valid.get());
468    }
469
470    #[test]
471    fn a_valid_default_opens_with_ok_enabled() {
472        let (_t, b) = built(
473            InputDialog::new(lit!("T"))
474                .default_text("Chapter One")
475                .validate(reject_empty()),
476        );
477        assert!(b.valid.get());
478    }
479
480    /// The message is withheld until the field is edited: an error on a dialog nobody has
481    /// typed into yet is noise, and the greyed OK already says it is not ready.
482    #[test]
483    fn the_message_is_withheld_until_the_field_is_edited() {
484        let (_t, b) = built(InputDialog::new(lit!("T")).validate(reject_empty()));
485        assert!(
486            matches!(b.validation.get(), ValidationState::None),
487            "silent while untouched"
488        );
489        assert!(!b.valid.get(), "but still not submittable");
490
491        b.text.set("x".into());
492        b.text.set("".into());
493        assert!(
494            matches!(b.validation.get(), ValidationState::Error(_)),
495            "once edited, an empty value explains itself"
496        );
497    }
498
499    /// Typing something acceptable clears both the block and the message.
500    #[test]
501    fn a_valid_value_clears_the_block_and_the_message() {
502        let (_t, b) = built(InputDialog::new(lit!("T")).validate(reject_empty()));
503        b.text.set("Character sheet".into());
504        assert!(b.valid.get());
505        assert!(matches!(b.validation.get(), ValidationState::None));
506    }
507
508    /// `Err(None)` blocks without printing anything — the "not yet" case.
509    #[test]
510    fn a_silent_rejection_blocks_without_a_message() {
511        let (_t, b) = built(
512            InputDialog::new(lit!("T"))
513                .default_text("seed")
514                .validate(|_: &str| Err(None)),
515        );
516        b.text.set("anything".into());
517        assert!(!b.valid.get());
518        assert!(matches!(b.validation.get(), ValidationState::None));
519    }
520
521    #[test]
522    fn input_dialog_body_builds() {
523        // Smoke test: the body widget renders without panic when added
524        // standalone (without going through present_modal).
525        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
526        let dlg = InputDialog::new(lit!("Rename"))
527            .prompt(lit!("Choose a new name:"))
528            .default_text("untitled");
529        let body = InputDialogBody::new(dlg);
530        let id = tree.add(body);
531        tree.layout(SizeProposal {
532            width: Some(420.0),
533            height: None,
534        });
535        let b = tree.bounds(id);
536        assert!(b.width > 0.0);
537        assert!(b.height > 0.0);
538    }
539}