1mod content_pane;
42mod controller;
43mod footer;
44mod indicator;
45mod indicator_strip;
46mod nav;
47mod step;
48mod wizard;
49
50#[cfg(test)]
51mod tests;
52
53use std::cell::RefCell;
54use std::rc::Rc;
55
56use teksilo_canvas::{Rect, SizeProposal};
57use teksilo_core::accessibility::AccessNodeBuilder;
58use teksilo_core::build_context::BuildContext;
59use teksilo_core::event::{EventResponse, Key, WidgetEvent};
60use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
61use teksilo_core::widget_builder::HandlerSet;
62use teksilo_core::widget_id::WidgetId;
63use teksilo_i18n::{LocalizedString, lit};
64
65use crate::primitives::{Divider, Expand, HStack, Switcher, VStack};
66
67pub use controller::StepperController;
68pub use nav::{FinishOutcome, IntoFinishOutcome};
69pub use step::{Step, StepStatus};
70pub use wizard::Wizard;
71
72use content_pane::StepPane;
73use footer::StepperFooter;
74use indicator::DEFAULT_CIRCLE_SIZE;
75use indicator_strip::{IndicatorStrip, StepMeta};
76use nav::{FinishAction, StepNav};
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80pub enum StepperOrientation {
81 #[default]
83 Horizontal,
84 Vertical,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
91pub enum ChromePosition {
92 #[default]
94 Leading,
95 Top,
97}
98
99type StepperAction = Rc<dyn Fn(&mut EventContext, &StepperController)>;
100
101pub struct Stepper {
104 steps: Vec<Step>,
105 controller: Option<StepperController>,
106 orientation: StepperOrientation,
107 non_linear: bool,
108 circle_size: f32,
109 chrome: Option<Box<dyn Widget>>,
110 chrome_position: ChromePosition,
111 back_label: LocalizedString,
112 next_label: LocalizedString,
113 finish_label: LocalizedString,
114 skip_label: LocalizedString,
115 help_label: Option<LocalizedString>,
116 help_action: Option<StepperAction>,
117 cancel_label: Option<LocalizedString>,
118 cancel_action: Option<StepperAction>,
119 finish_action: Option<FinishAction>,
120 enter_advances: bool,
121 root_child_id: Option<WidgetId>,
122 tooltip_text: Option<LocalizedString>,
123 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
124 composite_tooltip_content: Option<Box<dyn Widget>>,
125}
126
127impl Default for Stepper {
128 fn default() -> Self {
129 Self::new()
130 }
131}
132
133impl Stepper {
134 pub fn new() -> Self {
138 Self {
139 steps: Vec::new(),
140 controller: None,
141 orientation: StepperOrientation::Horizontal,
142 non_linear: false,
143 circle_size: DEFAULT_CIRCLE_SIZE,
144 chrome: None,
145 chrome_position: ChromePosition::Leading,
146 back_label: lit!("Back"),
147 next_label: lit!("Next"),
148 finish_label: lit!("Finish"),
149 skip_label: lit!("Skip"),
150 help_label: None,
151 help_action: None,
152 cancel_label: None,
153 cancel_action: None,
154 finish_action: None,
155 enter_advances: true,
156 root_child_id: None,
157 tooltip_text: None,
158 rich_tooltip_source: None,
159 composite_tooltip_content: None,
160 }
161 }
162
163 pub fn step(mut self, step: Step) -> Self {
165 self.steps.push(step);
166 self
167 }
168
169 pub fn steps(mut self, steps: impl IntoIterator<Item = Step>) -> Self {
171 self.steps.extend(steps);
172 self
173 }
174
175 pub fn controller(mut self, controller: StepperController) -> Self {
178 self.controller = Some(controller);
179 self
180 }
181
182 pub fn orientation(mut self, orientation: StepperOrientation) -> Self {
184 self.orientation = orientation;
185 self
186 }
187
188 pub fn vertical(mut self) -> Self {
190 self.orientation = StepperOrientation::Vertical;
191 self
192 }
193
194 pub fn non_linear(mut self, non_linear: bool) -> Self {
197 self.non_linear = non_linear;
198 self
199 }
200
201 pub fn circle_size(mut self, size: f32) -> Self {
203 self.circle_size = size;
204 self
205 }
206
207 pub fn chrome(mut self, chrome: impl Widget + 'static) -> Self {
216 self.chrome = Some(Box::new(chrome));
217 self
218 }
219
220 pub fn chrome_position(mut self, position: ChromePosition) -> Self {
224 self.chrome_position = position;
225 self
226 }
227
228 pub fn back_label(mut self, label: impl Into<LocalizedString>) -> Self {
230 self.back_label = label.into();
231 self
232 }
233 pub fn next_label(mut self, label: impl Into<LocalizedString>) -> Self {
235 self.next_label = label.into();
236 self
237 }
238 pub fn finish_label(mut self, label: impl Into<LocalizedString>) -> Self {
240 self.finish_label = label.into();
241 self
242 }
243 pub fn skip_label(mut self, label: impl Into<LocalizedString>) -> Self {
245 self.skip_label = label.into();
246 self
247 }
248
249 pub fn help(
251 mut self,
252 label: impl Into<LocalizedString>,
253 action: impl Fn(&mut EventContext, &StepperController) + 'static,
254 ) -> Self {
255 self.help_label = Some(label.into());
256 self.help_action = Some(Rc::new(action));
257 self
258 }
259
260 pub fn cancel(
262 mut self,
263 label: impl Into<LocalizedString>,
264 action: impl Fn(&mut EventContext, &StepperController) + 'static,
265 ) -> Self {
266 self.cancel_label = Some(label.into());
267 self.cancel_action = Some(Rc::new(action));
268 self
269 }
270
271 pub fn on_finish<R: IntoFinishOutcome>(
290 mut self,
291 action: impl Fn(&mut EventContext, &StepperController) -> R + 'static,
292 ) -> Self {
293 self.finish_action = Some(Rc::new(move |ctx, ctrl| {
294 action(ctx, ctrl).into_finish_outcome()
295 }));
296 self
297 }
298
299 pub fn enter_advances(mut self, enter_advances: bool) -> Self {
313 self.enter_advances = enter_advances;
314 self
315 }
316
317 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
320 self.tooltip_text = Some(text.into());
321 self.rich_tooltip_source = None;
322 self.composite_tooltip_content = None;
323 self
324 }
325
326 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
329 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
330 self.tooltip_text = None;
331 self.composite_tooltip_content = None;
332 self
333 }
334
335 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
338 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
339 self.tooltip_text = None;
340 self.composite_tooltip_content = None;
341 self
342 }
343
344 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
347 self.composite_tooltip_content = Some(Box::new(content));
348 self.tooltip_text = None;
349 self.rich_tooltip_source = None;
350 self
351 }
352}
353
354impl std::fmt::Debug for Stepper {
355 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356 f.debug_struct("Stepper")
357 .field("steps", &self.steps.len())
358 .field("orientation", &self.orientation)
359 .field("non_linear", &self.non_linear)
360 .finish()
361 }
362}
363
364impl Widget for Stepper {
365 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
366 if self.steps.is_empty() {
367 self.root_child_id = None;
368 return Vec::new();
369 }
370
371 let controller = self
375 .controller
376 .get_or_insert_with(|| StepperController::new(self.steps.len()))
377 .clone();
378 controller.seed_statuses(self.steps.iter().map(|s| s.initial_status).collect());
379
380 for (i, step) in self.steps.iter().enumerate() {
384 let Some(prop) = step.visible.clone() else {
385 continue;
386 };
387 let signal = prop.as_signal();
388 controller.set_visible(i, signal.get());
389 let c = controller.clone();
390 ctx.effect(&signal, move |visible| c.set_visible(i, *visible));
391 }
392
393 let nav = Rc::new(StepNav::new(
396 controller.clone(),
397 self.steps.iter().map(|s| s.validate.clone()).collect(),
398 self.steps.iter().map(|s| s.complete.clone()).collect(),
399 self.finish_action.clone(),
400 ));
401
402 let panel_ids: Rc<RefCell<Vec<WidgetId>>> = Rc::new(RefCell::new(Vec::new()));
403 let indicator_ids: Rc<RefCell<Vec<WidgetId>>> = Rc::new(RefCell::new(Vec::new()));
404
405 let mut switcher = Switcher::new(controller.current_step_signal())
409 .capture_child_ids_into(panel_ids.clone());
410 for step in &self.steps {
411 let factory = step.content_factory.as_ref().unwrap_or_else(|| {
412 panic!(
413 "Step \"{}\" requires .content(...) — no content factory was set",
414 step.title.resolve_now()
415 )
416 });
417 let pane = StepPane::new(
418 step.title.clone(),
419 factory(),
420 panel_ids.clone(),
421 indicator_ids.clone(),
422 );
423 let pane_id = ctx.add(pane);
424 switcher = switcher.child_id(pane_id);
425 }
426 let switcher_id = ctx.add(switcher);
427
428 let metas: Vec<StepMeta> = self
429 .steps
430 .iter()
431 .map(|s| StepMeta {
432 title: s.title.clone(),
433 supporting_text: s.supporting_text.clone(),
434 })
435 .collect();
436 let strip_id = ctx.add(IndicatorStrip::new(
437 metas,
438 controller.clone(),
439 self.orientation,
440 self.non_linear,
441 self.circle_size,
442 indicator_ids.clone(),
443 panel_ids.clone(),
444 ));
445
446 let optional_flags: Vec<bool> = self
447 .steps
448 .iter()
449 .map(|s| s.initial_status == StepStatus::Optional)
450 .collect();
451 let footer_id = ctx.add(StepperFooter::new(
452 nav.clone(),
453 optional_flags,
454 self.back_label.clone(),
455 self.next_label.clone(),
456 self.finish_label.clone(),
457 self.skip_label.clone(),
458 self.help_label.clone(),
459 self.cancel_label.clone(),
460 self.help_action.clone(),
461 self.cancel_action.clone(),
462 ));
463
464 let content = ctx.add(Expand::new().child_id(switcher_id));
465
466 let body = match self.orientation {
467 StepperOrientation::Horizontal => ctx.add(
468 VStack::new()
469 .spacing(12.0)
470 .add_child(strip_id)
471 .child(Divider::new())
472 .add_child(content)
473 .child(Divider::new())
474 .add_child(footer_id),
475 ),
476 StepperOrientation::Vertical => {
477 let right = ctx.add(
478 VStack::new()
479 .spacing(12.0)
480 .add_child(content)
481 .child(Divider::new())
482 .add_child(footer_id),
483 );
484 ctx.add(
485 HStack::new()
486 .spacing(20.0)
487 .add_child(strip_id)
488 .child(Expand::new().child_id(right)),
489 )
490 }
491 };
492
493 let root = if let Some(chrome) = self.chrome.take() {
496 let chrome_id = ctx.add_boxed(chrome);
497 let on_top = matches!(self.chrome_position, ChromePosition::Top)
498 || matches!(self.orientation, StepperOrientation::Vertical);
499 if on_top {
500 ctx.add(
501 VStack::new()
502 .spacing(12.0)
503 .add_child(chrome_id)
504 .child(Expand::new().child_id(body)),
505 )
506 } else {
507 ctx.add(
508 HStack::new()
509 .spacing(16.0)
510 .add_child(chrome_id)
511 .child(Expand::new().child_id(body)),
512 )
513 }
514 } else {
515 body
516 };
517
518 self.root_child_id = Some(root);
519
520 if self.enter_advances {
525 let nav = nav.clone();
526 ctx.apply_self_handlers(HandlerSet::new().on_key(move |event, ctx| match event {
527 WidgetEvent::KeyUp {
528 key: Key::Enter,
529 modifiers,
530 } if !modifiers.ctrl() && !modifiers.alt() && !modifiers.super_key() => {
531 nav.activate_primary(ctx);
532 EventResponse::Handled
533 }
534 WidgetEvent::KeyDown {
537 key: Key::Enter,
538 modifiers,
539 ..
540 } if !modifiers.ctrl() && !modifiers.alt() && !modifiers.super_key() => {
541 EventResponse::Handled
542 }
543 _ => EventResponse::Ignored,
544 }));
545 }
546
547 if let Some(content) = self.composite_tooltip_content.take() {
548 let delay = ctx.theme().motion.tooltip_delay_heavy;
549 crate::tooltip::attach_composite_tooltip_boxed(ctx, root, content, delay);
550 } else if let Some(source) = self.rich_tooltip_source.clone() {
551 let delay = ctx.theme().motion.tooltip_delay;
552 crate::tooltip::attach_rich_tooltip_source(ctx, root, source, delay);
553 } else if let Some(text) = self.tooltip_text.clone() {
554 let delay = ctx.theme().motion.tooltip_delay;
555 crate::tooltip::attach_plain_tooltip(ctx, root, text, delay);
556 }
557
558 vec![root]
559 }
560
561 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
562 self.root_child_id
563 .and_then(|id| ctx.child_size(id, proposal))
564 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
565 .into()
566 }
567
568 fn place_children(
569 &self,
570 bounds: Rect,
571 _proposal: SizeProposal,
572 children: &mut [WidgetPlacement],
573 _ctx: &LayoutContext,
574 ) {
575 for child in children.iter_mut() {
576 child.origin = bounds.origin();
577 child.size = bounds.size();
578 }
579 }
580
581 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
582 builder.set_role(teksilo_core::accesskit::Role::Group);
583 builder.set_name(teksilo_i18n::tr_widget!(a11y_stepper_content_name()).resolve_now());
584 }
585
586 fn children(&self) -> Vec<WidgetId> {
587 self.root_child_id.into_iter().collect()
588 }
589}