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    CssKind, CustomNumberFormatConfig, DatetimeFormatType, KeyValueOpts,
20    NumberSeriesStyleDefaultConfig,
21};
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/// One stop of a [`ControlSpec::GradientStops`] value in its in-memory
158/// form: a `#rrggbb` color at `offset` ∈ `[0, 1]`.
159#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
160pub struct GradientStopSpec {
161    pub color: String,
162    pub offset: f64,
163}
164
165/// The [`ControlSpec::GradientStops`] canonical stop order: offsets
166/// clamped to `[0, 1]` and rounded to 3 decimals, stops sorted stably
167/// by offset.
168pub fn canonicalize_gradient_stops(mut stops: Vec<GradientStopSpec>) -> Vec<GradientStopSpec> {
169    for stop in &mut stops {
170        stop.offset = (stop.offset.clamp(0.0, 1.0) * 1000.0).round() / 1000.0;
171    }
172
173    stops.sort_by(|a, b| {
174        a.offset
175            .partial_cmp(&b.offset)
176            .unwrap_or(std::cmp::Ordering::Equal)
177    });
178
179    stops
180}
181
182/// Fit `stops` to a `discrete` field's fixed pair: an over-length value
183/// keeps only its two end colors, pinned to `0`/`1`.
184pub fn discrete_pair(stops: Vec<GradientStopSpec>) -> Vec<GradientStopSpec> {
185    let stops = canonicalize_gradient_stops(stops);
186    match (stops.first(), stops.last()) {
187        (Some(first), Some(last)) if stops.len() > 2 => vec![
188            GradientStopSpec {
189                color: first.color.clone(),
190                offset: 0.0,
191            },
192            GradientStopSpec {
193                color: last.color.clone(),
194                offset: 1.0,
195            },
196        ],
197        _ => stops,
198    }
199}
200
201impl ColumnConfigSchema {
202    pub fn canonicalize(self) -> Self {
203        self.canonicalize_defaults().group_format_controls()
204    }
205
206    pub fn group_format_controls(mut self) -> Self {
207        fn is_format(spec: &ControlSpec) -> bool {
208            matches!(
209                spec,
210                ControlSpec::NumberFormat { .. }
211                    | ControlSpec::DatetimeFormat { .. }
212                    | ControlSpec::StringFormat
213            )
214        }
215
216        fn walk(fields: &mut Vec<ControlSpec>) {
217            for spec in fields.iter_mut() {
218                if let ControlSpec::Group { key, fields } = spec
219                    && key != "format"
220                {
221                    walk(fields);
222                }
223            }
224
225            let first = fields.iter().position(is_format);
226            if let Some(first) = first {
227                let mut formats = vec![];
228                let mut i = first;
229                while i < fields.len() {
230                    if is_format(&fields[i]) {
231                        formats.push(fields.remove(i));
232                    } else {
233                        i += 1;
234                    }
235                }
236
237                fields.insert(first, ControlSpec::Group {
238                    key: "format".to_owned(),
239                    fields: formats,
240                });
241            }
242        }
243
244        walk(&mut self.fields);
245        self
246    }
247
248    /// Canonicalize every CSS-valued default at schema ingest, dropping
249    /// (and logging) fields whose default fails its kind's reader.
250    pub fn canonicalize_defaults(mut self) -> Self {
251        fn canonicalize_specs(fields: &mut Vec<ControlSpec>) {
252            fields.retain_mut(|spec| {
253                let (kind, key, default) = match spec {
254                    ControlSpec::Group { fields, .. } => {
255                        canonicalize_specs(fields);
256                        return !fields.is_empty();
257                    },
258                    ControlSpec::Color { key, default } => (CssKind::Color, key, default),
259                    ControlSpec::Palette { key, default, .. } => (CssKind::Palette, key, default),
260                    ControlSpec::GradientStops { key, default, .. } => {
261                        (CssKind::Gradient, key, default)
262                    },
263                    _ => return true,
264                };
265
266                match kind.canonicalize(default) {
267                    Ok(canonical) => {
268                        *default = canonical;
269                        true
270                    },
271                    Err(error) => {
272                        tracing::error!("Dropping `{key}` — invalid schema default: {error}");
273                        false
274                    },
275                }
276            });
277        }
278
279        canonicalize_specs(&mut self.fields);
280        self
281    }
282
283    /// The CSS kind of the control owning `key`, if it is CSS-valued.
284    pub fn css_kind_of(&self, key: &str) -> Option<CssKind> {
285        self.leaf_fields().into_iter().find_map(|spec| match spec {
286            ControlSpec::Color { key: k, .. } if k == key => Some(CssKind::Color),
287            ControlSpec::Palette { key: k, .. } if k == key => Some(CssKind::Palette),
288            ControlSpec::GradientStops { key: k, .. } if k == key => Some(CssKind::Gradient),
289            _ => None,
290        })
291    }
292}
293
294impl ControlSpec {
295    /// Top-level JSON keys this control owns when its value is serialized
296    /// into a column's config map. For primitives this is just `[key]`;
297    /// for composites it's the set of fields the wrapped sub-struct
298    /// flattens. Used by [`ColumnConfigSchema::active_keys`] to filter the
299    /// `columns_config` blob passed to `plugin.restore()`.
300    pub fn serialized_keys(&self) -> Vec<&str> {
301        match self {
302            ControlSpec::DatetimeFormat { .. } => vec!["date_format"],
303            ControlSpec::StringFormat => vec!["format"],
304            ControlSpec::NumberSeriesStyle { .. } => vec!["chart_type", "stack"],
305            ControlSpec::Symbols { .. } => vec!["symbols"],
306            ControlSpec::NumberFormat { .. } => vec!["number_format"],
307            ControlSpec::AggregateDepth => vec!["aggregate_depth"],
308            ControlSpec::Enum { key, .. }
309            | ControlSpec::Bool { key, .. }
310            | ControlSpec::Number { key, .. }
311            | ControlSpec::String { key, .. }
312            | ControlSpec::Color { key, .. }
313            | ControlSpec::Palette { key, .. }
314            | ControlSpec::GradientStops { key, .. } => vec![key.as_str()],
315            ControlSpec::Group { fields, .. } => {
316                fields.iter().flat_map(|f| f.serialized_keys()).collect()
317            },
318        }
319    }
320}
321
322/// One UI-emitted change to a single schema field. The emitting widget
323/// declares which top-level keys the update is allowed to write
324/// (`keys` — equivalent to the field's [`ControlSpec::serialized_keys`])
325/// and a partial new sub-state (`value`).
326#[derive(Clone, Debug, Deserialize, Serialize)]
327pub struct ColumnConfigFieldUpdate {
328    pub keys: Vec<String>,
329    pub value: serde_json::Map<String, Value>,
330}
331
332/// Filter a per-column config map to only the keys advertised by the
333/// active plugin's schema. Foreign keys (left over from a previous plugin)
334/// stay in the unfiltered presentation state but never reach `restore()`.
335pub fn filter_to_schema(
336    config: &serde_json::Map<String, Value>,
337    active_keys: &HashSet<String>,
338) -> serde_json::Map<String, Value> {
339    config
340        .iter()
341        .filter(|(k, _)| active_keys.contains(k.as_str()))
342        .map(|(k, v)| (k.clone(), v.clone()))
343        .collect()
344}
345
346#[cfg(test)]
347mod tests {
348    use serde_json::json;
349
350    use super::*;
351
352    fn color(key: &str, default: &str) -> ControlSpec {
353        ControlSpec::Color {
354            key: key.to_owned(),
355            default: default.to_owned(),
356        }
357    }
358
359    fn flag(key: &str) -> ControlSpec {
360        ControlSpec::Bool {
361            key: key.to_owned(),
362            default: false,
363        }
364    }
365
366    fn group(key: &str, fields: Vec<ControlSpec>) -> ControlSpec {
367        ControlSpec::Group {
368            key: key.to_owned(),
369            fields,
370        }
371    }
372
373    #[test]
374    fn group_deserializes_recursively() {
375        let schema: ColumnConfigSchema = serde_json::from_value(json!({
376            "fields": [{
377                "kind": "Group",
378                "key": "legend",
379                "fields": [
380                    { "kind": "Bool", "key": "legend_on", "default": false },
381                    {
382                        "kind": "Group",
383                        "key": "inner",
384                        "fields": [{ "kind": "Color", "key": "color", "default": "#ff0000" }]
385                    }
386                ]
387            }]
388        }))
389        .unwrap();
390
391        let keys = schema.active_keys();
392        assert_eq!(
393            keys,
394            HashSet::from(["legend_on".to_owned(), "color".to_owned()])
395        );
396
397        let leaves = schema.leaf_fields();
398        assert_eq!(leaves.len(), 2);
399        assert!(
400            leaves
401                .iter()
402                .all(|s| !matches!(s, ControlSpec::Group { .. }))
403        );
404    }
405
406    #[test]
407    fn grouped_schema_is_equivalent_to_flat() {
408        let flat = ColumnConfigSchema {
409            fields: vec![flag("stack"), color("color", "#0366d6")],
410        };
411
412        let grouped = ColumnConfigSchema {
413            fields: vec![group("series", vec![
414                flag("stack"),
415                color("color", "#0366d6"),
416            ])],
417        };
418
419        assert_eq!(flat.active_keys(), grouped.active_keys());
420        assert_eq!(flat.css_kind_of("color"), grouped.css_kind_of("color"));
421        assert_eq!(flat.css_kind_of("stack"), grouped.css_kind_of("stack"));
422    }
423
424    #[test]
425    fn format_controls_group_and_merge() {
426        let schema = ColumnConfigSchema {
427            fields: vec![
428                ControlSpec::NumberFormat { default: None },
429                flag("flag"),
430                ControlSpec::StringFormat,
431            ],
432        }
433        .group_format_controls();
434
435        assert_eq!(schema.fields.len(), 2);
436        let ControlSpec::Group { key, fields } = &schema.fields[0] else {
437            panic!("expected format group first");
438        };
439
440        assert_eq!(key, "format");
441        assert!(matches!(fields[0], ControlSpec::NumberFormat { .. }));
442        assert!(matches!(fields[1], ControlSpec::StringFormat));
443        assert!(matches!(&schema.fields[1], ControlSpec::Bool { .. }));
444
445        assert_eq!(
446            schema.active_keys(),
447            HashSet::from([
448                "number_format".to_owned(),
449                "format".to_owned(),
450                "flag".to_owned()
451            ])
452        );
453    }
454
455    #[test]
456    fn format_grouping_recurses_but_never_double_wraps() {
457        let schema = ColumnConfigSchema {
458            fields: vec![
459                group("format", vec![ControlSpec::NumberFormat { default: None }]),
460                group("styling", vec![flag("x"), ControlSpec::DatetimeFormat {
461                    default: None,
462                }]),
463            ],
464        }
465        .group_format_controls();
466
467        let ControlSpec::Group { key, fields } = &schema.fields[0] else {
468            panic!("expected group");
469        };
470
471        assert_eq!(key, "format");
472        assert!(matches!(fields[0], ControlSpec::NumberFormat { .. }));
473
474        let ControlSpec::Group { fields, .. } = &schema.fields[1] else {
475            panic!("expected group");
476        };
477
478        assert!(matches!(
479            &fields[1],
480            ControlSpec::Group { key, fields }
481                if key == "format" && matches!(fields[0], ControlSpec::DatetimeFormat { .. })
482        ));
483    }
484
485    #[test]
486    fn format_controls_deserialize_without_default_payload() {
487        let schema: ColumnConfigSchema = serde_json::from_value(json!({
488            "fields": [{ "kind": "NumberFormat" }, { "kind": "DatetimeFormat" }]
489        }))
490        .unwrap();
491
492        assert!(matches!(&schema.fields[0], ControlSpec::NumberFormat {
493            default: None
494        }));
495        assert!(matches!(&schema.fields[1], ControlSpec::DatetimeFormat {
496            default: None
497        }));
498    }
499
500    #[test]
501    fn number_format_default_payload_deserializes_flattened_families() {
502        let schema: ColumnConfigSchema = serde_json::from_value(json!({
503            "fields": [{
504                "kind": "NumberFormat",
505                "default": {
506                    "notation": "compact",
507                    "compactDisplay": "short",
508                    "minimumFractionDigits": 0,
509                    "maximumFractionDigits": 1
510                }
511            }]
512        }))
513        .unwrap();
514
515        let ControlSpec::NumberFormat {
516            default: Some(default),
517        } = &schema.fields[0]
518        else {
519            panic!("expected NumberFormat with default");
520        };
521
522        assert_eq!(
523            default._notation,
524            Some(crate::config::Notation::Compact(
525                crate::config::CompactDisplay::Short
526            ))
527        );
528        assert_eq!(default._style, None);
529        assert_eq!(default.minimum_fraction_digits, Some(0.));
530        assert_eq!(default.maximum_fraction_digits, Some(1.));
531        assert_eq!(
532            schema.active_keys(),
533            HashSet::from(["number_format".to_owned()])
534        );
535    }
536
537    #[test]
538    fn datetime_format_default_payload_deserializes_simple_arm() {
539        let schema: ColumnConfigSchema = serde_json::from_value(json!({
540            "fields": [{
541                "kind": "DatetimeFormat",
542                "default": { "dateStyle": "medium", "timeStyle": "disabled" }
543            }]
544        }))
545        .unwrap();
546
547        let ControlSpec::DatetimeFormat {
548            default: Some(DatetimeFormatType::Simple(simple)),
549        } = &schema.fields[0]
550        else {
551            panic!("expected DatetimeFormat with Simple default");
552        };
553
554        assert_eq!(
555            simple.date_style,
556            crate::config::SimpleDatetimeFormat::Medium
557        );
558        assert_eq!(
559            simple.time_style,
560            crate::config::SimpleDatetimeFormat::Disabled
561        );
562    }
563
564    #[test]
565    fn canonicalize_defaults_recurses_and_drops_empty_groups() {
566        let schema = ColumnConfigSchema {
567            fields: vec![
568                group("ok", vec![color("good", "RGB(255,0,0)"), flag("flag")]),
569                group("doomed", vec![color("bad", "not-a-color")]),
570            ],
571        }
572        .canonicalize_defaults();
573
574        assert_eq!(schema.fields.len(), 1);
575        let ControlSpec::Group { key, fields } = &schema.fields[0] else {
576            panic!("expected group");
577        };
578
579        assert_eq!(key, "ok");
580        assert!(matches!(
581            &fields[0],
582            ControlSpec::Color { default, .. } if default == "#ff0000"
583        ));
584    }
585}