Skip to main content

perspective_viewer/config/
column_config_schema.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13use 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/// The full schema for one column at one point in time. Plugins may return
24/// different schemas for the same column based on the column's current
25/// stored value (e.g. to hide dependent fields), so this is re-queried on
26/// every field update.
27#[derive(Clone, Debug, Default, Deserialize, Serialize)]
28pub struct ColumnConfigSchema {
29    pub fields: Vec<ControlSpec>,
30}
31
32impl ColumnConfigSchema {
33    /// Union of every JSON key any control in this schema knows how to
34    /// read or write. Used to build the schema-filtered view of
35    /// `columns_config` passed to `plugin.restore()` — keys not in this
36    /// set are "ghost" state from a different plugin and stay invisible
37    /// to the active one.
38    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/// Discriminated union of widget kinds the viewer can render. Composite
65/// variants wrap an existing rich Yew component and carry only the
66/// component's `*DefaultConfig`. Primitive variants render generic scalar
67/// widgets and carry their own `key` inline; the visible label is
68/// resolved at CSS time via `--psp-label--<key>--content`.
69#[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    Bool {
78        key: String,
79        default: bool,
80    },
81    Number {
82        key: String,
83        default: f64,
84
85        /// If `true`, always serialize this values even if it is the default.
86        #[serde(default, skip_serializing_if = "Option::is_none")]
87        include: Option<bool>,
88
89        #[serde(default, skip_serializing_if = "Option::is_none")]
90        min: Option<f64>,
91
92        #[serde(default, skip_serializing_if = "Option::is_none")]
93        max: Option<f64>,
94
95        #[serde(default, skip_serializing_if = "Option::is_none")]
96        step: Option<f64>,
97    },
98    String {
99        key: String,
100        default: String,
101        #[serde(default, skip_serializing_if = "Option::is_none")]
102        placeholder: Option<String>,
103    },
104    Color {
105        key: String,
106        default: String,
107    },
108    Palette {
109        key: String,
110        default: String,
111
112        #[serde(default, skip_serializing_if = "Option::is_none")]
113        max: Option<usize>,
114    },
115    GradientStops {
116        key: String,
117        default: String,
118
119        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
120        discrete: bool,
121    },
122    DatetimeFormat {
123        /// Plugin-declared default `date_format`, shown by the editor in
124        /// unedited fields and elided from serialized configs.
125        #[serde(default, skip_serializing_if = "Option::is_none")]
126        default: Option<DatetimeFormatType>,
127    },
128    StringFormat,
129    NumberSeriesStyle {
130        default: NumberSeriesStyleDefaultConfig,
131    },
132    Symbols {
133        default: KeyValueOpts,
134    },
135    NumberFormat {
136        /// Plugin-declared default format, keyed like `number_format`
137        /// itself.
138        #[serde(default, skip_serializing_if = "Option::is_none")]
139        default: Option<CustomNumberFormatConfig>,
140    },
141    AggregateDepth,
142
143    Group {
144        key: String,
145        #[serde(default)]
146        fields: Vec<ControlSpec>,
147    },
148}
149
150#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
151pub struct EnumVariant {
152    pub value: String,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub label: Option<String>,
155}
156
157/// Fit `stops` to a `discrete` field's fixed pair: an over-length value
158/// keeps only its two end colors, pinned to `0`/`1`.
159pub fn discrete_pair(stops: Vec<GradientStopSpec>) -> Vec<GradientStopSpec> {
160    let stops = canonicalize_gradient_stops(stops);
161    match (stops.first(), stops.last()) {
162        (Some(first), Some(last)) if stops.len() > 2 => vec![
163            GradientStopSpec {
164                color: first.color.clone(),
165                offset: 0.0,
166            },
167            GradientStopSpec {
168                color: last.color.clone(),
169                offset: 1.0,
170            },
171        ],
172        _ => stops,
173    }
174}
175
176impl ColumnConfigSchema {
177    pub fn canonicalize(self) -> Self {
178        self.canonicalize_defaults().group_format_controls()
179    }
180
181    pub fn group_format_controls(mut self) -> Self {
182        fn is_format(spec: &ControlSpec) -> bool {
183            matches!(
184                spec,
185                ControlSpec::NumberFormat { .. }
186                    | ControlSpec::DatetimeFormat { .. }
187                    | ControlSpec::StringFormat
188            )
189        }
190
191        fn walk(fields: &mut Vec<ControlSpec>) {
192            for spec in fields.iter_mut() {
193                if let ControlSpec::Group { key, fields } = spec
194                    && key != "format"
195                {
196                    walk(fields);
197                }
198            }
199
200            let first = fields.iter().position(is_format);
201            if let Some(first) = first {
202                let mut formats = vec![];
203                let mut i = first;
204                while i < fields.len() {
205                    if is_format(&fields[i]) {
206                        formats.push(fields.remove(i));
207                    } else {
208                        i += 1;
209                    }
210                }
211
212                fields.insert(first, ControlSpec::Group {
213                    key: "format".to_owned(),
214                    fields: formats,
215                });
216            }
217        }
218
219        walk(&mut self.fields);
220        self
221    }
222
223    /// Canonicalize every CSS-valued default at schema ingest, dropping
224    /// (and logging) fields whose default fails its kind's reader.
225    pub fn canonicalize_defaults(mut self) -> Self {
226        fn canonicalize_specs(fields: &mut Vec<ControlSpec>) {
227            fields.retain_mut(|spec| {
228                let (kind, key, default) = match spec {
229                    ControlSpec::Group { fields, .. } => {
230                        canonicalize_specs(fields);
231                        return !fields.is_empty();
232                    },
233                    ControlSpec::Color { key, default } => (CssKind::Color, key, default),
234                    ControlSpec::Palette { key, default, .. } => (CssKind::Palette, key, default),
235                    ControlSpec::GradientStops { key, default, .. } => {
236                        (CssKind::Gradient, key, default)
237                    },
238                    _ => return true,
239                };
240
241                match kind.canonicalize(default) {
242                    Ok(canonical) => {
243                        *default = canonical;
244                        true
245                    },
246                    Err(error) => {
247                        tracing::error!("Dropping `{key}` — invalid schema default: {error}");
248                        false
249                    },
250                }
251            });
252        }
253
254        canonicalize_specs(&mut self.fields);
255        self
256    }
257
258    /// The CSS kind of the control owning `key`, if it is CSS-valued.
259    pub fn css_kind_of(&self, key: &str) -> Option<CssKind> {
260        self.leaf_fields().into_iter().find_map(|spec| match spec {
261            ControlSpec::Color { key: k, .. } if k == key => Some(CssKind::Color),
262            ControlSpec::Palette { key: k, .. } if k == key => Some(CssKind::Palette),
263            ControlSpec::GradientStops { key: k, .. } if k == key => Some(CssKind::Gradient),
264            _ => None,
265        })
266    }
267}
268
269impl ControlSpec {
270    /// Top-level JSON keys this control owns when its value is serialized
271    /// into a column's config map. For primitives this is just `[key]`;
272    /// for composites it's the set of fields the wrapped sub-struct
273    /// flattens. Used by [`ColumnConfigSchema::active_keys`] to filter the
274    /// `columns_config` blob passed to `plugin.restore()`.
275    pub fn serialized_keys(&self) -> Vec<&str> {
276        match self {
277            ControlSpec::DatetimeFormat { .. } => vec!["date_format"],
278            ControlSpec::StringFormat => vec!["format"],
279            ControlSpec::NumberSeriesStyle { .. } => vec!["chart_type", "stack"],
280            ControlSpec::Symbols { .. } => vec!["symbols"],
281            ControlSpec::NumberFormat { .. } => vec!["number_format"],
282            ControlSpec::AggregateDepth => vec!["aggregate_depth"],
283            ControlSpec::Enum { key, .. }
284            | ControlSpec::Bool { key, .. }
285            | ControlSpec::Number { key, .. }
286            | ControlSpec::String { key, .. }
287            | ControlSpec::Color { key, .. }
288            | ControlSpec::Palette { key, .. }
289            | ControlSpec::GradientStops { key, .. } => vec![key.as_str()],
290            ControlSpec::Group { fields, .. } => {
291                fields.iter().flat_map(|f| f.serialized_keys()).collect()
292            },
293        }
294    }
295}
296
297/// One UI-emitted change to a single schema field. The emitting widget
298/// declares which top-level keys the update is allowed to write
299/// (`keys` — equivalent to the field's [`ControlSpec::serialized_keys`])
300/// and a partial new sub-state (`value`).
301#[derive(Clone, Debug, Deserialize, Serialize)]
302pub struct ColumnConfigFieldUpdate {
303    pub keys: Vec<String>,
304    pub value: serde_json::Map<String, Value>,
305}
306
307/// Filter a per-column config map to only the keys advertised by the
308/// active plugin's schema. Foreign keys (left over from a previous plugin)
309/// stay in the unfiltered presentation state but never reach `restore()`.
310pub fn filter_to_schema(
311    config: &serde_json::Map<String, Value>,
312    active_keys: &HashSet<String>,
313) -> serde_json::Map<String, Value> {
314    config
315        .iter()
316        .filter(|(k, _)| active_keys.contains(k.as_str()))
317        .map(|(k, v)| (k.clone(), v.clone()))
318        .collect()
319}
320
321#[cfg(test)]
322mod tests {
323    use serde_json::json;
324
325    use super::*;
326
327    fn color(key: &str, default: &str) -> ControlSpec {
328        ControlSpec::Color {
329            key: key.to_owned(),
330            default: default.to_owned(),
331        }
332    }
333
334    fn flag(key: &str) -> ControlSpec {
335        ControlSpec::Bool {
336            key: key.to_owned(),
337            default: false,
338        }
339    }
340
341    fn group(key: &str, fields: Vec<ControlSpec>) -> ControlSpec {
342        ControlSpec::Group {
343            key: key.to_owned(),
344            fields,
345        }
346    }
347
348    #[test]
349    fn group_deserializes_recursively() {
350        let schema: ColumnConfigSchema = serde_json::from_value(json!({
351            "fields": [{
352                "kind": "Group",
353                "key": "legend",
354                "fields": [
355                    { "kind": "Bool", "key": "legend_on", "default": false },
356                    {
357                        "kind": "Group",
358                        "key": "inner",
359                        "fields": [{ "kind": "Color", "key": "color", "default": "#ff0000" }]
360                    }
361                ]
362            }]
363        }))
364        .unwrap();
365
366        let keys = schema.active_keys();
367        assert_eq!(
368            keys,
369            HashSet::from(["legend_on".to_owned(), "color".to_owned()])
370        );
371
372        let leaves = schema.leaf_fields();
373        assert_eq!(leaves.len(), 2);
374        assert!(
375            leaves
376                .iter()
377                .all(|s| !matches!(s, ControlSpec::Group { .. }))
378        );
379    }
380
381    #[test]
382    fn grouped_schema_is_equivalent_to_flat() {
383        let flat = ColumnConfigSchema {
384            fields: vec![flag("stack"), color("color", "#0366d6")],
385        };
386
387        let grouped = ColumnConfigSchema {
388            fields: vec![group("series", vec![
389                flag("stack"),
390                color("color", "#0366d6"),
391            ])],
392        };
393
394        assert_eq!(flat.active_keys(), grouped.active_keys());
395        assert_eq!(flat.css_kind_of("color"), grouped.css_kind_of("color"));
396        assert_eq!(flat.css_kind_of("stack"), grouped.css_kind_of("stack"));
397    }
398
399    #[test]
400    fn format_controls_group_and_merge() {
401        let schema = ColumnConfigSchema {
402            fields: vec![
403                ControlSpec::NumberFormat { default: None },
404                flag("flag"),
405                ControlSpec::StringFormat,
406            ],
407        }
408        .group_format_controls();
409
410        assert_eq!(schema.fields.len(), 2);
411        let ControlSpec::Group { key, fields } = &schema.fields[0] else {
412            panic!("expected format group first");
413        };
414
415        assert_eq!(key, "format");
416        assert!(matches!(fields[0], ControlSpec::NumberFormat { .. }));
417        assert!(matches!(fields[1], ControlSpec::StringFormat));
418        assert!(matches!(&schema.fields[1], ControlSpec::Bool { .. }));
419
420        assert_eq!(
421            schema.active_keys(),
422            HashSet::from([
423                "number_format".to_owned(),
424                "format".to_owned(),
425                "flag".to_owned()
426            ])
427        );
428    }
429
430    #[test]
431    fn format_grouping_recurses_but_never_double_wraps() {
432        let schema = ColumnConfigSchema {
433            fields: vec![
434                group("format", vec![ControlSpec::NumberFormat { default: None }]),
435                group("styling", vec![flag("x"), ControlSpec::DatetimeFormat {
436                    default: None,
437                }]),
438            ],
439        }
440        .group_format_controls();
441
442        let ControlSpec::Group { key, fields } = &schema.fields[0] else {
443            panic!("expected group");
444        };
445
446        assert_eq!(key, "format");
447        assert!(matches!(fields[0], ControlSpec::NumberFormat { .. }));
448
449        let ControlSpec::Group { fields, .. } = &schema.fields[1] else {
450            panic!("expected group");
451        };
452
453        assert!(matches!(
454            &fields[1],
455            ControlSpec::Group { key, fields }
456                if key == "format" && matches!(fields[0], ControlSpec::DatetimeFormat { .. })
457        ));
458    }
459
460    #[test]
461    fn format_controls_deserialize_without_default_payload() {
462        let schema: ColumnConfigSchema = serde_json::from_value(json!({
463            "fields": [{ "kind": "NumberFormat" }, { "kind": "DatetimeFormat" }]
464        }))
465        .unwrap();
466
467        assert!(matches!(&schema.fields[0], ControlSpec::NumberFormat {
468            default: None
469        }));
470        assert!(matches!(&schema.fields[1], ControlSpec::DatetimeFormat {
471            default: None
472        }));
473    }
474
475    #[test]
476    fn number_format_default_payload_deserializes_flattened_families() {
477        let schema: ColumnConfigSchema = serde_json::from_value(json!({
478            "fields": [{
479                "kind": "NumberFormat",
480                "default": {
481                    "notation": "compact",
482                    "compactDisplay": "short",
483                    "minimumFractionDigits": 0,
484                    "maximumFractionDigits": 1
485                }
486            }]
487        }))
488        .unwrap();
489
490        let ControlSpec::NumberFormat {
491            default: Some(default),
492        } = &schema.fields[0]
493        else {
494            panic!("expected NumberFormat with default");
495        };
496
497        assert_eq!(
498            default._notation,
499            Some(crate::config::Notation::Compact(
500                crate::config::CompactDisplay::Short
501            ))
502        );
503        assert_eq!(default._style, None);
504        assert_eq!(default.minimum_fraction_digits, Some(0.));
505        assert_eq!(default.maximum_fraction_digits, Some(1.));
506        assert_eq!(
507            schema.active_keys(),
508            HashSet::from(["number_format".to_owned()])
509        );
510    }
511
512    #[test]
513    fn datetime_format_default_payload_deserializes_simple_arm() {
514        let schema: ColumnConfigSchema = serde_json::from_value(json!({
515            "fields": [{
516                "kind": "DatetimeFormat",
517                "default": { "dateStyle": "medium", "timeStyle": "disabled" }
518            }]
519        }))
520        .unwrap();
521
522        let ControlSpec::DatetimeFormat {
523            default: Some(DatetimeFormatType::Simple(simple)),
524        } = &schema.fields[0]
525        else {
526            panic!("expected DatetimeFormat with Simple default");
527        };
528
529        assert_eq!(
530            simple.date_style,
531            crate::config::SimpleDatetimeFormat::Medium
532        );
533        assert_eq!(
534            simple.time_style,
535            crate::config::SimpleDatetimeFormat::Disabled
536        );
537    }
538
539    #[test]
540    fn canonicalize_defaults_recurses_and_drops_empty_groups() {
541        let schema = ColumnConfigSchema {
542            fields: vec![
543                group("ok", vec![color("good", "RGB(255,0,0)"), flag("flag")]),
544                group("doomed", vec![color("bad", "not-a-color")]),
545            ],
546        }
547        .canonicalize_defaults();
548
549        assert_eq!(schema.fields.len(), 1);
550        let ControlSpec::Group { key, fields } = &schema.fields[0] else {
551            panic!("expected group");
552        };
553
554        assert_eq!(key, "ok");
555        assert!(matches!(
556            &fields[0],
557            ControlSpec::Color { default, .. } if default == "#ff0000"
558        ));
559    }
560}