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 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
198struct 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 valid: Signal<bool>,
213 validation: Signal<ValidationState>,
217 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 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 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 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 _ => ValidationState::None,
317 });
318 }
319 }
320 });
321 }
322
323 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 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 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 #[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 #[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 #[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 #[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 #[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 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}