1use std::fmt;
4use std::rc::Rc;
5
6use ravenrs_core::{Border, Color, CursorIcon, Insets, Role};
7use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
8use ravenrs_reactive::{Binding, State};
9use ravenrs_style::{Semantic, TextStyle, Weight};
10
11use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
12use crate::{Alert, AnyView, Button, ButtonStyle, IconName, Tone, View, util};
13
14type StepCheck = Rc<dyn Fn() -> Result<(), String>>;
15
16#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
18pub struct WizardStep {
19 title: String,
20 content: AnyView,
21 check: Option<StepCheck>,
22 failed: bool,
23}
24
25impl fmt::Debug for WizardStep {
26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27 f.debug_struct("WizardStep").field("title", &self.title).finish_non_exhaustive()
28 }
29}
30
31#[cfg_attr(feature = "live", ravenrs_live::expose)]
32impl WizardStep {
33 pub fn new(title: impl Into<String>, content: impl View + 'static) -> Self {
35 Self { title: title.into(), content: Box::new(content), check: None, failed: false }
36 }
37
38 pub fn check(mut self, check: impl Fn() -> Result<(), String> + 'static) -> Self {
41 self.check = Some(Rc::new(check));
42 self
43 }
44
45 pub fn failed(mut self, failed: bool) -> Self {
48 self.failed = failed;
49 self
50 }
51}
52
53#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
73pub struct Wizard {
74 step: Binding<usize>,
75 steps: Vec<WizardStep>,
76 on_finish: Option<Rc<dyn Fn()>>,
77 on_cancel: Option<Rc<dyn Fn()>>,
78 finish_label: Option<String>,
79}
80
81impl fmt::Debug for Wizard {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 f.debug_struct("Wizard").field("steps", &self.steps).finish_non_exhaustive()
84 }
85}
86
87#[cfg_attr(feature = "live", ravenrs_live::expose)]
88impl Wizard {
89 pub fn new(step: Binding<usize>, steps: Vec<WizardStep>) -> Self {
91 Self { step, steps, on_finish: None, on_cancel: None, finish_label: None }
92 }
93
94 pub fn on_finish(mut self, handler: impl Fn() + 'static) -> Self {
96 self.on_finish = Some(Rc::new(handler));
97 self
98 }
99
100 pub fn on_cancel(mut self, handler: impl Fn() + 'static) -> Self {
102 self.on_cancel = Some(Rc::new(handler));
103 self
104 }
105
106 pub fn finish_label(mut self, label: impl Into<String>) -> Self {
108 self.finish_label = Some(label.into());
109 self
110 }
111}
112
113#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
115#[non_exhaustive]
116#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
117pub enum StepState {
118 #[default]
120 Upcoming,
121 Current,
123 Done,
125 Error,
127}
128
129fn step_dot(cx: &mut BuildCx<'_>, number: usize, state: StepState, size: f32) -> NodeId {
132 let theme = *cx.theme();
133 let (muted, on_accent, danger) =
134 (theme.color(Semantic::InkMuted), theme.color(Semantic::OnAccent), theme.color(Semantic::Danger));
135 let icon_size = size * 7.0 / 12.0;
136 let mark = match state {
137 StepState::Done => util::icon(cx, IconName::Check, icon_size, on_accent),
138 StepState::Error => util::icon(cx, IconName::CircleAlert, icon_size, danger),
139 StepState::Current | StepState::Upcoming => {
140 let color = if state == StepState::Current { on_accent } else { muted };
141 util::label(cx, &number.to_string(), TextStyle::Callout, Some(Weight::SEMIBOLD), color)
142 }
143 };
144 let paint = match state {
145 StepState::Done | StepState::Current => QuadPaint::new(theme.color(Semantic::Accent)),
146 StepState::Upcoming => QuadPaint::new(theme.color(Semantic::Ink).with_alpha(0.08)),
147 StepState::Error => QuadPaint {
148 border: Some(Border { width: 1.0, color: danger }),
149 ..QuadPaint::new(Color::TRANSPARENT)
150 },
151 };
152 cx.container(
153 LayoutStyle {
154 width: Some(size),
155 height: Some(size),
156 align: Align::Center,
157 justify: Justify::Center,
158 ..LayoutStyle::stack(Axis::Horizontal)
159 },
160 &[mark],
161 Paint::Quad(QuadPaint { corner_radius: theme.radii.full, ..paint }),
162 )
163}
164
165fn step_state_text(cx: &BuildCx<'_>, state: StepState) -> Option<String> {
167 let labels = cx.labels();
168 match state {
169 StepState::Done => Some(labels.wizard_done.clone()),
170 StepState::Error => Some(labels.wizard_error.clone()),
171 StepState::Current | StepState::Upcoming => None,
172 }
173}
174
175#[derive(Clone, Copy, Debug)]
185#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
186pub struct StepBadge {
187 number: usize,
188 state: StepState,
189 size: f32,
190}
191
192#[cfg_attr(feature = "live", ravenrs_live::expose)]
193impl StepBadge {
194 pub fn new(number: usize) -> Self {
196 Self { number, state: StepState::Upcoming, size: 24.0 }
197 }
198
199 pub fn state(mut self, state: StepState) -> Self {
201 self.state = state;
202 self
203 }
204
205 pub fn size(mut self, size: f32) -> Self {
207 self.size = size;
208 self
209 }
210}
211
212impl View for StepBadge {
213 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
214 let node = step_dot(cx, self.number, self.state, self.size);
215 let mut semantics = Semantics::new(Role::Image)
216 .name(self.number.to_string())
217 .selected(self.state == StepState::Current);
218 if let Some(text) = step_state_text(cx, self.state) {
219 semantics = semantics.description(text);
220 }
221 cx.semantics(node, semantics);
222 node
223 }
224}
225
226struct StepRow<'a> {
228 number: usize,
229 title: &'a str,
230 note: Option<&'a str>,
231 state: StepState,
232 on_click: Option<Rc<dyn Fn()>>,
233 stretch: bool,
234}
235
236impl View for StepRow<'_> {
237 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
238 let theme = *cx.theme();
239 let (ink, muted) = (theme.color(Semantic::Ink), theme.color(Semantic::InkMuted));
240 let here = self.state == StepState::Current;
241 let dot = step_dot(cx, self.number, self.state, 24.0);
242 cx.semantics(dot, Semantics::hidden());
243 let weight = here.then_some(Weight::SEMIBOLD);
244 let color = if self.state == StepState::Upcoming { muted } else { ink };
245 let title = util::label(cx, self.title, TextStyle::Callout, weight, color);
246 let text = match self.note {
247 Some(note) => {
248 let note = util::label(cx, note, TextStyle::Caption, None, muted);
249 cx.container(
250 LayoutStyle { grow: 1.0, spacing: 2.0, ..LayoutStyle::stack(Axis::Vertical) },
251 &[title, note],
252 Paint::None,
253 )
254 }
255 None => title,
256 };
257 let clickable = self.on_click.is_some();
258 let node = cx.container(
259 LayoutStyle {
260 padding: Insets::symmetric(4.0, 6.0),
261 spacing: 8.0,
262 align: Align::Center,
263 align_self: self.stretch.then_some(Align::Stretch),
264 ..LayoutStyle::stack(Axis::Horizontal)
265 },
266 &[dot, text],
267 Paint::Quad(QuadPaint {
268 corner_radius: theme.radii.sm,
269 ..QuadPaint::new(if clickable && cx.is_hovered() {
270 ink.with_alpha(0.05)
271 } else {
272 Color::TRANSPARENT
273 })
274 }),
275 );
276 let role = if clickable { Role::Button } else { Role::ListItem };
277 let mut semantics = Semantics::new(role).name(self.title.to_owned());
278 let description: Vec<String> =
280 self.note.map(str::to_owned).into_iter().chain(step_state_text(cx, self.state)).collect();
281 if !description.is_empty() {
282 semantics = semantics.description(description.join(", "));
283 }
284 if here || !clickable {
285 semantics = semantics.selected(here);
286 }
287 cx.semantics(node, semantics);
288 if let Some(on_click) = &self.on_click {
289 cx.on_click(node, on_click.clone());
290 cx.focusable(node, theme.radii.sm);
291 cx.cursor(node, CursorIcon::Pointer);
292 }
293 node
294 }
295}
296
297#[derive(Clone, Debug)]
299#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
300pub struct IndicatorStep {
301 title: String,
302 state: StepState,
303 note: Option<String>,
304}
305
306#[cfg_attr(feature = "live", ravenrs_live::expose)]
307impl IndicatorStep {
308 pub fn new(title: impl Into<String>, state: StepState) -> Self {
310 Self { title: title.into(), state, note: None }
311 }
312
313 pub fn note(mut self, note: impl Into<String>) -> Self {
315 self.note = Some(note.into());
316 self
317 }
318}
319
320#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
337pub struct StepIndicator {
338 steps: Vec<IndicatorStep>,
339 vertical: bool,
340 on_select: Option<Rc<dyn Fn(usize)>>,
341}
342
343impl fmt::Debug for StepIndicator {
344 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345 f.debug_struct("StepIndicator").field("steps", &self.steps).finish_non_exhaustive()
346 }
347}
348
349#[cfg_attr(feature = "live", ravenrs_live::expose)]
350impl StepIndicator {
351 pub fn new(steps: Vec<IndicatorStep>) -> Self {
353 Self { steps, vertical: false, on_select: None }
354 }
355
356 pub fn vertical(mut self) -> Self {
358 self.vertical = true;
359 self
360 }
361
362 pub fn on_select(mut self, handler: impl Fn(usize) + 'static) -> Self {
365 self.on_select = Some(Rc::new(handler));
366 self
367 }
368}
369
370impl View for StepIndicator {
371 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
372 let labels = cx.labels();
373 let mut rows = Vec::with_capacity(self.steps.len());
374 for (i, step) in self.steps.iter().enumerate() {
375 let on_click = self.on_select.clone().map(|select| Rc::new(move || select(i)) as Rc<dyn Fn()>);
376 rows.push(cx.keyed_child(
377 i as u64,
378 &StepRow {
379 number: i + 1,
380 title: &step.title,
381 note: step.note.as_deref(),
382 state: step.state,
383 on_click,
384 stretch: self.vertical,
385 },
386 ));
387 }
388 let style = if self.vertical {
389 LayoutStyle { spacing: 4.0, align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) }
390 } else {
391 LayoutStyle {
392 spacing: 4.0,
393 wrap: true,
394 align: Align::Center,
395 ..LayoutStyle::stack(Axis::Horizontal)
396 }
397 };
398 let node = cx.container(style, &rows, Paint::None);
399 let current = self.steps.iter().position(|s| s.state == StepState::Current).map_or(0, |i| i + 1);
400 cx.semantics(
401 node,
402 Semantics::new(Role::List).name(labels.wizard_progress(current, self.steps.len())),
403 );
404 node
405 }
406}
407
408fn advance(
410 check: Option<StepCheck>,
411 error: &State<Option<String>>,
412 step: &Binding<usize>,
413 next: Option<usize>,
414 finish: Option<&Rc<dyn Fn()>>,
415) {
416 if let Some(check) = check
417 && let Err(message) = check()
418 {
419 error.set(Some(message));
420 return;
421 }
422 error.set(None);
423 match next {
424 Some(next) => step.set(next),
425 None => {
426 if let Some(finish) = finish {
427 finish();
428 }
429 }
430 }
431}
432
433impl View for Wizard {
434 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
435 let labels = cx.labels();
436 let error: State<Option<String>> = cx.use_state(|| None);
437 let count = self.steps.len();
438 if count == 0 {
439 return cx.empty();
440 }
441 let current = self.step.get().min(count - 1);
442 let mut marks = Vec::with_capacity(count);
443 for (index, step) in self.steps.iter().enumerate() {
444 let state = match index.cmp(¤t) {
445 _ if step.failed => StepState::Error,
446 std::cmp::Ordering::Less => StepState::Done,
447 std::cmp::Ordering::Equal => StepState::Current,
448 std::cmp::Ordering::Greater => StepState::Upcoming,
449 };
450 let on_click = (index < current).then(|| {
452 let (moved, error) = (self.step.clone(), error.clone());
453 Rc::new(move || {
454 error.set(None);
455 moved.set(index);
456 }) as Rc<dyn Fn()>
457 });
458 let mark = StepRow {
459 number: index + 1,
460 title: &step.title,
461 note: None,
462 state,
463 on_click,
464 stretch: false,
465 };
466 marks.push(cx.keyed_child(index as u64, &mark));
467 }
468 let header = cx.container(
469 LayoutStyle {
470 spacing: 4.0,
471 wrap: true,
472 align: Align::Center,
473 ..LayoutStyle::stack(Axis::Horizontal)
474 },
475 &marks,
476 Paint::None,
477 );
478 cx.semantics(header, Semantics::new(Role::List).name(labels.wizard_progress(current + 1, count)));
479 let step = &self.steps[current];
480 let mut body = Vec::new();
481 if let Some(message) = error.get() {
482 body.push(cx.child(&Alert::new(step.title.clone(), message).tone(Tone::Danger)));
483 }
484 body.push(cx.keyed_child(current as u64 + 1, step.content.as_ref()));
485 let content = cx.container(
486 LayoutStyle {
487 grow: 1.0,
488 spacing: 12.0,
489 align: Align::Stretch,
490 align_self: Some(Align::Stretch),
491 ..LayoutStyle::stack(Axis::Vertical)
492 },
493 &body,
494 Paint::None,
495 );
496 let mut buttons = Vec::new();
497 if let Some(cancel) = &self.on_cancel {
498 let cancel = cancel.clone();
499 buttons.push(
500 cx.child(
501 &Button::new(labels.wizard_cancel.clone())
502 .style(ButtonStyle::Ghost)
503 .on_click(move || cancel()),
504 ),
505 );
506 }
507 buttons.push(cx.container(LayoutStyle { grow: 1.0, ..LayoutStyle::leaf() }, &[], Paint::None));
508 if current > 0 {
509 let (back, failed) = (self.step.clone(), error.clone());
510 buttons.push(cx.child(
511 &Button::new(labels.wizard_back.clone()).style(ButtonStyle::Secondary).on_click(move || {
512 failed.set(None);
513 back.set(current - 1);
514 }),
515 ));
516 }
517 let last = current + 1 == count;
518 let text = if last {
519 self.finish_label.clone().unwrap_or_else(|| labels.wizard_finish.clone())
520 } else {
521 labels.wizard_next.clone()
522 };
523 let (check, failed, moved, finish) =
524 (step.check.clone(), error.clone(), self.step.clone(), self.on_finish.clone());
525 buttons.push(cx.child(&Button::new(text).on_click(move || {
526 advance(check.clone(), &failed, &moved, (!last).then_some(current + 1), finish.as_ref());
527 })));
528 let footer = cx.container(
529 LayoutStyle {
530 spacing: 8.0,
531 align: Align::Center,
532 align_self: Some(Align::Stretch),
533 ..LayoutStyle::stack(Axis::Horizontal)
534 },
535 &buttons,
536 Paint::None,
537 );
538 cx.container(
539 LayoutStyle { spacing: 16.0, align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
540 &[header, content, footer],
541 Paint::None,
542 )
543 }
544}