1use std::collections::BTreeMap;
35
36use gpui::{
37 AnyElement, App, AppContext, Context, Entity, EventEmitter, InteractiveElement, IntoElement,
38 ParentElement, Render, SharedString, Styled, Subscription, Window, div, prelude::FluentBuilder,
39 px,
40};
41use gpui_kit_semantics::{NodeSpec, Role, Semantic};
42use gpui_kit_theme::{ActiveTheme, ControlSize, Space, TextTone, TypeScale};
43
44use crate::controls::combobox::{Combobox, ComboboxEvent};
45use crate::controls::form_field::FormField;
46use crate::controls::input::{TextInput, TextInputEvent};
47use crate::controls::number_input::{NumberInput, NumberInputEvent};
48use crate::controls::select::{Select, SelectEvent, SelectOption};
49use crate::controls::tag_input::{TagInput, TagInputEvent};
50use crate::controls::toggle::Switch;
51use crate::display::badge::Tone;
52use crate::display::status::Callout;
53use crate::foundation::{Disableable, Ident, Sizable, StyledExt, text};
54use crate::strings::{ActiveStrings, StringKey};
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct SchemaChoice {
59 id: SharedString,
60 label: SharedString,
61 description: Option<SharedString>,
62}
63
64impl SchemaChoice {
65 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
66 Self {
67 id: id.into(),
68 label: label.into(),
69 description: None,
70 }
71 }
72
73 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
74 self.description = Some(description.into());
75 self
76 }
77
78 fn option(&self) -> SelectOption {
79 let option = SelectOption::new(self.id.clone(), self.label.clone());
80 match &self.description {
81 Some(description) => option.description(description.clone()),
82 None => option,
83 }
84 }
85}
86
87#[derive(Debug, Clone, Copy, Default, PartialEq)]
92pub struct NumberBounds {
93 pub min: Option<f64>,
94 pub max: Option<f64>,
95 pub step: Option<f64>,
96}
97
98impl NumberBounds {
99 pub fn new() -> Self {
100 Self::default()
101 }
102
103 pub fn min(mut self, min: f64) -> Self {
104 self.min = Some(min);
105 self
106 }
107
108 pub fn max(mut self, max: f64) -> Self {
109 self.max = Some(max);
110 self
111 }
112
113 pub fn step(mut self, step: f64) -> Self {
114 self.step = Some(step);
115 self
116 }
117}
118
119#[derive(Debug, Clone, PartialEq)]
121pub enum SchemaKind {
122 Text {
123 placeholder: Option<SharedString>,
124 secret: bool,
126 },
127 Number(NumberBounds),
128 Integer(NumberBounds),
131 Boolean,
132 Enum(Vec<SchemaChoice>),
134 OpenEnum(Vec<SchemaChoice>),
136 TextList {
138 max: Option<usize>,
139 },
140 Object(Vec<SchemaField>),
142 Unrenderable(SharedString),
145}
146
147#[derive(Debug, Clone, PartialEq)]
149pub struct SchemaField {
150 name: SharedString,
151 label: Option<SharedString>,
152 description: Option<SharedString>,
153 required: bool,
154 kind: SchemaKind,
155}
156
157impl SchemaField {
158 pub fn new(name: impl Into<SharedString>, kind: SchemaKind) -> Self {
159 Self {
160 name: name.into(),
161 label: None,
162 description: None,
163 required: false,
164 kind,
165 }
166 }
167
168 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
171 self.label = Some(label.into());
172 self
173 }
174
175 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
176 self.description = Some(description.into());
177 self
178 }
179
180 pub fn required(mut self, required: bool) -> Self {
181 self.required = required;
182 self
183 }
184
185 pub fn name(&self) -> &SharedString {
186 &self.name
187 }
188
189 pub fn kind(&self) -> &SchemaKind {
190 &self.kind
191 }
192
193 pub fn is_required(&self) -> bool {
194 self.required
195 }
196
197 fn shown_label(&self) -> SharedString {
198 self.label.clone().unwrap_or_else(|| self.name.clone())
199 }
200}
201
202#[derive(Debug, Clone, Default, PartialEq)]
204pub struct Schema {
205 fields: Vec<SchemaField>,
206}
207
208impl Schema {
209 pub fn new() -> Self {
210 Self::default()
211 }
212
213 pub fn field(mut self, field: SchemaField) -> Self {
214 self.fields.push(field);
215 self
216 }
217
218 pub fn fields(mut self, fields: impl IntoIterator<Item = SchemaField>) -> Self {
219 self.fields.extend(fields);
220 self
221 }
222
223 pub fn is_empty(&self) -> bool {
224 self.fields.is_empty()
225 }
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct UnrenderableField {
231 pub path: SharedString,
234 pub label: SharedString,
235 pub required: bool,
236 pub reason: SharedString,
239}
240
241#[derive(Debug, Clone, PartialEq)]
243pub enum FieldValue {
244 Text(SharedString),
245 Number(f64),
246 Boolean(bool),
247 Choice(SharedString),
248 List(Vec<SharedString>),
249 Absent,
252 Unrenderable,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
259pub enum SchemaFormEvent {
260 Changed(SharedString),
263 Submitted,
265}
266
267impl EventEmitter<SchemaFormEvent> for SchemaForm {}
268
269enum Control {
271 Text(Entity<TextInput>),
272 Number(Entity<NumberInput>),
273 Boolean(bool),
275 Choice(Entity<Select>),
276 OpenChoice(Entity<Combobox>),
277 List(Entity<TagInput>),
278 Group,
280 Unrenderable(SharedString),
281}
282
283struct Field {
285 path: SharedString,
286 label: SharedString,
287 description: Option<SharedString>,
288 required: bool,
289 level: u32,
290 control: Control,
291}
292
293pub struct SchemaForm {
299 ident: Ident,
300 fields: Vec<Field>,
301 host_errors: BTreeMap<SharedString, SharedString>,
303 derived_errors: BTreeMap<SharedString, SharedString>,
306 unrenderable: Vec<UnrenderableField>,
307 size: ControlSize,
308 disabled: bool,
309 _subscriptions: Vec<Subscription>,
310}
311
312impl std::fmt::Debug for SchemaForm {
313 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314 formatter
315 .debug_struct("SchemaForm")
316 .field("ident", &self.ident)
317 .field("fields", &self.fields.len())
318 .field("unrenderable", &self.unrenderable.len())
319 .field("disabled", &self.disabled)
320 .finish()
321 }
322}
323
324impl SchemaForm {
325 pub fn new(
326 ident: impl Into<Ident>,
327 schema: Schema,
328 window: &mut Window,
329 cx: &mut Context<Self>,
330 ) -> Self {
331 let ident = ident.into();
332 let mut form = Self {
333 ident,
334 fields: Vec::new(),
335 host_errors: BTreeMap::new(),
336 derived_errors: BTreeMap::new(),
337 unrenderable: Vec::new(),
338 size: ControlSize::Md,
339 disabled: false,
340 _subscriptions: Vec::new(),
341 };
342 let ident = form.ident.clone();
343 form.build(&schema.fields, "", 1, &ident, window, cx);
344 form
345 }
346
347 fn build(
348 &mut self,
349 fields: &[SchemaField],
350 prefix: &str,
351 level: u32,
352 ident: &Ident,
353 window: &mut Window,
354 cx: &mut Context<Self>,
355 ) {
356 for field in fields {
357 let path = if prefix.is_empty() {
358 field.name.clone()
359 } else {
360 SharedString::from(format!("{prefix}/{}", field.name))
361 };
362 let field_ident = ident.child(path.as_ref());
363 let label = field.shown_label();
364 let control = self.control_for(field, &field_ident, window, cx);
365
366 if let Control::Unrenderable(reason) = &control {
367 self.unrenderable.push(UnrenderableField {
368 path: path.clone(),
369 label: label.clone(),
370 required: field.required,
371 reason: reason.clone(),
372 });
373 }
374
375 let nested = match &field.kind {
376 SchemaKind::Object(children) => Some(children.clone()),
377 _ => None,
378 };
379
380 self.fields.push(Field {
381 path: path.clone(),
382 label,
383 description: field.description.clone(),
384 required: field.required,
385 level,
386 control,
387 });
388
389 if let Some(children) = nested {
390 self.build(&children, path.as_ref(), level + 1, ident, window, cx);
391 }
392 }
393 }
394
395 fn control_for(
396 &mut self,
397 field: &SchemaField,
398 ident: &Ident,
399 window: &mut Window,
400 cx: &mut Context<Self>,
401 ) -> Control {
402 let path = SharedString::from(ident.as_str().to_string());
403 let control_ident = ident.child("control");
404 match &field.kind {
405 SchemaKind::Unrenderable(reason) => Control::Unrenderable(reason.clone()),
406 SchemaKind::Enum(choices) | SchemaKind::OpenEnum(choices) if choices.is_empty() => {
410 Control::Unrenderable(cx.strings().text(StringKey::SchemaNoChoices))
411 }
412 SchemaKind::Object(_) => Control::Group,
413 SchemaKind::Text {
414 placeholder,
415 secret,
416 } => {
417 let secret = *secret;
418 let placeholder = placeholder.clone();
419 let input = cx.new(|cx| {
420 let mut input = TextInput::new(control_ident, window, cx)
421 .secret(secret)
422 .required(field.required);
423 if let Some(placeholder) = placeholder {
424 input = input.placeholder(placeholder);
425 }
426 input
427 });
428 self.watch_text(&path, &input, cx);
429 Control::Text(input)
430 }
431 SchemaKind::Number(bounds) | SchemaKind::Integer(bounds) => {
432 let integer = matches!(field.kind, SchemaKind::Integer(_));
433 let bounds = *bounds;
434 let required = field.required;
435 let label = field.shown_label();
436 let number = cx.new(|cx| {
437 let mut number = NumberInput::new(control_ident, window, cx)
438 .required(required)
439 .name(label);
440 if let Some(min) = bounds.min {
441 number = number.min(min);
442 }
443 if let Some(max) = bounds.max {
444 number = number.max(max);
445 }
446 number = number.step(bounds.step.unwrap_or(1.0));
447 if integer {
448 number = number.precision(0);
449 }
450 number
451 });
452 self.watch_number(&path, &number, cx);
453 Control::Number(number)
454 }
455 SchemaKind::Boolean => Control::Boolean(false),
456 SchemaKind::Enum(choices) => {
457 let options: Vec<SelectOption> = choices.iter().map(SchemaChoice::option).collect();
458 let name = field.shown_label();
459 let select = cx.new(|cx| {
460 Select::new(control_ident, window, cx)
461 .name(name)
462 .options(options)
463 });
464 self.watch_select(&path, &select, cx);
465 Control::Choice(select)
466 }
467 SchemaKind::OpenEnum(choices) => {
468 let options: Vec<SelectOption> = choices.iter().map(SchemaChoice::option).collect();
469 let name = field.shown_label();
470 let combobox = cx.new(|cx| {
471 Combobox::new(control_ident, window, cx)
472 .name(name)
473 .options(options)
474 .allow_custom(true)
475 });
476 self.watch_combobox(&path, &combobox, cx);
477 Control::OpenChoice(combobox)
478 }
479 SchemaKind::TextList { max } => {
480 let max = *max;
481 let tags = cx.new(|cx| {
482 let field = TagInput::new(control_ident, window, cx);
483 match max {
484 Some(max) => field.max(max),
485 None => field,
486 }
487 });
488 self.watch_tags(&path, &tags, cx);
489 Control::List(tags)
490 }
491 }
492 }
493
494 fn watch_text(
495 &mut self,
496 path: &SharedString,
497 input: &Entity<TextInput>,
498 cx: &mut Context<Self>,
499 ) {
500 let path = path.clone();
501 self._subscriptions.push(cx.subscribe(
502 input,
503 move |form, _, event: &TextInputEvent, cx| match event {
504 TextInputEvent::Change(_) => form.changed(path.clone(), cx),
505 TextInputEvent::Submit => cx.emit(SchemaFormEvent::Submitted),
506 _ => {}
507 },
508 ));
509 }
510
511 fn watch_number(
512 &mut self,
513 path: &SharedString,
514 number: &Entity<NumberInput>,
515 cx: &mut Context<Self>,
516 ) {
517 let path = path.clone();
518 self._subscriptions.push(cx.subscribe(
519 number,
520 move |form, number, event: &NumberInputEvent, cx| match event {
521 NumberInputEvent::Changed(value) => {
522 let value = *value;
523 number.update(cx, |number, cx| number.set_value(value, cx));
524 form.changed(path.clone(), cx);
525 }
526 NumberInputEvent::Unparsable(_) => form.changed(path.clone(), cx),
527 NumberInputEvent::Submit => cx.emit(SchemaFormEvent::Submitted),
528 },
529 ));
530 }
531
532 fn watch_select(
533 &mut self,
534 path: &SharedString,
535 select: &Entity<Select>,
536 cx: &mut Context<Self>,
537 ) {
538 let path = path.clone();
539 self._subscriptions.push(cx.subscribe(
540 select,
541 move |form, select, event: &SelectEvent, cx| {
542 if let SelectEvent::Selected(id) = event {
543 let id = id.clone();
544 select.update(cx, |select, cx| select.set_selected(Some(id), cx));
545 form.changed(path.clone(), cx);
546 }
547 },
548 ));
549 }
550
551 fn watch_combobox(
552 &mut self,
553 path: &SharedString,
554 combobox: &Entity<Combobox>,
555 cx: &mut Context<Self>,
556 ) {
557 let path = path.clone();
558 self._subscriptions.push(cx.subscribe(
559 combobox,
560 move |form, combobox, event: &ComboboxEvent, cx| match event {
561 ComboboxEvent::Selected(id) => {
562 let id = id.clone();
563 combobox.update(cx, |combobox, cx| combobox.set_selected(Some(id), cx));
564 form.changed(path.clone(), cx);
565 }
566 ComboboxEvent::Custom(_) => form.changed(path.clone(), cx),
567 _ => {}
568 },
569 ));
570 }
571
572 fn watch_tags(&mut self, path: &SharedString, tags: &Entity<TagInput>, cx: &mut Context<Self>) {
573 let path = path.clone();
574 self._subscriptions.push(cx.subscribe(
575 tags,
576 move |form, tags, event: &TagInputEvent, cx| {
577 let next = match event {
578 TagInputEvent::Added(value) => {
579 let mut next = tags.read(cx).current().to_vec();
580 next.push(value.clone());
581 Some(next)
582 }
583 TagInputEvent::Removed(value) => Some(
584 tags.read(cx)
585 .current()
586 .iter()
587 .filter(|tag| *tag != value)
588 .cloned()
589 .collect(),
590 ),
591 _ => None,
595 };
596 if let Some(next) = next {
597 tags.update(cx, |tags, cx| tags.set_tags(next, cx));
598 form.changed(path.clone(), cx);
599 }
600 },
601 ));
602 }
603
604 fn changed(&mut self, path: SharedString, cx: &mut Context<Self>) {
605 self.derived_errors.remove(&path);
608 cx.emit(SchemaFormEvent::Changed(path));
609 cx.notify();
610 }
611
612 pub fn set_error(
614 &mut self,
615 path: impl Into<SharedString>,
616 message: impl Into<SharedString>,
617 cx: &mut Context<Self>,
618 ) {
619 self.host_errors.insert(path.into(), message.into());
620 cx.notify();
621 }
622
623 pub fn clear_host_errors(&mut self, cx: &mut Context<Self>) {
626 self.host_errors.clear();
627 cx.notify();
628 }
629
630 pub fn validate(&mut self, cx: &mut Context<Self>) -> bool {
639 self.derived_errors.clear();
640 let missing: Vec<SharedString> = self
641 .fields
642 .iter()
643 .filter(|field| {
644 field.required && matches!(self.value_of(field, cx), FieldValue::Absent)
645 })
646 .map(|field| field.path.clone())
647 .collect();
648 let message = cx.strings().text(StringKey::SchemaRequiredMissing);
649 for path in missing {
650 self.derived_errors.insert(path, message.clone());
651 }
652 let rejected: Vec<(SharedString, SharedString)> = self
653 .fields
654 .iter()
655 .filter_map(|field| match &field.control {
656 Control::Number(number) => number
657 .read(cx)
658 .invalid_reason(cx)
659 .map(|reason| (field.path.clone(), reason)),
660 _ => None,
661 })
662 .collect();
663 for (path, reason) in rejected {
664 self.derived_errors.entry(path).or_insert(reason);
665 }
666 cx.notify();
667 self.derived_errors.is_empty() && !self.unrenderable.iter().any(|field| field.required)
668 }
669
670 pub fn values(&self, cx: &App) -> Vec<(SharedString, FieldValue)> {
674 self.fields
675 .iter()
676 .filter(|field| !matches!(field.control, Control::Group))
677 .map(|field| (field.path.clone(), self.value_of(field, cx)))
678 .collect()
679 }
680
681 pub fn unrenderable(&self) -> &[UnrenderableField] {
683 &self.unrenderable
684 }
685
686 pub fn has_unrenderable_required(&self) -> bool {
688 self.unrenderable.iter().any(|field| field.required)
689 }
690
691 pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
692 self.disabled = disabled;
693 for field in &self.fields {
694 match &field.control {
695 Control::Text(input) => {
696 input.update(cx, |input, cx| input.set_disabled(disabled, cx))
697 }
698 Control::Number(number) => {
699 number.update(cx, |number, cx| number.set_disabled(disabled, cx))
700 }
701 Control::Choice(select) => {
702 select.update(cx, |select, cx| select.set_disabled(disabled, cx))
703 }
704 Control::OpenChoice(combobox) => {
705 combobox.update(cx, |combobox, cx| combobox.set_disabled(disabled, cx))
706 }
707 Control::List(tags) => tags.update(cx, |tags, cx| tags.set_disabled(disabled, cx)),
708 Control::Boolean(_) | Control::Group | Control::Unrenderable(_) => {}
709 }
710 }
711 cx.notify();
712 }
713
714 fn value_of(&self, field: &Field, cx: &App) -> FieldValue {
715 match &field.control {
716 Control::Text(input) => match input.read(cx).value() {
717 text if text.is_empty() => FieldValue::Absent,
718 text => FieldValue::Text(text.clone()),
719 },
720 Control::Number(number) => match number.read(cx).shown(cx) {
721 Some(value) => FieldValue::Number(value),
722 None => FieldValue::Absent,
723 },
724 Control::Boolean(on) => FieldValue::Boolean(*on),
725 Control::Choice(select) => match select.read(cx).selected_id() {
726 Some(id) => FieldValue::Choice(id.clone()),
727 None => FieldValue::Absent,
728 },
729 Control::OpenChoice(combobox) => {
730 let combobox = combobox.read(cx);
731 match combobox.selected_id() {
732 Some(id) => FieldValue::Choice(id.clone()),
733 None => match combobox.query_text(cx) {
734 query if query.is_empty() => FieldValue::Absent,
735 query => FieldValue::Choice(query),
736 },
737 }
738 }
739 Control::List(tags) => match tags.read(cx).current() {
740 [] => FieldValue::Absent,
741 tags => FieldValue::List(tags.to_vec()),
742 },
743 Control::Unrenderable(_) => FieldValue::Unrenderable,
744 Control::Group => FieldValue::Absent,
745 }
746 }
747
748 fn error_for(&self, field: &Field, cx: &App) -> Option<SharedString> {
755 if let Some(error) = self
756 .host_errors
757 .get(&field.path)
758 .or_else(|| self.derived_errors.get(&field.path))
759 {
760 return Some(error.clone());
761 }
762 match &field.control {
763 Control::Number(number) => number.read(cx).invalid_reason(cx),
764 _ => None,
765 }
766 }
767}
768
769impl Sizable for SchemaForm {
770 fn control_size(mut self, size: ControlSize) -> Self {
771 self.size = size;
772 self
773 }
774}
775
776impl Disableable for SchemaForm {
777 fn disabled(mut self, disabled: bool) -> Self {
778 self.disabled = disabled;
779 self
780 }
781}
782
783impl Render for SchemaForm {
784 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
785 let theme = cx.theme().clone();
786 let count = self.fields.len();
787 let strings = cx.strings();
788 let unrenderable_required = self.has_unrenderable_required();
789 let summary = (!self.unrenderable.is_empty()).then(|| {
790 if self.unrenderable.len() == 1 {
791 strings.text(StringKey::SchemaUnrenderableOne)
792 } else {
793 strings.format(
794 StringKey::SchemaUnrenderableMany,
795 &[&self.unrenderable.len().to_string()],
796 )
797 }
798 });
799
800 let rows: Vec<AnyElement> = self
801 .fields
802 .iter()
803 .map(|field| self.field_element(field, cx))
804 .collect();
805
806 div()
807 .id(self.ident.element_id())
808 .column()
809 .w_full()
810 .gap_token(&theme, Space::Md)
811 .children(rows)
812 .when_some(summary, |element, summary| {
813 let ident = self.ident.child("unrenderable");
814 element.child(
815 div()
816 .child(
817 Callout::new(
818 summary.clone(),
819 if unrenderable_required {
820 Tone::Danger
821 } else {
822 Tone::Warning
823 },
824 )
825 .id(ident.child("callout")),
826 )
827 .semantic_in(
828 cx,
829 NodeSpec::new(ident.semantic_id(), Role::Status)
830 .parent(self.ident.semantic_id())
831 .text(summary)
832 .invalid(true)
833 .required(unrenderable_required)
834 .value(if unrenderable_required {
835 "unrenderable, required"
836 } else {
837 "unrenderable"
838 }),
839 ),
840 )
841 })
842 .semantic_in(
843 cx,
844 NodeSpec::new(self.ident.semantic_id(), Role::Form).value(count.to_string()),
845 )
846 }
847}
848
849impl SchemaForm {
850 fn field_element(&self, field: &Field, cx: &mut Context<Self>) -> AnyElement {
851 let theme = cx.theme().clone();
852 let ident = self.ident.child(field.path.as_ref());
853 let indent = px(field.level.saturating_sub(1) as f32 * theme.space(Space::Lg));
854
855 if let Control::Group = field.control {
856 return div()
857 .ml(indent)
858 .column()
859 .gap_token(&theme, Space::Xs)
860 .child(text(&theme, TypeScale::Subtitle, field.label.clone()))
861 .when_some(field.description.clone(), |element, description| {
862 element.child(
863 text(&theme, TypeScale::Body, description)
864 .text_tone(&theme, TextTone::Muted),
865 )
866 })
867 .semantic_in(
868 cx,
869 NodeSpec::new(ident.semantic_id(), Role::Group)
870 .parent(self.ident.semantic_id())
871 .text(field.label.clone())
872 .required(field.required)
873 .level(field.level),
874 )
875 .into_any_element();
876 }
877
878 let control_ident = ident.child("control");
879 let error = self.error_for(field, cx);
880 let mut form_field = FormField::new(ident.clone(), field.label.clone())
881 .control(control_ident.semantic_id())
882 .required(field.required);
883 if let Some(description) = field.description.clone() {
884 form_field = form_field.description(description);
885 }
886 if let Some(error) = error.clone() {
887 form_field = form_field.error(error);
888 }
889
890 let body: AnyElement = match &field.control {
891 Control::Text(input) => input.clone().into_any_element(),
892 Control::Number(number) => number.clone().into_any_element(),
893 Control::Choice(select) => select.clone().into_any_element(),
894 Control::OpenChoice(combobox) => combobox.clone().into_any_element(),
895 Control::List(tags) => tags.clone().into_any_element(),
896 Control::Boolean(on) => {
897 let on = *on;
898 let path = field.path.clone();
899 let form = cx.entity().downgrade();
902 Switch::new(control_ident.clone())
903 .named(field.label.clone())
906 .on(on)
907 .disabled(self.disabled)
908 .when(!self.disabled, |switch| {
909 switch.on_change(move |next, _, cx| {
910 let path = path.clone();
911 form.update(cx, |form, cx| {
912 if let Some(field) =
913 form.fields.iter_mut().find(|field| field.path == path)
914 {
915 field.control = Control::Boolean(next);
916 }
917 form.changed(path, cx);
918 })
919 .ok();
920 })
921 })
922 .into_any_element()
923 }
924 Control::Unrenderable(reason) => {
928 let refusal = ident.child("unrenderable");
929 div()
930 .child(
931 Callout::new(
932 reason.clone(),
933 if field.required {
934 Tone::Danger
935 } else {
936 Tone::Warning
937 },
938 )
939 .id(refusal.child("callout")),
940 )
941 .semantic_in(
942 cx,
943 NodeSpec::new(refusal.semantic_id(), Role::Status)
944 .parent(ident.semantic_id())
945 .text(reason.clone())
946 .invalid(true)
947 .required(field.required)
948 .value(if field.required {
949 "unrenderable, required"
950 } else {
951 "unrenderable"
952 }),
953 )
954 .into_any_element()
955 }
956 Control::Group => div().into_any_element(),
957 };
958
959 div()
960 .ml(indent)
961 .child(form_field.child(body))
962 .into_any_element()
963 }
964}
965
966#[cfg(test)]
967mod tests {
968 use super::*;
969
970 #[test]
971 fn a_field_shows_its_name_when_the_schema_named_nothing_else() {
972 let field = SchemaField::new("max_tokens", SchemaKind::Integer(NumberBounds::new()));
973 assert_eq!(field.shown_label().as_ref(), "max_tokens");
974 assert_eq!(
975 field.label("Maximum tokens").shown_label().as_ref(),
976 "Maximum tokens"
977 );
978 }
979
980 #[test]
981 fn bounds_are_absent_until_a_schema_states_them() {
982 let bounds = NumberBounds::new();
983 assert_eq!(bounds.min, None);
984 assert_eq!(bounds.max, None);
985 let bounded = NumberBounds::new().min(1.0).max(4.0).step(0.5);
986 assert_eq!(bounded.min, Some(1.0));
987 assert_eq!(bounded.max, Some(4.0));
988 assert_eq!(bounded.step, Some(0.5));
989 }
990}