1use std::{cmp::Ordering, fmt::Write};
2
3use omp_core::Str;
4use serde_json::Value;
5
6use crate::{
7 component::{
8 Cached, Component, EventCtx, Flow, Hit, HitTag, IntoChildren, IntoComponent, PaintCtx, Slot,
9 next_slot,
10 },
11 context::UiContext,
12 frame::{Color, Frame, Rect, Style},
13 input::{Key, Mouse, UiEvent},
14 props::{Prop, PropValue, Props},
15 rich::cell_width,
16};
17
18#[derive(Default)]
19struct WizardState {
20 idx: u16,
21 error: Option<String>,
22 button: u8,
23 spans: Vec<(u16, u16)>,
24 rect: Option<Rect>,
25 scratch: String,
26 rule: String,
27}
28
29pub struct Wizard {
31 props: Props,
32 slot: Slot,
33 steps: Vec<Cached>,
34 state: WizardState,
35}
36
37impl Wizard {
38 pub fn new() -> Self {
40 Self {
41 props: Props::new(),
42 slot: next_slot(),
43 steps: Vec::new(),
44 state: WizardState::default(),
45 }
46 }
47
48 pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
50 self.props.set(prop, value);
51 self
52 }
53
54 pub fn with_str(self, prop: Prop, value: &str) -> Self {
56 self.with(prop, value)
57 }
58
59 pub fn child(mut self, children: impl IntoChildren) -> Self {
61 children.extend_children(&mut self.steps);
62 self
63 }
64
65 pub fn step(mut self, title: impl Into<Str>, children: impl IntoChildren) -> Self {
67 let pane = super::Col::new()
68 .with(Prop::Title, title.into())
69 .child(children);
70 self.steps.push(Cached::new(pane.into_component()));
71 self
72 }
73
74 #[allow(dead_code, reason = "acceptance-suite probe")]
75 pub(crate) fn error(&self) -> Option<&str> {
76 self.state.error.as_deref()
77 }
78
79 #[allow(dead_code, reason = "acceptance-suite probe")]
80 pub(crate) fn step_index(&self) -> usize {
81 usize::from(self.state.idx)
82 }
83
84 fn active(&self) -> Option<usize> {
85 let index = usize::from(self.state.idx);
86 (index < self.steps.len()).then_some(index)
87 }
88
89 fn validate_step(&self) -> Option<String> {
90 let step = self.steps.get(self.active()?)?;
91 validate_cached(step)
92 }
93
94 fn next(&mut self) -> UiEvent {
95 if let Some(error) = self.validate_step() {
96 self.state.error = Some(error);
97 return UiEvent::None;
98 }
99 self.state.error = None;
100 if usize::from(self.state.idx) + 1 < self.steps.len() {
101 self.state.idx += 1;
102 UiEvent::None
103 } else if self.props.flag(Prop::Submit) {
104 UiEvent::Submit
105 } else {
106 UiEvent::None
107 }
108 }
109
110 fn back(&mut self) {
111 self.state.error = None;
112 self.state.idx = self.state.idx.saturating_sub(1);
113 }
114}
115
116impl Default for Wizard {
117 fn default() -> Self {
118 Self::new()
119 }
120}
121
122impl Component for Wizard {
123 fn props(&self) -> &Props {
124 &self.props
125 }
126
127 fn props_mut(&mut self) -> &mut Props {
128 &mut self.props
129 }
130
131 fn slot(&self) -> Slot {
132 self.slot
133 }
134
135 fn children(&self) -> &[Cached] {
136 &self.steps
137 }
138
139 fn children_mut(&mut self) -> &mut [Cached] {
140 &mut self.steps
141 }
142
143 fn measure(&mut self, ctx: &UiContext) -> (u16, u16) {
144 let chips = u16::try_from(self.steps.len())
145 .unwrap_or(u16::MAX)
146 .saturating_mul(12);
147 let mut natural = chips.max(24);
148 for step in &mut self.steps {
149 natural = natural.max(step.measure(ctx).1);
150 }
151 (24, natural)
152 }
153
154 fn height(&mut self, ctx: &UiContext, width: u16) -> u16 {
155 let content = if let Some(active) = self.active() {
156 self.steps[active].height(ctx, width)
157 } else {
158 0
159 };
160 content
161 .saturating_add(3)
162 .saturating_add(u16::from(self.state.error.is_some()))
163 }
164
165 fn place(&mut self, ctx: &UiContext, content: Rect) {
166 self.state.rect = Some(content);
167 if let Some(active) = self.active() {
168 let height = self.steps[active].height(ctx, content.width);
169 self.steps[active]
170 .place(ctx, Rect::new(content.x, content.y.saturating_add(2), content.width, height));
171 }
172 }
173
174 fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
175 self.state.rect = Some(rect);
176 let focused = pc.focus == Some(self.slot);
177 let base = Style::new().fg(pc.ctx.theme.fg);
178 self.state.spans.clear();
179 if rect.y < pc.clip {
180 let mut x = rect.x;
181 for (index, step) in self.steps.iter().enumerate() {
182 let title = step.comp().props().title().map_or("step", Str::as_str);
183 let start = x.saturating_sub(rect.x);
184 match u16::try_from(index)
185 .unwrap_or(u16::MAX)
186 .cmp(&self.state.idx)
187 {
188 Ordering::Less => {
189 x = pc.frame.put(
190 x,
191 rect.y,
192 pc.ctx.charset.check(),
193 Style::new().fg(pc.ctx.theme.ok),
194 );
195 x = pc
196 .frame
197 .put(x, rect.y, " ", Style::new().fg(pc.ctx.theme.ok));
198 x = pc
199 .frame
200 .put(x, rect.y, title, Style::new().fg(pc.ctx.theme.ok));
201 },
202 Ordering::Equal => {
203 self.state.scratch.clear();
204 let _ = write!(self.state.scratch, " {} {} ", index + 1, title);
205 x = pill(
206 pc.frame,
207 x,
208 rect.y,
209 &self.state.scratch,
210 pc.ctx.theme.accent,
211 pc.ctx.theme.contrast,
212 pc.ctx.charset.pill_caps(),
213 focused,
214 );
215 },
216 Ordering::Greater => {
217 self.state.scratch.clear();
218 let _ = write!(self.state.scratch, "{} {}", index + 1, title);
219 x = pc.frame.put(
220 x,
221 rect.y,
222 &self.state.scratch,
223 Style::new().fg(pc.ctx.theme.muted),
224 );
225 },
226 }
227 let end = x.saturating_sub(rect.x);
228 self.state.spans.push((start, end));
229 if end > start {
230 pc.hits.push(Hit {
231 rect: Rect::new(rect.x.saturating_add(start), rect.y, end - start, 1),
232 slot: self.slot,
233 tag: HitTag::Chip(index as u16),
234 });
235 }
236 x = pc.frame.put(x, rect.y, " ", base);
237 }
238 }
239 if rect.y.saturating_add(1) < pc.clip {
240 self.state.rule.clear();
241 for _ in 0..rect.width {
242 self.state.rule.push(pc.ctx.charset.rule());
243 }
244 pc.frame.put(
245 rect.x,
246 rect.y.saturating_add(1),
247 &self.state.rule,
248 Style::new().fg(pc.ctx.theme.muted),
249 );
250 }
251 if let Some(active) = self.active() {
252 self.steps[active].paint(pc);
253 }
254
255 let y = rect.y.saturating_add(rect.height).saturating_sub(1);
256 if let Some(error) = &self.state.error {
257 let error_y = y.saturating_sub(1);
258 if error_y < pc.clip {
259 let style = Style::new().fg(pc.ctx.theme.warn);
260 let mut x =
261 pc.frame
262 .put(rect.x, error_y, pc.ctx.charset.icon(crate::Icon::Warning), style);
263 x = pc.frame.put(x, error_y, " ", style);
264 pc.frame.put(x, error_y, error, style);
265 }
266 }
267 if y < pc.clip {
268 let last = usize::from(self.state.idx) + 1 == self.steps.len();
269 let next_label = if last { "Finish" } else { "Next" };
270 let back_x = rect.x.saturating_add(
271 rect
272 .width
273 .saturating_sub(cell_width(next_label) + cell_width("Back") + 9),
274 );
275 let x = pill(
276 pc.frame,
277 back_x,
278 y,
279 " Back ",
280 pc.ctx.theme.surface,
281 pc.ctx.theme.fg,
282 pc.ctx.charset.pill_caps(),
283 focused && self.state.button == 0,
284 );
285 self.state.scratch.clear();
286 let _ = write!(self.state.scratch, " {next_label} ");
287 pill(
288 pc.frame,
289 x.saturating_add(2),
290 y,
291 &self.state.scratch,
292 pc.ctx.theme.accent,
293 pc.ctx.theme.contrast,
294 pc.ctx.charset.pill_caps(),
295 focused && self.state.button == 1,
296 );
297 pc.hits.push(Hit {
298 rect: Rect::new(rect.x, y, rect.width, 1),
299 slot: self.slot,
300 tag: HitTag::Press,
301 });
302 }
303 }
304
305 fn focusable(&self) -> bool {
306 true
307 }
308
309 fn ring(&self, out: &mut Vec<Slot>) {
310 if let Some(active) = self.active()
311 && self.steps[active].visible
312 {
313 self.steps[active].comp().ring(out);
314 }
315 out.push(self.slot);
316 }
317
318 fn key(&mut self, _ec: &mut EventCtx<'_>, key: Key) -> Flow {
319 match key {
320 Key::Left => {
321 self.state.button = 0;
322 Flow::Consumed
323 },
324 Key::Right => {
325 self.state.button = 1;
326 Flow::Consumed
327 },
328 Key::Enter | Key::Space if self.state.button == 0 => {
329 self.back();
330 Flow::Consumed
331 },
332 Key::Enter | Key::Space => match self.next() {
333 UiEvent::None => Flow::Consumed,
334 event => Flow::Event(event),
335 },
336 _ => Flow::Skip,
337 }
338 }
339
340 fn mouse(
341 &mut self,
342 _ec: &mut EventCtx<'_>,
343 tag: HitTag,
344 at: (u16, u16),
345 _rect: Rect,
346 mouse: Mouse,
347 ) -> Flow {
348 match mouse {
349 Mouse::Click => match tag {
350 HitTag::Chip(target) => {
351 if target < self.state.idx {
352 self.state.idx = target;
353 self.state.error = None;
354 }
355 Flow::Consumed
356 },
357 HitTag::Press => {
358 let Some(rect) = self.state.rect else {
359 return Flow::Skip;
360 };
361 let midpoint = rect.x.saturating_add(rect.width.saturating_sub(9));
362 if at.0 >= midpoint {
363 self.state.button = 1;
364 match self.next() {
365 UiEvent::None => Flow::Consumed,
366 event => Flow::Event(event),
367 }
368 } else {
369 self.state.button = 0;
370 self.back();
371 Flow::Consumed
372 }
373 },
374 _ => Flow::Skip,
375 },
376 Mouse::RightClick
377 | Mouse::MiddleClick
378 | Mouse::Move
379 | Mouse::Drag
380 | Mouse::Release
381 | Mouse::WheelUp
382 | Mouse::WheelDown
383 | Mouse::WheelLeft
384 | Mouse::WheelRight => Flow::Skip,
385 }
386 }
387}
388
389pub fn match_simple(pattern: &str, text: &str) -> bool {
395 #[derive(Clone)]
396 enum Atom {
397 Literal(char),
398 Class(Vec<(char, char)>, bool),
399 Any,
400 }
401 fn atom_matches(atom: &Atom, c: char) -> bool {
402 match atom {
403 Atom::Literal(l) => *l == c,
404 Atom::Any => true,
405 Atom::Class(ranges, negated) => {
406 let inside = ranges.iter().any(|&(lo, hi)| c >= lo && c <= hi);
407 inside != *negated
408 },
409 }
410 }
411 let mut atoms: Vec<(Atom, char)> = Vec::new();
413 let mut chars = pattern.chars().peekable();
414 while let Some(c) = chars.next() {
415 let atom = match c {
416 '.' => Atom::Any,
417 '[' => {
418 let mut ranges = Vec::new();
419 let negated = chars.peek() == Some(&'^');
420 if negated {
421 chars.next();
422 }
423 let mut prev: Option<char> = None;
424 loop {
425 match chars.next() {
426 None | Some(']') => break,
427 Some('-') => {
428 let (Some(lo), Some(&hi)) = (prev, chars.peek()) else {
429 ranges.push(('-', '-'));
430 continue;
431 };
432 chars.next();
433 ranges.pop();
434 ranges.push((lo, hi));
435 prev = None;
436 },
437 Some(other) => {
438 ranges.push((other, other));
439 prev = Some(other);
440 },
441 }
442 }
443 Atom::Class(ranges, negated)
444 },
445 '\\' => Atom::Literal(chars.next().unwrap_or('\\')),
446 other => Atom::Literal(other),
447 };
448 let quantifier = match chars.peek() {
449 Some(&q @ ('*' | '+' | '?')) => {
450 chars.next();
451 q
452 },
453 _ => ' ',
454 };
455 atoms.push((atom, quantifier));
456 }
457 fn matches_at(atoms: &[(Atom, char)], text: &[char], ai: usize, ti: usize) -> bool {
459 let Some((atom, quantifier)) = atoms.get(ai) else {
460 return ti == text.len();
461 };
462 match quantifier {
463 '*' | '+' => {
464 let mut count = 0usize;
465 let minimum = usize::from(*quantifier == '+');
466 loop {
467 if count >= minimum && matches_at(atoms, text, ai + 1, ti + count) {
468 return true;
469 }
470 match text.get(ti + count) {
471 Some(&c) if atom_matches(atom, c) => count += 1,
472 _ => return false,
473 }
474 }
475 },
476 '?' => {
477 if matches_at(atoms, text, ai + 1, ti) {
478 return true;
479 }
480 matches!(text.get(ti), Some(&c) if atom_matches(atom, c))
481 && matches_at(atoms, text, ai + 1, ti + 1)
482 },
483 _ => {
484 matches!(text.get(ti), Some(&c) if atom_matches(atom, c))
485 && matches_at(atoms, text, ai + 1, ti + 1)
486 },
487 }
488 }
489 let text: Vec<char> = text.chars().collect();
490 matches_at(&atoms, &text, 0, 0)
491}
492
493fn validate_cached(cached: &Cached) -> Option<String> {
494 if !cached.visible {
495 return None;
496 }
497 let component = cached.comp();
498 let props = component.props();
499 if let Some(id) = props.id() {
500 let mut values = serde_json::Map::new();
501 component.value(&mut values);
502 if let Some(value) = values.get(id.as_str()) {
503 let text = display_value(value);
504 if props.flag(Prop::Required) && text.trim().is_empty() {
505 return Some(format!("{id} is required"));
506 }
507 if let Some(pattern) = props.str_of(Prop::Match)
508 && !text.trim().is_empty()
509 && !match_simple(pattern, text.trim())
510 {
511 return Some(format!("{id} must match {pattern}"));
512 }
513 }
514 }
515 if let Some(error) = component.validation_error() {
516 return Some(error);
517 }
518 for child in component.children() {
519 if let Some(error) = validate_cached(child) {
520 return Some(error);
521 }
522 }
523 None
524}
525
526pub(super) fn display_value(value: &Value) -> String {
527 match value {
528 Value::Null => String::new(),
529 Value::String(value) => value.clone(),
530 Value::Array(values) => values
531 .iter()
532 .map(display_value)
533 .collect::<Vec<_>>()
534 .join(" "),
535 Value::Bool(value) => value.to_string(),
536 Value::Number(value) => value.to_string(),
537 Value::Object(_) => value.to_string(),
538 }
539}
540
541fn pill(
542 frame: &mut Frame,
543 x: u16,
544 y: u16,
545 label: &str,
546 background: Color,
547 foreground: Color,
548 caps: (&str, &str),
549 highlight: bool,
550) -> u16 {
551 let background = if highlight {
552 brighten(background)
553 } else {
554 background
555 };
556 let cap = Style::new().fg(background);
557 let body = Style::new().fg(foreground).bg(background).bold();
558 let mut x = frame.put(x, y, caps.0, cap);
559 x = frame.put(x, y, label, body);
560 frame.put(x, y, caps.1, cap)
561}
562
563fn brighten(color: Color) -> Color {
564 match color {
565 Color::Rgb(red, green, blue) => Color::Rgb(
566 red.saturating_add((255 - u16::from(red)) as u8 / 5),
567 green.saturating_add((255 - u16::from(green)) as u8 / 5),
568 blue.saturating_add((255 - u16::from(blue)) as u8 / 5),
569 ),
570 other => other,
571 }
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577 use crate::{
578 component::EventCtx,
579 components::{Field, Form, Input},
580 };
581
582 #[test]
583 fn next_and_back_switch_the_active_ring_before_the_wizard_slot() {
584 let ctx = UiContext::default();
585 let first = Input::new();
586 let first_slot = first.slot();
587 let second = Input::new();
588 let second_slot = second.slot();
589 let mut wizard = Wizard::new().step("First", first).step("Second", second);
590 let wizard_slot = wizard.slot();
591 let mut ring = Vec::new();
592 wizard.ring(&mut ring);
593 assert_eq!(ring, vec![first_slot, wizard_slot]);
594
595 let mut ec = EventCtx::new(&ctx, 30, 6);
596 assert_eq!(wizard.key(&mut ec, Key::Right), Flow::Consumed);
597 assert_eq!(wizard.key(&mut ec, Key::Enter), Flow::Consumed);
598 ring.clear();
599 wizard.ring(&mut ring);
600 assert_eq!(ring, vec![second_slot, wizard_slot]);
601
602 assert_eq!(wizard.key(&mut ec, Key::Left), Flow::Consumed);
603 assert_eq!(wizard.key(&mut ec, Key::Enter), Flow::Consumed);
604 ring.clear();
605 wizard.ring(&mut ring);
606 assert_eq!(ring, vec![first_slot, wizard_slot]);
607 }
608
609 #[test]
610 fn required_value_blocks_next_step() {
611 let ctx = UiContext::default();
612 let required = Input::new()
613 .with(Prop::Id, "name")
614 .with(Prop::Required, true);
615 let required_slot = required.slot();
616 let second = Input::new();
617 let second_slot = second.slot();
618 let mut wizard = Wizard::new().step("Details", required).step("Done", second);
619 let wizard_slot = wizard.slot();
620 let mut ec = EventCtx::new(&ctx, 30, 6);
621 wizard.key(&mut ec, Key::Right);
622 assert_eq!(wizard.key(&mut ec, Key::Enter), Flow::Consumed);
623
624 let mut ring = Vec::new();
625 wizard.ring(&mut ring);
626 assert_eq!(ring, vec![required_slot, wizard_slot]);
627 assert!(!ring.contains(&second_slot));
628 assert_eq!(wizard.state.error.as_deref(), Some("name is required"));
629 }
630
631 #[test]
632 fn form_field_validation_blocks_and_allows_next_step() {
633 let empty = Form::new().field(
634 Field::new()
635 .with(Prop::Id, "name")
636 .with(Prop::Required, true),
637 );
638 let mut blocked = Wizard::new()
639 .step("Details", empty)
640 .step("Done", Input::new());
641 assert_eq!(blocked.next(), UiEvent::None);
642 assert_eq!(blocked.step_index(), 0);
643 assert_eq!(blocked.error(), Some("name is required"));
644
645 let filled = Form::new().field(
646 Field::new()
647 .with(Prop::Id, "name")
648 .with(Prop::Required, true)
649 .with(Prop::Value, "OMP"),
650 );
651 let mut allowed = Wizard::new()
652 .step("Details", filled)
653 .step("Done", Input::new());
654 assert_eq!(allowed.next(), UiEvent::None);
655 assert_eq!(allowed.step_index(), 1);
656 assert_eq!(allowed.error(), None);
657 }
658}