1use 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
72pub type ValidateResult = Result<(), Option<LocalizedString>>;
77
78type ValidatorFn = Rc<dyn Fn(&str) -> ValidateResult>;
79
80pub 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 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 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 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 pub fn default_text(mut self, text: impl Into<String>) -> Self {
124 self.default_text = text.into();
125 self
126 }
127
128 pub fn ok_label(mut self, label: impl Into<LocalizedString>) -> Self {
131 self.ok_label = Some(label.into());
132 self
133 }
134
135 pub fn cancel_label(mut self, label: impl Into<LocalizedString>) -> Self {
138 self.cancel_label = Some(label.into());
139 self
140 }
141
142 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 pub fn validate(mut self, f: impl Fn(&str) -> ValidateResult + 'static) -> Self {
163 self.validate = Some(Rc::new(f));
164 self
165 }
166
167 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 #[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
225struct 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 valid: Signal<bool>,
240 validation: Signal<ValidationState>,
244 touched: Rc<std::cell::Cell<bool>>,
246 root_child_id: Option<WidgetId>,
247 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 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 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 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 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 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 _ => ValidationState::None,
371 });
372 }
373 }
374 });
375 }
376
377 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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}