1use std::fmt::Write as _;
2
3use omp_core::Str;
4use serde_json::{Map, Value};
5use smallvec::SmallVec;
6
7use crate::{
8 component::{Component, EventCtx, Flow, Hit, HitTag, IntoChildren, PaintCtx, Slot, next_slot},
9 context::{Theme, UiContext},
10 frame::{Rect, Style},
11 input::{Key, Mouse, sanitize_paste, word_rubout_start},
12 props::{Prop, PropValue, Props},
13 rich::cell_width,
14};
15
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17enum FieldKind {
18 Bool,
19 Enum,
20 Text,
21 Select,
22 Multi,
23 Number,
24}
25
26#[derive(Clone, Debug)]
27enum FieldValue {
28 Bool(bool),
29 Text(String),
30 Choice(Str),
31 Many(SmallVec<Str, 4>),
32 Number(i64),
33}
34
35pub struct Field {
37 props: Props,
38 label: Str,
39 children: Vec<crate::component::Cached>,
40}
41
42impl Field {
43 pub fn new() -> Self {
45 Self { props: Props::new(), label: Str::new(""), children: Vec::new() }
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(mut self, prop: Prop, value: &str) -> Self {
56 self.props.set(prop, value);
57 self
58 }
59
60 pub fn label(mut self, label: impl Into<Str>) -> Self {
62 self.label = label.into();
63 self
64 }
65
66 pub fn child(mut self, children: impl IntoChildren) -> Self {
68 children.extend_children(&mut self.children);
69 self
70 }
71}
72
73impl Default for Field {
74 fn default() -> Self {
75 Self::new()
76 }
77}
78
79#[derive(Clone, Debug)]
80struct FieldData {
81 kind: FieldKind,
82 id: Str,
83 label: Str,
84 desc: Option<Str>,
85 options: SmallVec<Str, 8>,
86 value: FieldValue,
87 required: bool,
88 pattern: Option<Str>,
89 min: i64,
90 max: i64,
91 step: i64,
92}
93
94impl FieldData {
95 fn from_field(field: Field) -> Self {
96 let kind = match field.props.str_of(Prop::Kind).map(Str::as_str) {
97 Some("bool") => FieldKind::Bool,
98 Some("enum") => FieldKind::Enum,
99 Some("select") => FieldKind::Select,
100 Some("multi") => FieldKind::Multi,
101 Some("number") => FieldKind::Number,
102 _ => FieldKind::Text,
103 };
104 let options: SmallVec<Str, 8> = field
105 .props
106 .str_of(Prop::Options)
107 .map(|options| options.split_whitespace().map(Str::new).collect())
108 .unwrap_or_default();
109 let raw = field.props.str_of(Prop::Value);
110 let value = match kind {
111 FieldKind::Bool => FieldValue::Bool(raw.is_some_and(|value| value == "true")),
112 FieldKind::Enum | FieldKind::Select => FieldValue::Choice(
113 raw.filter(|value| options.iter().any(|option| option == *value))
114 .cloned()
115 .or_else(|| options.first().cloned())
116 .unwrap_or_default(),
117 ),
118 FieldKind::Multi => FieldValue::Many(
119 raw.map(|value| {
120 options
121 .iter()
122 .filter(|option| value.split_whitespace().any(|part| *option == part))
123 .cloned()
124 .collect()
125 })
126 .unwrap_or_default(),
127 ),
128 FieldKind::Number => {
129 FieldValue::Number(raw.and_then(|value| value.parse().ok()).unwrap_or(0))
130 },
131 FieldKind::Text => FieldValue::Text(raw.map(ToString::to_string).unwrap_or_default()),
132 };
133 let i64_prop = |prop| match field.props.get(prop) {
134 Some(PropValue::I64(value)) => Some(*value),
135 Some(PropValue::U16(value)) => Some(i64::from(*value)),
136 Some(PropValue::Str(value)) => value.parse().ok(),
137 _ => None,
138 };
139 let id = field.props.id().cloned().unwrap_or_default();
140 let label = if field.label.is_empty() {
141 field
142 .props
143 .str_of(Prop::Label)
144 .cloned()
145 .unwrap_or_else(|| id.clone())
146 } else {
147 field.label
148 };
149 Self {
150 kind,
151 id,
152 label,
153 desc: field.props.str_of(Prop::Desc).cloned(),
154 options,
155 value,
156 required: field.props.flag(Prop::Required),
157 pattern: field.props.str_of(Prop::Match).cloned(),
158 min: i64_prop(Prop::Min).unwrap_or(i64::MIN),
159 max: i64_prop(Prop::Max).unwrap_or(i64::MAX),
160 step: i64_prop(Prop::Step).unwrap_or(1),
161 }
162 }
163}
164
165pub struct Form {
167 props: Props,
168 slot: Slot,
169 fields: Vec<FieldData>,
170 cursor: u16,
171 editing: bool,
172 open: Option<u16>,
173 sub_cursor: u16,
174 scratch: String,
175}
176
177impl Form {
178 pub fn new() -> Self {
180 Self {
181 props: Props::new(),
182 slot: next_slot(),
183 fields: Vec::new(),
184 cursor: 0,
185 editing: false,
186 open: None,
187 sub_cursor: 0,
188 scratch: String::new(),
189 }
190 }
191
192 pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
194 self.props.set(prop, value);
195 self
196 }
197
198 pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
200 self.props.set(prop, value);
201 self
202 }
203
204 pub fn field(mut self, field: Field) -> Self {
206 self.fields.push(FieldData::from_field(field));
207 self
208 }
209
210 fn activate(&mut self) {
211 let cursor = self.cursor;
212 let Some(field) = self.fields.get_mut(usize::from(cursor)) else {
213 return;
214 };
215 match field.kind {
216 FieldKind::Bool => {
217 if let FieldValue::Bool(value) = &mut field.value {
218 *value = !*value;
219 }
220 },
221 FieldKind::Enum => cycle_choice(field, true),
222 FieldKind::Select | FieldKind::Multi => {
223 self.open = Some(cursor);
224 self.sub_cursor = match (&field.value, field.kind) {
225 (FieldValue::Choice(choice), FieldKind::Select) => field
226 .options
227 .iter()
228 .position(|option| option == choice)
229 .unwrap_or(0) as u16,
230 _ => 0,
231 };
232 },
233 FieldKind::Text => self.editing = true,
234 FieldKind::Number => {},
235 }
236 }
237
238 fn click_row(&mut self, index: u16) {
239 if usize::from(index) >= self.fields.len() {
240 return;
241 }
242 self.cursor = index;
243 if self.open.is_some() && self.open != Some(index) {
244 self.open = None;
245 }
246 self.activate();
247 }
248
249 fn click_sub(&mut self, index: u16) {
250 let Some(open) = self.open else { return };
251 self.sub_cursor = index;
252 let field = &mut self.fields[usize::from(open)];
253 if field.kind == FieldKind::Multi {
254 toggle_multi(field, index);
255 } else {
256 if let Some(option) = field.options.get(usize::from(index)) {
257 field.value = FieldValue::Choice(option.clone());
258 }
259 self.open = None;
260 }
261 }
262}
263
264impl Default for Form {
265 fn default() -> Self {
266 Self::new()
267 }
268}
269
270impl Component for Form {
271 fn props(&self) -> &Props {
272 &self.props
273 }
274
275 fn props_mut(&mut self) -> &mut Props {
276 &mut self.props
277 }
278
279 fn slot(&self) -> Slot {
280 self.slot
281 }
282
283 fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
284 let natural = self
285 .fields
286 .iter()
287 .map(|field| cell_width(&field.label) + 24)
288 .max()
289 .unwrap_or(24);
290 (24, natural)
291 }
292
293 fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
294 let mut height = self.fields.len() as u16;
295 if let Some(open) = self.open {
296 height += self.fields[usize::from(open)].options.len() as u16;
297 }
298 if self
299 .fields
300 .get(usize::from(self.cursor))
301 .is_some_and(|field| field.desc.is_some())
302 {
303 height += 1;
304 }
305 height
306 }
307
308 fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
309 let focused = pc.focus == Some(self.slot);
310 let label_width = self
311 .fields
312 .iter()
313 .map(|field| cell_width(&field.label))
314 .max()
315 .unwrap_or(8)
316 + 2;
317 let mut hit_y = rect.y;
318 for (index, field) in self.fields.iter().enumerate() {
319 pc.hits.push(Hit {
320 rect: Rect::new(rect.x, hit_y, rect.width, 1),
321 slot: self.slot,
322 tag: HitTag::Row(index as u16),
323 });
324 hit_y = hit_y.saturating_add(1);
325 if self.open == Some(index as u16) {
326 for option_index in 0..field.options.len() as u16 {
327 pc.hits.push(Hit {
328 rect: Rect::new(rect.x, hit_y, rect.width, 1),
329 slot: self.slot,
330 tag: HitTag::Sub(option_index),
331 });
332 hit_y = hit_y.saturating_add(1);
333 }
334 }
335 }
336 let mut y = rect.y;
337 for (index, field) in self.fields.iter().enumerate() {
338 if y >= pc.clip {
339 return;
340 }
341 let here = focused && index as u16 == self.cursor;
342 let hovered = matches!(pc.hover, Some((slot, HitTag::Row(row))) if slot == self.slot && row == index as u16);
343 if hovered {
344 pc.frame
345 .fill(Rect::new(rect.x, y, rect.width, 1), Style::new().bg(pc.ctx.theme.hover));
346 }
347 let tint = |style: Style| {
348 if hovered {
349 style.bg(pc.ctx.theme.hover)
350 } else {
351 style
352 }
353 };
354 let mut x = pc.frame.put(
355 rect.x,
356 y,
357 if here { pc.ctx.charset.cursor() } else { " " },
358 tint(Style::new().fg(pc.ctx.theme.accent)),
359 );
360 let label_style = if here {
361 tint(Style::new().fg(pc.ctx.theme.accent).bold())
362 } else {
363 tint(base(&pc.ctx.theme))
364 };
365 x = pc.frame.put(x, y, &field.label, label_style);
366 for _ in cell_width(&field.label)..label_width {
367 x = pc.frame.put(x, y, " ", tint(base(&pc.ctx.theme)));
368 }
369 x = paint_field_value(pc.ctx, pc.frame, x, y, field, here, tint, &mut self.scratch);
370 if here && self.editing {
371 pc.frame
372 .put(x, y, pc.ctx.charset.beam(), tint(Style::new().fg(pc.ctx.theme.accent)));
373 }
374 y += 1;
375 if self.open == Some(index as u16) {
376 for (option_index, option) in field.options.iter().enumerate() {
377 if y >= pc.clip {
378 return;
379 }
380 let sub_here = option_index as u16 == self.sub_cursor;
381 let picked = match &field.value {
382 FieldValue::Choice(choice) => choice == option,
383 FieldValue::Many(values) => values.contains(option),
384 _ => false,
385 };
386 let mark = if field.kind == FieldKind::Multi {
387 pc.ctx.charset.checkbox(picked)
388 } else {
389 pc.ctx.charset.radio(picked)
390 };
391 let mut sx = pc.frame.put(
392 rect.x + 4,
393 y,
394 if sub_here {
395 pc.ctx.charset.cursor()
396 } else {
397 " "
398 },
399 Style::new().fg(pc.ctx.theme.accent),
400 );
401 sx = pc.frame.put(
402 sx,
403 y,
404 mark,
405 Style::new().fg(if picked {
406 pc.ctx.theme.ok
407 } else {
408 pc.ctx.theme.muted
409 }),
410 );
411 sx = pc.frame.put(sx, y, " ", base(&pc.ctx.theme));
412 pc.frame.put(
413 sx,
414 y,
415 option,
416 if sub_here {
417 Style::new().fg(pc.ctx.theme.accent).bold()
418 } else {
419 base(&pc.ctx.theme)
420 },
421 );
422 y += 1;
423 }
424 }
425 }
426 if let Some(field) = self.fields.get(usize::from(self.cursor))
427 && let Some(desc) = &field.desc
428 && y < pc.clip
429 {
430 pc.frame.put(rect.x + 2, y, desc, dim(&pc.ctx.theme));
431 }
432 }
433
434 fn focusable(&self) -> bool {
435 true
436 }
437
438 fn enter(&mut self, forward: bool) {
439 self.cursor = if forward {
440 0
441 } else {
442 self.fields.len().saturating_sub(1) as u16
443 };
444 }
445
446 fn key(&mut self, _ec: &mut EventCtx<'_>, key: Key) -> Flow {
447 if self.fields.is_empty() {
448 return Flow::Skip;
449 }
450 if let Some(open) = self.open {
451 let field = &mut self.fields[usize::from(open)];
452 let len = field.options.len() as u16;
453 match key {
454 Key::Up if len > 0 => self.sub_cursor = (self.sub_cursor + len - 1) % len,
455 Key::Down if len > 0 => self.sub_cursor = (self.sub_cursor + 1) % len,
456 Key::Space if field.kind == FieldKind::Multi => toggle_multi(field, self.sub_cursor),
457 Key::Enter => {
458 if field.kind != FieldKind::Multi
459 && let Some(option) = field.options.get(usize::from(self.sub_cursor))
460 {
461 field.value = FieldValue::Choice(option.clone());
462 }
463 self.open = None;
464 },
465 Key::Esc => self.open = None,
466 _ => {},
467 }
468 return Flow::Consumed;
469 }
470 let field_count = self.fields.len() as u16;
471 if self.editing {
472 let field = &mut self.fields[usize::from(self.cursor)];
473 let FieldValue::Text(text) = &mut field.value else {
474 self.editing = false;
475 return Flow::Consumed;
476 };
477 match key {
478 Key::Enter | Key::Esc => self.editing = false,
479 Key::Backspace => {
480 text.pop();
481 },
482 Key::Space => text.push(' '),
483 Key::Char(character) => text.push(character),
484 Key::Ctrl('u') => text.clear(),
485 Key::Ctrl('w') => text.truncate(word_rubout_start(text, text.len())),
486 _ => {},
487 }
488 return Flow::Consumed;
489 }
490 let kind = self.fields[usize::from(self.cursor)].kind;
491 match key {
492 Key::Left | Key::Right if kind == FieldKind::Enum => {
493 cycle_choice(&mut self.fields[usize::from(self.cursor)], key == Key::Right);
494 Flow::Consumed
495 },
496 Key::Left | Key::Right if kind == FieldKind::Number => {
497 let field = &mut self.fields[usize::from(self.cursor)];
498 if let FieldValue::Number(value) = &mut field.value {
499 let step = if key == Key::Right {
500 field.step
501 } else {
502 field.step.saturating_neg()
503 };
504 *value = value.saturating_add(step).clamp(field.min, field.max);
505 }
506 Flow::Consumed
507 },
508 Key::Up if self.cursor > 0 => {
509 self.cursor -= 1;
510 Flow::Consumed
511 },
512 Key::Down if self.cursor + 1 < field_count => {
513 self.cursor += 1;
514 Flow::Consumed
515 },
516 Key::Enter | Key::Space => {
517 self.activate();
518 Flow::Consumed
519 },
520 _ => Flow::Skip,
521 }
522 }
523
524 fn mouse(
525 &mut self,
526 _ec: &mut EventCtx<'_>,
527 tag: HitTag,
528 _at: (u16, u16),
529 _rect: Rect,
530 mouse: Mouse,
531 ) -> Flow {
532 match mouse {
533 Mouse::Click => {
534 match tag {
535 HitTag::Row(index) => self.click_row(index),
536 HitTag::Sub(index) => self.click_sub(index),
537 _ => return Flow::Skip,
538 }
539 Flow::Consumed
540 },
541 Mouse::RightClick
542 | Mouse::MiddleClick
543 | Mouse::Move
544 | Mouse::Drag
545 | Mouse::Release
546 | Mouse::WheelUp
547 | Mouse::WheelDown
548 | Mouse::WheelLeft
549 | Mouse::WheelRight => Flow::Skip,
550 }
551 }
552
553 fn paste(&mut self, _ec: &mut EventCtx<'_>, text: &str) -> Flow {
554 if !self.editing {
555 return Flow::Skip;
556 }
557 let sanitized = sanitize_paste(text);
558 if sanitized.is_empty() {
559 return Flow::Skip;
560 }
561 let Some(FieldData { value: FieldValue::Text(value), .. }) =
562 self.fields.get_mut(usize::from(self.cursor))
563 else {
564 return Flow::Skip;
565 };
566 value.push_str(&sanitized.replace(['\n', '\t'], " "));
567 Flow::Consumed
568 }
569
570 fn validation_error(&self) -> Option<String> {
571 for field in &self.fields {
572 let value = field_value(field);
573 let text = super::wizard::display_value(&value);
574 if field.required && text.trim().is_empty() {
575 return Some(format!("{} is required", field.id));
576 }
577 if let Some(pattern) = &field.pattern
578 && !text.trim().is_empty()
579 && !super::wizard::match_simple(pattern, text.trim())
580 {
581 return Some(format!("{} must match {}", field.id, pattern));
582 }
583 }
584 None
585 }
586
587 fn value(&self, out: &mut Map<String, Value>) {
588 let Some(id) = self.props.id() else { return };
589 let mut object = Map::new();
590 for field in &self.fields {
591 if !field.id.is_empty() {
592 object.insert(field.id.to_string(), field_value(field));
593 }
594 }
595 out.insert(id.to_string(), Value::Object(object));
596 }
597}
598
599fn cycle_choice(field: &mut FieldData, forward: bool) {
600 if field.options.is_empty() {
601 return;
602 }
603 if let FieldValue::Choice(current) = &field.value {
604 let len = field.options.len();
605 let at = field
606 .options
607 .iter()
608 .position(|option| option == current)
609 .unwrap_or(0);
610 let next = if forward {
611 (at + 1) % len
612 } else {
613 (at + len - 1) % len
614 };
615 field.value = FieldValue::Choice(field.options[next].clone());
616 }
617}
618
619fn toggle_multi(field: &mut FieldData, index: u16) {
620 let Some(option) = field.options.get(usize::from(index)).cloned() else {
621 return;
622 };
623 if let FieldValue::Many(values) = &mut field.value {
624 if values.contains(&option) {
625 values.retain(|value| *value != option);
626 } else {
627 values.push(option);
628 values.sort_by_key(|value| field.options.iter().position(|option| option == value));
629 }
630 }
631}
632
633fn field_value(field: &FieldData) -> Value {
634 match &field.value {
635 FieldValue::Bool(value) => Value::Bool(*value),
636 FieldValue::Text(value) => Value::String(value.clone()),
637 FieldValue::Choice(value) => Value::String(value.to_string()),
638 FieldValue::Many(values) => Value::Array(
639 values
640 .iter()
641 .map(|value| Value::String(value.to_string()))
642 .collect(),
643 ),
644 FieldValue::Number(value) => Value::Number((*value).into()),
645 }
646}
647
648const fn base(theme: &Theme) -> Style {
649 Style::new().fg(theme.fg)
650}
651const fn dim(theme: &Theme) -> Style {
652 Style::new().fg(theme.muted)
653}
654
655fn paint_field_value(
656 ctx: &UiContext,
657 frame: &mut crate::frame::Frame,
658 x: u16,
659 y: u16,
660 field: &FieldData,
661 here: bool,
662 tint: impl Fn(Style) -> Style,
663 scratch: &mut String,
664) -> u16 {
665 match (&field.value, field.kind) {
666 (FieldValue::Bool(value), _) => frame.put(
667 x,
668 y,
669 if *value { "true" } else { "false" },
670 tint(Style::new().fg(if *value {
671 ctx.theme.ok
672 } else {
673 ctx.theme.muted
674 })),
675 ),
676 (FieldValue::Choice(choice), FieldKind::Enum) => {
677 let mut x = frame.put(x, y, choice, tint(Style::new().fg(ctx.theme.info)));
678 if here {
679 x = frame.put(x, y, " ", tint(dim(&ctx.theme)));
680 x = frame.put(x, y, ctx.charset.arrows().0, tint(dim(&ctx.theme)));
681 x = frame.put(x, y, " ", tint(dim(&ctx.theme)));
682 x = frame.put(x, y, ctx.charset.arrows().1, tint(dim(&ctx.theme)));
683 }
684 x
685 },
686 (FieldValue::Choice(choice), _) => {
687 let x = frame.put(x, y, choice, tint(Style::new().fg(ctx.theme.info)));
688 frame.put(x, y, ctx.charset.dropdown(), tint(dim(&ctx.theme)))
689 },
690 (FieldValue::Many(values), _) => {
691 scratch.clear();
692 if values.is_empty() {
693 scratch.push('—');
694 } else {
695 for (index, value) in values.iter().enumerate() {
696 if index > 0 {
697 scratch.push_str(", ");
698 }
699 scratch.push_str(value);
700 }
701 }
702 let x = frame.put(x, y, scratch, tint(Style::new().fg(ctx.theme.info)));
703 frame.put(x, y, ctx.charset.dropdown(), tint(dim(&ctx.theme)))
704 },
705 (FieldValue::Number(value), _) => {
706 let mut x = x;
707 if here {
708 x = frame.put(x, y, ctx.charset.arrows().0, tint(dim(&ctx.theme)));
709 x = frame.put(x, y, " ", tint(dim(&ctx.theme)));
710 }
711 scratch.clear();
712 let _ = write!(scratch, "{value}");
713 x = frame.put(x, y, scratch, tint(Style::new().fg(ctx.theme.warn)));
714 if here {
715 x = frame.put(x, y, " ", tint(dim(&ctx.theme)));
716 x = frame.put(x, y, ctx.charset.arrows().1, tint(dim(&ctx.theme)));
717 }
718 x
719 },
720 (FieldValue::Text(text), _) => frame.put(x, y, text, tint(base(&ctx.theme))),
721 }
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727
728 fn event_ctx(ctx: &UiContext) -> EventCtx<'_> {
729 EventCtx::new(ctx, 40, 10)
730 }
731
732 #[test]
733 fn navigation_edit_and_values_match_form_contract() {
734 let mut form = Form::new()
735 .with(Prop::Id, "settings")
736 .field(
737 Field::new()
738 .with(Prop::Id, "name")
739 .with(Prop::Kind, "text")
740 .with(Prop::Value, "omp"),
741 )
742 .field(
743 Field::new()
744 .with(Prop::Id, "theme")
745 .with(Prop::Kind, "select")
746 .with(Prop::Options, "dark light")
747 .with(Prop::Value, "dark"),
748 );
749 let ctx = UiContext::default();
750 let mut ec = event_ctx(&ctx);
751 assert_eq!(form.key(&mut ec, Key::Enter), Flow::Consumed);
752 assert_eq!(form.key(&mut ec, Key::Char('!')), Flow::Consumed);
753 assert_eq!(form.key(&mut ec, Key::Enter), Flow::Consumed);
754 assert_eq!(form.key(&mut ec, Key::Down), Flow::Consumed);
755 assert_eq!(form.key(&mut ec, Key::Enter), Flow::Consumed);
756 assert_eq!(form.key(&mut ec, Key::Down), Flow::Consumed);
757 assert_eq!(form.key(&mut ec, Key::Enter), Flow::Consumed);
758 let mut values = Map::new();
759 form.value(&mut values);
760 assert_eq!(values["settings"], serde_json::json!({ "name": "omp!", "theme": "light" }));
761 }
762
763 #[test]
764 fn validation_reports_the_first_invalid_field() {
765 let mut form = Form::new()
766 .field(
767 Field::new()
768 .with(Prop::Id, "name")
769 .with(Prop::Required, true),
770 )
771 .field(
772 Field::new()
773 .with(Prop::Id, "slug")
774 .with(Prop::Match, "[a-z-]+")
775 .with(Prop::Value, "Bad Slug"),
776 );
777
778 assert_eq!(form.validation_error().as_deref(), Some("name is required"));
779 form.fields[0].value = FieldValue::Text("OMP".into());
780 assert_eq!(form.validation_error().as_deref(), Some("slug must match [a-z-]+"));
781 form.fields[1].value = FieldValue::Text("valid-slug".into());
782 assert_eq!(form.validation_error(), None);
783 }
784}