1use std::collections::HashSet;
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18use super::{
19 CustomNumberFormatConfig, DatetimeFormatType, KeyValueOpts, NumberSeriesStyleDefaultConfig,
20};
21use crate::utils::{CssKind, GradientStopSpec, canonicalize_gradient_stops};
22
23#[derive(Clone, Debug, Default, Deserialize, Serialize)]
28pub struct ColumnConfigSchema {
29 pub fields: Vec<ControlSpec>,
30}
31
32impl ColumnConfigSchema {
33 pub fn active_keys(&self) -> HashSet<String> {
39 let mut out = HashSet::new();
40 for spec in &self.fields {
41 for k in spec.serialized_keys() {
42 out.insert(k.to_string());
43 }
44 }
45 out
46 }
47
48 pub fn leaf_fields(&self) -> Vec<&ControlSpec> {
49 fn collect<'a>(fields: &'a [ControlSpec], out: &mut Vec<&'a ControlSpec>) {
50 for spec in fields {
51 match spec {
52 ControlSpec::Group { fields, .. } => collect(fields, out),
53 leaf => out.push(leaf),
54 }
55 }
56 }
57
58 let mut out = vec![];
59 collect(&self.fields, &mut out);
60 out
61 }
62}
63
64#[derive(Clone, Debug, Deserialize, Serialize)]
70#[serde(tag = "kind")]
71pub enum ControlSpec {
72 Enum {
73 key: String,
74 variants: Vec<EnumVariant>,
75 default: String,
76 },
77
78 Font {
82 key: String,
83 default: String,
84
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 size: Option<FontSize>,
87
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 bold: Option<FontToggle>,
90
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 italic: Option<FontToggle>,
93 },
94 Alignment {
97 key: String,
98
99 #[serde(default, skip_serializing_if = "Option::is_none")]
103 default: Option<Alignment>,
104
105 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
107 corners: bool,
108 },
109 Bool {
110 key: String,
111 default: bool,
112 },
113 Number {
114 key: String,
115 default: f64,
116
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 include: Option<bool>,
120
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 min: Option<f64>,
123
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 max: Option<f64>,
126
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 step: Option<f64>,
129 },
130 String {
131 key: String,
132 default: String,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 placeholder: Option<String>,
135 },
136 Color {
137 key: String,
138 default: String,
139 },
140 Palette {
141 key: String,
142 default: String,
143
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 max: Option<usize>,
146 },
147 GradientStops {
148 key: String,
149 default: String,
150
151 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
152 discrete: bool,
153 },
154 DatetimeFormat {
155 #[serde(default, skip_serializing_if = "Option::is_none")]
158 default: Option<DatetimeFormatType>,
159 },
160 NumberSeriesStyle {
161 default: NumberSeriesStyleDefaultConfig,
162 },
163 Symbols {
164 default: KeyValueOpts,
165 },
166 NumberFormat {
167 #[serde(default, skip_serializing_if = "Option::is_none")]
170 default: Option<CustomNumberFormatConfig>,
171 },
172 AggregateDepth,
173
174 Group {
175 key: String,
176 #[serde(default)]
177 fields: Vec<ControlSpec>,
178 },
179}
180
181#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
185#[serde(rename_all = "kebab-case")]
186pub enum Alignment {
187 TopLeft,
188 Top,
189 TopRight,
190 Left,
191 Center,
192 Right,
193 BottomLeft,
194 Bottom,
195 BottomRight,
196}
197
198impl Alignment {
199 pub const ALL: [Alignment; 9] = [
201 Alignment::TopLeft,
202 Alignment::Top,
203 Alignment::TopRight,
204 Alignment::Left,
205 Alignment::Center,
206 Alignment::Right,
207 Alignment::BottomLeft,
208 Alignment::Bottom,
209 Alignment::BottomRight,
210 ];
211 pub const CORNERS: [Alignment; 4] = [
212 Alignment::TopLeft,
213 Alignment::TopRight,
214 Alignment::BottomLeft,
215 Alignment::BottomRight,
216 ];
217
218 pub fn is_corner(self) -> bool {
219 Self::CORNERS.contains(&self)
220 }
221
222 pub fn as_str(self) -> &'static str {
224 match self {
225 Alignment::TopLeft => "top-left",
226 Alignment::Top => "top",
227 Alignment::TopRight => "top-right",
228 Alignment::Left => "left",
229 Alignment::Center => "center",
230 Alignment::Right => "right",
231 Alignment::BottomLeft => "bottom-left",
232 Alignment::Bottom => "bottom",
233 Alignment::BottomRight => "bottom-right",
234 }
235 }
236
237 pub fn parse(src: &str) -> Option<Self> {
238 Self::ALL.into_iter().find(|x| x.as_str() == src)
239 }
240
241 pub fn humanized(self) -> String {
243 self.as_str()
244 .split('-')
245 .map(|word| {
246 let mut chars = word.chars();
247 match chars.next() {
248 Some(first) => first.to_uppercase().chain(chars).collect::<String>(),
249 None => String::new(),
250 }
251 })
252 .collect::<Vec<_>>()
253 .join(" ")
254 }
255}
256
257#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
260pub struct FontToggle {
261 pub key: String,
262 #[serde(default)]
263 pub default: bool,
264}
265
266#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
269pub struct FontSize {
270 pub key: String,
271 pub default: f64,
272
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub min: Option<f64>,
275
276 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub max: Option<f64>,
278
279 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub step: Option<f64>,
281}
282
283#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
284pub struct EnumVariant {
285 pub value: String,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub label: Option<String>,
288}
289
290pub fn discrete_pair(stops: Vec<GradientStopSpec>) -> Vec<GradientStopSpec> {
293 let stops = canonicalize_gradient_stops(stops);
294 match (stops.first(), stops.last()) {
295 (Some(first), Some(last)) if stops.len() > 2 => vec![
296 GradientStopSpec {
297 color: first.color.clone(),
298 offset: 0.0,
299 },
300 GradientStopSpec {
301 color: last.color.clone(),
302 offset: 1.0,
303 },
304 ],
305 _ => stops,
306 }
307}
308
309impl ColumnConfigSchema {
310 pub fn canonicalize(self) -> Self {
311 self.canonicalize_defaults().group_format_controls()
312 }
313
314 pub fn group_format_controls(mut self) -> Self {
315 fn is_format(spec: &ControlSpec) -> bool {
316 matches!(
317 spec,
318 ControlSpec::NumberFormat { .. } | ControlSpec::DatetimeFormat { .. }
319 )
320 }
321
322 fn walk(fields: &mut Vec<ControlSpec>) {
323 for spec in fields.iter_mut() {
324 if let ControlSpec::Group { key, fields } = spec
325 && key != "format"
326 {
327 walk(fields);
328 }
329 }
330
331 let first = fields.iter().position(is_format);
332 if let Some(first) = first {
333 let mut formats = vec![];
334 let mut i = first;
335 while i < fields.len() {
336 if is_format(&fields[i]) {
337 formats.push(fields.remove(i));
338 } else {
339 i += 1;
340 }
341 }
342
343 fields.insert(first, ControlSpec::Group {
344 key: "format".to_owned(),
345 fields: formats,
346 });
347 }
348 }
349
350 walk(&mut self.fields);
351 self
352 }
353
354 pub fn canonicalize_defaults(mut self) -> Self {
357 fn canonicalize_specs(fields: &mut Vec<ControlSpec>) {
358 fields.retain_mut(|spec| {
359 let (kind, key, default) = match spec {
360 ControlSpec::Group { fields, .. } => {
361 canonicalize_specs(fields);
362 return !fields.is_empty();
363 },
364 ControlSpec::Alignment {
365 key,
366 default: Some(default),
367 corners: true,
368 } if !default.is_corner() => {
369 tracing::error!(
370 "Dropping `{key}` — default `{}` is not a corner",
371 default.as_str()
372 );
373
374 return false;
375 },
376 ControlSpec::Color { key, default } => (CssKind::Color, key, default),
377 ControlSpec::Palette { key, default, .. } => (CssKind::Palette, key, default),
378 ControlSpec::GradientStops { key, default, .. } => {
379 (CssKind::Gradient, key, default)
380 },
381 _ => return true,
382 };
383
384 match kind.canonicalize(default) {
385 Ok(canonical) => {
386 *default = canonical;
387 true
388 },
389 Err(error) => {
390 tracing::error!("Dropping `{key}` — invalid schema default: {error}");
391 false
392 },
393 }
394 });
395 }
396
397 canonicalize_specs(&mut self.fields);
398 self
399 }
400
401 pub fn enum_variants_of(&self, key: &str) -> Option<&[EnumVariant]> {
403 self.leaf_fields().into_iter().find_map(|spec| match spec {
404 ControlSpec::Enum {
405 key: k, variants, ..
406 } if k == key => Some(variants.as_slice()),
407 _ => None,
408 })
409 }
410
411 pub fn css_kind_of(&self, key: &str) -> Option<CssKind> {
413 self.leaf_fields().into_iter().find_map(|spec| match spec {
414 ControlSpec::Color { key: k, .. } if k == key => Some(CssKind::Color),
415 ControlSpec::Palette { key: k, .. } if k == key => Some(CssKind::Palette),
416 ControlSpec::GradientStops { key: k, .. } if k == key => Some(CssKind::Gradient),
417 _ => None,
418 })
419 }
420}
421
422impl ControlSpec {
423 pub fn serialized_keys(&self) -> Vec<&str> {
429 match self {
430 ControlSpec::DatetimeFormat { .. } => vec!["date_format"],
431 ControlSpec::Font {
432 key,
433 size,
434 bold,
435 italic,
436 ..
437 } => [
438 Some(key.as_str()),
439 size.as_ref().map(|s| s.key.as_str()),
440 bold.as_ref().map(|t| t.key.as_str()),
441 italic.as_ref().map(|t| t.key.as_str()),
442 ]
443 .into_iter()
444 .flatten()
445 .collect(),
446 ControlSpec::NumberSeriesStyle { .. } => vec!["chart_type", "stack"],
447 ControlSpec::Symbols { .. } => vec!["symbols"],
448 ControlSpec::NumberFormat { .. } => vec!["number_format"],
449 ControlSpec::AggregateDepth => vec!["aggregate_depth"],
450 ControlSpec::Enum { key, .. }
451 | ControlSpec::Alignment { key, .. }
452 | ControlSpec::Bool { key, .. }
453 | ControlSpec::Number { key, .. }
454 | ControlSpec::String { key, .. }
455 | ControlSpec::Color { key, .. }
456 | ControlSpec::Palette { key, .. }
457 | ControlSpec::GradientStops { key, .. } => vec![key.as_str()],
458 ControlSpec::Group { fields, .. } => {
459 fields.iter().flat_map(|f| f.serialized_keys()).collect()
460 },
461 }
462 }
463}
464
465#[derive(Clone, Debug, Deserialize, Serialize)]
470pub struct ColumnConfigFieldUpdate {
471 pub keys: Vec<String>,
472 pub value: serde_json::Map<String, Value>,
473}
474
475pub fn filter_to_schema(
479 config: &serde_json::Map<String, Value>,
480 active_keys: &HashSet<String>,
481) -> serde_json::Map<String, Value> {
482 config
483 .iter()
484 .filter(|(k, _)| active_keys.contains(k.as_str()))
485 .map(|(k, v)| (k.clone(), v.clone()))
486 .collect()
487}
488
489#[cfg(test)]
490mod tests {
491 use serde_json::json;
492
493 use super::*;
494
495 fn color(key: &str, default: &str) -> ControlSpec {
496 ControlSpec::Color {
497 key: key.to_owned(),
498 default: default.to_owned(),
499 }
500 }
501
502 fn flag(key: &str) -> ControlSpec {
503 ControlSpec::Bool {
504 key: key.to_owned(),
505 default: false,
506 }
507 }
508
509 fn group(key: &str, fields: Vec<ControlSpec>) -> ControlSpec {
510 ControlSpec::Group {
511 key: key.to_owned(),
512 fields,
513 }
514 }
515
516 #[test]
517 fn group_deserializes_recursively() {
518 let schema: ColumnConfigSchema = serde_json::from_value(json!({
519 "fields": [{
520 "kind": "Group",
521 "key": "legend",
522 "fields": [
523 { "kind": "Bool", "key": "legend_on", "default": false },
524 {
525 "kind": "Group",
526 "key": "inner",
527 "fields": [{ "kind": "Color", "key": "color", "default": "#ff0000" }]
528 }
529 ]
530 }]
531 }))
532 .unwrap();
533
534 let keys = schema.active_keys();
535 assert_eq!(
536 keys,
537 HashSet::from(["legend_on".to_owned(), "color".to_owned()])
538 );
539
540 let leaves = schema.leaf_fields();
541 assert_eq!(leaves.len(), 2);
542 assert!(
543 leaves
544 .iter()
545 .all(|s| !matches!(s, ControlSpec::Group { .. }))
546 );
547 }
548
549 #[test]
550 fn grouped_schema_is_equivalent_to_flat() {
551 let flat = ColumnConfigSchema {
552 fields: vec![flag("stack"), color("color", "#0366d6")],
553 };
554
555 let grouped = ColumnConfigSchema {
556 fields: vec![group("series", vec![
557 flag("stack"),
558 color("color", "#0366d6"),
559 ])],
560 };
561
562 assert_eq!(flat.active_keys(), grouped.active_keys());
563 assert_eq!(flat.css_kind_of("color"), grouped.css_kind_of("color"));
564 assert_eq!(flat.css_kind_of("stack"), grouped.css_kind_of("stack"));
565 }
566
567 #[test]
568 fn enum_variants_see_through_groups() {
569 let mode = ControlSpec::Enum {
570 key: "fg_mode".to_owned(),
571 default: "color".to_owned(),
572 variants: vec![
573 EnumVariant {
574 value: "disabled".to_owned(),
575 label: None,
576 },
577 EnumVariant {
578 value: "color".to_owned(),
579 label: None,
580 },
581 ],
582 };
583
584 let grouped = ColumnConfigSchema {
585 fields: vec![group("color", vec![mode, flag("flag")])],
586 };
587
588 let values: Vec<&str> = grouped
589 .enum_variants_of("fg_mode")
590 .unwrap()
591 .iter()
592 .map(|v| v.value.as_str())
593 .collect();
594
595 assert_eq!(values, vec!["disabled", "color"]);
596 assert!(grouped.enum_variants_of("flag").is_none());
597 assert!(grouped.enum_variants_of("missing").is_none());
598 }
599
600 #[test]
601 fn format_controls_group_and_merge() {
602 let schema = ColumnConfigSchema {
603 fields: vec![
604 ControlSpec::NumberFormat { default: None },
605 flag("flag"),
606 ControlSpec::DatetimeFormat { default: None },
607 ],
608 }
609 .group_format_controls();
610
611 assert_eq!(schema.fields.len(), 2);
612 let ControlSpec::Group { key, fields } = &schema.fields[0] else {
613 panic!("expected format group first");
614 };
615
616 assert_eq!(key, "format");
617 assert!(matches!(fields[0], ControlSpec::NumberFormat { .. }));
618 assert!(matches!(fields[1], ControlSpec::DatetimeFormat { .. }));
619 assert!(matches!(&schema.fields[1], ControlSpec::Bool { .. }));
620
621 assert_eq!(
622 schema.active_keys(),
623 HashSet::from([
624 "number_format".to_owned(),
625 "date_format".to_owned(),
626 "flag".to_owned()
627 ])
628 );
629 }
630
631 #[test]
632 fn font_owns_its_size_and_toggle_keys() {
633 let spec = ControlSpec::Font {
634 key: "font_family".to_owned(),
635 default: "inherit".to_owned(),
636 size: Some(FontSize {
637 key: "font_size".to_owned(),
638 default: 12.0,
639 min: None,
640 max: None,
641 step: None,
642 }),
643 bold: Some(FontToggle {
644 key: "bold".to_owned(),
645 default: false,
646 }),
647 italic: None,
648 };
649
650 assert_eq!(spec.serialized_keys(), vec![
651 "font_family",
652 "font_size",
653 "bold"
654 ]);
655 }
656
657 #[test]
658 fn format_grouping_recurses_but_never_double_wraps() {
659 let schema = ColumnConfigSchema {
660 fields: vec![
661 group("format", vec![ControlSpec::NumberFormat { default: None }]),
662 group("styling", vec![flag("x"), ControlSpec::DatetimeFormat {
663 default: None,
664 }]),
665 ],
666 }
667 .group_format_controls();
668
669 let ControlSpec::Group { key, fields } = &schema.fields[0] else {
670 panic!("expected group");
671 };
672
673 assert_eq!(key, "format");
674 assert!(matches!(fields[0], ControlSpec::NumberFormat { .. }));
675
676 let ControlSpec::Group { fields, .. } = &schema.fields[1] else {
677 panic!("expected group");
678 };
679
680 assert!(matches!(
681 &fields[1],
682 ControlSpec::Group { key, fields }
683 if key == "format" && matches!(fields[0], ControlSpec::DatetimeFormat { .. })
684 ));
685 }
686
687 #[test]
688 fn format_controls_deserialize_without_default_payload() {
689 let schema: ColumnConfigSchema = serde_json::from_value(json!({
690 "fields": [{ "kind": "NumberFormat" }, { "kind": "DatetimeFormat" }]
691 }))
692 .unwrap();
693
694 assert!(matches!(&schema.fields[0], ControlSpec::NumberFormat {
695 default: None
696 }));
697 assert!(matches!(&schema.fields[1], ControlSpec::DatetimeFormat {
698 default: None
699 }));
700 }
701
702 #[test]
703 fn number_format_default_payload_deserializes_flattened_families() {
704 let schema: ColumnConfigSchema = serde_json::from_value(json!({
705 "fields": [{
706 "kind": "NumberFormat",
707 "default": {
708 "notation": "compact",
709 "compactDisplay": "short",
710 "minimumFractionDigits": 0,
711 "maximumFractionDigits": 1
712 }
713 }]
714 }))
715 .unwrap();
716
717 let ControlSpec::NumberFormat {
718 default: Some(default),
719 } = &schema.fields[0]
720 else {
721 panic!("expected NumberFormat with default");
722 };
723
724 assert_eq!(
725 default._notation,
726 Some(crate::config::Notation::Compact(
727 crate::config::CompactDisplay::Short
728 ))
729 );
730 assert_eq!(default._style, None);
731 assert_eq!(default.minimum_fraction_digits, Some(0.));
732 assert_eq!(default.maximum_fraction_digits, Some(1.));
733 assert_eq!(
734 schema.active_keys(),
735 HashSet::from(["number_format".to_owned()])
736 );
737 }
738
739 #[test]
740 fn datetime_format_default_payload_deserializes_simple_arm() {
741 let schema: ColumnConfigSchema = serde_json::from_value(json!({
742 "fields": [{
743 "kind": "DatetimeFormat",
744 "default": { "dateStyle": "medium", "timeStyle": "disabled" }
745 }]
746 }))
747 .unwrap();
748
749 let ControlSpec::DatetimeFormat {
750 default: Some(DatetimeFormatType::Simple(simple)),
751 } = &schema.fields[0]
752 else {
753 panic!("expected DatetimeFormat with Simple default");
754 };
755
756 assert_eq!(
757 simple.date_style,
758 crate::config::SimpleDatetimeFormat::Medium
759 );
760 assert_eq!(
761 simple.time_style,
762 crate::config::SimpleDatetimeFormat::Disabled
763 );
764 }
765
766 #[test]
767 fn alignment_tokens_round_trip() {
768 for align in Alignment::ALL {
769 assert_eq!(Alignment::parse(align.as_str()), Some(align));
770 assert_eq!(serde_json::to_value(align).unwrap(), json!(align.as_str()));
771 }
772
773 for bad in ["middle", "left-top", "top-center", "middle-left", ""] {
774 assert_eq!(Alignment::parse(bad), None, "{bad}");
775 }
776
777 assert_eq!(Alignment::ALL.iter().filter(|x| x.is_corner()).count(), 4);
778 assert_eq!(Alignment::TopLeft.humanized(), "Top Left");
779 assert_eq!(Alignment::Center.humanized(), "Center");
780 }
781
782 #[test]
783 fn alignment_deserializes_with_optional_default_and_corners() {
784 let schema: ColumnConfigSchema = serde_json::from_value(json!({
785 "fields": [
786 { "kind": "Alignment", "key": "align" },
787 { "kind": "Alignment", "key": "legend_anchor", "default": "top-right", "corners": true }
788 ]
789 }))
790 .unwrap();
791
792 assert!(matches!(&schema.fields[0], ControlSpec::Alignment {
793 default: None,
794 corners: false,
795 ..
796 }));
797 assert!(matches!(&schema.fields[1], ControlSpec::Alignment {
798 default: Some(Alignment::TopRight),
799 corners: true,
800 ..
801 }));
802 assert_eq!(
803 schema.active_keys(),
804 HashSet::from(["align".to_owned(), "legend_anchor".to_owned()])
805 );
806 assert!(
807 serde_json::from_value::<ColumnConfigSchema>(json!({
808 "fields": [{ "kind": "Alignment", "key": "x", "default": "middle" }]
809 }))
810 .is_err()
811 );
812 }
813
814 #[test]
815 fn canonicalize_defaults_drops_non_corner_default_of_corners_field() {
816 let schema = ColumnConfigSchema {
817 fields: vec![
818 ControlSpec::Alignment {
819 key: "bad".to_owned(),
820 default: Some(Alignment::Top),
821 corners: true,
822 },
823 ControlSpec::Alignment {
824 key: "ok".to_owned(),
825 default: Some(Alignment::Top),
826 corners: false,
827 },
828 ControlSpec::Alignment {
829 key: "unset".to_owned(),
830 default: None,
831 corners: true,
832 },
833 ],
834 }
835 .canonicalize_defaults();
836
837 assert_eq!(
838 schema.active_keys(),
839 HashSet::from(["ok".to_owned(), "unset".to_owned()])
840 );
841 }
842
843 #[test]
844 fn canonicalize_defaults_recurses_and_drops_empty_groups() {
845 let schema = ColumnConfigSchema {
846 fields: vec![
847 group("ok", vec![color("good", "RGB(255,0,0)"), flag("flag")]),
848 group("doomed", vec![color("bad", "not-a-color")]),
849 ],
850 }
851 .canonicalize_defaults();
852
853 assert_eq!(schema.fields.len(), 1);
854 let ControlSpec::Group { key, fields } = &schema.fields[0] else {
855 panic!("expected group");
856 };
857
858 assert_eq!(key, "ok");
859 assert!(matches!(
860 &fields[0],
861 ControlSpec::Color { default, .. } if default == "#ff0000"
862 ));
863 }
864}