Skip to main content

omp_tui/components/
wizard.rs

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
29/// A validated sequence of step panes backing the `<wizard>` markup tag.
30pub struct Wizard {
31	props: Props,
32	slot:  Slot,
33	steps: Vec<Cached>,
34	state: WizardState,
35}
36
37impl Wizard {
38	/// Creates an empty wizard.
39	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	/// Sets one wizard property.
49	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
50		self.props.set(prop, value);
51		self
52	}
53
54	/// Sets one wizard property from a string.
55	pub fn with_str(self, prop: Prop, value: &str) -> Self {
56		self.with(prop, value)
57	}
58
59	/// Appends an untitled step pane.
60	pub fn child(mut self, children: impl IntoChildren) -> Self {
61		children.extend_children(&mut self.steps);
62		self
63	}
64
65	/// Appends a titled step pane.
66	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
389/// Tiny anchored pattern matcher for `match=` validation. Supported
390/// syntax: literals, `[a-z0-9-]` classes (ranges + literals, leading `^`
391/// negation), and the postfix quantifiers `*` `+` `?` on the previous
392/// atom. This is NOT a regex engine; unsupported constructs match
393/// literally.
394pub 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	// parse into (atom, quantifier) pairs
412	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	// backtracking match, anchored both ends
458	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}