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/// A per-column statistic a [`ControlSpec::Number`] may take its default from.
65#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
66#[serde(rename_all = "snake_case")]
67pub enum NumberStat {
68    /// The largest absolute value in the column.
69    AbsMax,
70}
71
72/// Discriminated union of widget kinds the viewer can render. Composite
73/// variants wrap an existing rich Yew component and carry only the
74/// component's `*DefaultConfig`. Primitive variants render generic scalar
75/// widgets and carry their own `key` inline; the visible label is
76/// resolved at CSS time via `--psp-label--<key>--content`.
77#[derive(Clone, Debug, Deserialize, Serialize)]
78#[serde(tag = "kind")]
79pub enum ControlSpec {
80    Enum {
81        key: String,
82        variants: Vec<EnumVariant>,
83        default: String,
84    },
85
86    /// A CSS `font-family` picked from the generic keywords plus the families
87    /// the host can enumerate, with optional size, bold and italic inputs
88    /// each bound to its own key.
89    Font {
90        key: String,
91        default: String,
92
93        #[serde(default, skip_serializing_if = "Option::is_none")]
94        size: Option<FontSize>,
95
96        #[serde(default, skip_serializing_if = "Option::is_none")]
97        bold: Option<FontToggle>,
98
99        #[serde(default, skip_serializing_if = "Option::is_none")]
100        italic: Option<FontToggle>,
101    },
102    /// A 3×3 anchor picker whose value is one of the nine [`Alignment`]
103    /// tokens.
104    Alignment {
105        key: String,
106
107        /// The cell shown as the unmodified value and elided from serialized
108        /// configs, or `None` when an unset key stands for something no cell
109        /// can show.
110        #[serde(default, skip_serializing_if = "Option::is_none")]
111        default: Option<Alignment>,
112
113        /// Only the four corner cells are selectable.
114        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
115        corners: bool,
116    },
117    Bool {
118        key: String,
119        default: bool,
120    },
121    Number {
122        key: String,
123        default: f64,
124
125        /// If `true`, always serialize this values even if it is the default.
126        #[serde(default, skip_serializing_if = "Option::is_none")]
127        include: Option<bool>,
128
129        #[serde(default, skip_serializing_if = "Option::is_none")]
130        min: Option<f64>,
131
132        #[serde(default, skip_serializing_if = "Option::is_none")]
133        max: Option<f64>,
134
135        #[serde(default, skip_serializing_if = "Option::is_none")]
136        step: Option<f64>,
137
138        /// The column statistic the HOST substitutes for `default`, once it has
139        /// fetched it.
140        #[serde(default, skip_serializing_if = "Option::is_none")]
141        default_stat: Option<NumberStat>,
142    },
143    String {
144        key: String,
145        default: String,
146        #[serde(default, skip_serializing_if = "Option::is_none")]
147        placeholder: Option<String>,
148    },
149    Color {
150        key: String,
151        default: String,
152    },
153    Palette {
154        key: String,
155        default: String,
156
157        #[serde(default, skip_serializing_if = "Option::is_none")]
158        max: Option<usize>,
159    },
160    GradientStops {
161        key: String,
162        default: String,
163
164        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
165        discrete: bool,
166    },
167    DatetimeFormat {
168        /// Plugin-declared default `date_format`, shown by the editor in
169        /// unedited fields and elided from serialized configs.
170        #[serde(default, skip_serializing_if = "Option::is_none")]
171        default: Option<DatetimeFormatType>,
172    },
173    NumberSeriesStyle {
174        default: NumberSeriesStyleDefaultConfig,
175    },
176    Symbols {
177        default: KeyValueOpts,
178    },
179    NumberFormat {
180        /// Plugin-declared default format, keyed like `number_format`
181        /// itself.
182        #[serde(default, skip_serializing_if = "Option::is_none")]
183        default: Option<CustomNumberFormatConfig>,
184    },
185    AggregateDepth,
186
187    Group {
188        key: String,
189        #[serde(default)]
190        fields: Vec<ControlSpec>,
191    },
192}
193
194/// One cell of a 3×3 anchor grid: a corner `top-left`/`top-right`/
195/// `bottom-left`/`bottom-right`, an edge `top`/`left`/`right`/`bottom`, or
196/// `center`.
197#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
198#[serde(rename_all = "kebab-case")]
199pub enum Alignment {
200    TopLeft,
201    Top,
202    TopRight,
203    Left,
204    Center,
205    Right,
206    BottomLeft,
207    Bottom,
208    BottomRight,
209}
210
211impl Alignment {
212    /// Every cell in row-major order.
213    pub const ALL: [Alignment; 9] = [
214        Alignment::TopLeft,
215        Alignment::Top,
216        Alignment::TopRight,
217        Alignment::Left,
218        Alignment::Center,
219        Alignment::Right,
220        Alignment::BottomLeft,
221        Alignment::Bottom,
222        Alignment::BottomRight,
223    ];
224    pub const CORNERS: [Alignment; 4] = [
225        Alignment::TopLeft,
226        Alignment::TopRight,
227        Alignment::BottomLeft,
228        Alignment::BottomRight,
229    ];
230
231    pub fn is_corner(self) -> bool {
232        Self::CORNERS.contains(&self)
233    }
234
235    /// The serialized token.
236    pub fn as_str(self) -> &'static str {
237        match self {
238            Alignment::TopLeft => "top-left",
239            Alignment::Top => "top",
240            Alignment::TopRight => "top-right",
241            Alignment::Left => "left",
242            Alignment::Center => "center",
243            Alignment::Right => "right",
244            Alignment::BottomLeft => "bottom-left",
245            Alignment::Bottom => "bottom",
246            Alignment::BottomRight => "bottom-right",
247        }
248    }
249
250    pub fn parse(src: &str) -> Option<Self> {
251        Self::ALL.into_iter().find(|x| x.as_str() == src)
252    }
253
254    /// `Top Left`-style text for accessible names.
255    pub fn humanized(self) -> String {
256        self.as_str()
257            .split('-')
258            .map(|word| {
259                let mut chars = word.chars();
260                match chars.next() {
261                    Some(first) => first.to_uppercase().chain(chars).collect::<String>(),
262                    None => String::new(),
263                }
264            })
265            .collect::<Vec<_>>()
266            .join(" ")
267    }
268}
269
270/// One boolean style toggle of a [`ControlSpec::Font`] control: the
271/// config key it writes and the value that counts as "not set".
272#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
273pub struct FontToggle {
274    pub key: String,
275    #[serde(default)]
276    pub default: bool,
277}
278
279/// The size input of a [`ControlSpec::Font`] control: a number (CSS px)
280/// under its own key, elided from serialized configs at `default`.
281#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
282pub struct FontSize {
283    pub key: String,
284    pub default: f64,
285
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub min: Option<f64>,
288
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub max: Option<f64>,
291
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub step: Option<f64>,
294}
295
296#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
297pub struct EnumVariant {
298    pub value: String,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub label: Option<String>,
301}
302
303/// Fit `stops` to a `discrete` field's fixed pair: an over-length value
304/// keeps only its two end colors, pinned to `0`/`1`.
305pub fn discrete_pair(stops: Vec<GradientStopSpec>) -> Vec<GradientStopSpec> {
306    let stops = canonicalize_gradient_stops(stops);
307    match (stops.first(), stops.last()) {
308        (Some(first), Some(last)) if stops.len() > 2 => vec![
309            GradientStopSpec {
310                color: first.color.clone(),
311                offset: 0.0,
312            },
313            GradientStopSpec {
314                color: last.color.clone(),
315                offset: 1.0,
316            },
317        ],
318        _ => stops,
319    }
320}
321
322impl ColumnConfigSchema {
323    pub fn canonicalize(self) -> Self {
324        self.canonicalize_defaults().group_format_controls()
325    }
326
327    /// Replace the `default` of every [`ControlSpec::Number`] that declares a
328    /// `default_stat` with that statistic, where the host has it.
329    pub fn resolve_stat_defaults(mut self, abs_max: Option<f64>) -> Self {
330        fn walk(fields: &mut [ControlSpec], abs_max: Option<f64>) {
331            for spec in fields {
332                match spec {
333                    ControlSpec::Group { fields, .. } => walk(fields, abs_max),
334                    ControlSpec::Number {
335                        default,
336                        default_stat: Some(NumberStat::AbsMax),
337                        ..
338                    } => {
339                        if let Some(abs_max) = abs_max {
340                            *default = abs_max;
341                        }
342                    },
343                    _ => {},
344                }
345            }
346        }
347
348        walk(&mut self.fields, abs_max);
349        self
350    }
351
352    pub fn group_format_controls(mut self) -> Self {
353        fn is_format(spec: &ControlSpec) -> bool {
354            matches!(
355                spec,
356                ControlSpec::NumberFormat { .. } | ControlSpec::DatetimeFormat { .. }
357            )
358        }
359
360        fn walk(fields: &mut Vec<ControlSpec>) {
361            for spec in fields.iter_mut() {
362                if let ControlSpec::Group { key, fields } = spec
363                    && key != "format"
364                {
365                    walk(fields);
366                }
367            }
368
369            let first = fields.iter().position(is_format);
370            if let Some(first) = first {
371                let mut formats = vec![];
372                let mut i = first;
373                while i < fields.len() {
374                    if is_format(&fields[i]) {
375                        formats.push(fields.remove(i));
376                    } else {
377                        i += 1;
378                    }
379                }
380
381                fields.insert(first, ControlSpec::Group {
382                    key: "format".to_owned(),
383                    fields: formats,
384                });
385            }
386        }
387
388        walk(&mut self.fields);
389        self
390    }
391
392    /// Canonicalize every CSS-valued default at schema ingest, dropping
393    /// (and logging) fields whose default fails its kind's reader.
394    pub fn canonicalize_defaults(mut self) -> Self {
395        fn canonicalize_specs(fields: &mut Vec<ControlSpec>) {
396            fields.retain_mut(|spec| {
397                let (kind, key, default) = match spec {
398                    ControlSpec::Group { fields, .. } => {
399                        canonicalize_specs(fields);
400                        return !fields.is_empty();
401                    },
402                    ControlSpec::Alignment {
403                        key,
404                        default: Some(default),
405                        corners: true,
406                    } if !default.is_corner() => {
407                        tracing::error!(
408                            "Dropping `{key}` — default `{}` is not a corner",
409                            default.as_str()
410                        );
411
412                        return false;
413                    },
414                    ControlSpec::Color { key, default } => (CssKind::Color, key, default),
415                    ControlSpec::Palette { key, default, .. } => (CssKind::Palette, key, default),
416                    ControlSpec::GradientStops { key, default, .. } => {
417                        (CssKind::Gradient, key, default)
418                    },
419                    _ => return true,
420                };
421
422                match kind.canonicalize(default) {
423                    Ok(canonical) => {
424                        *default = canonical;
425                        true
426                    },
427                    Err(error) => {
428                        tracing::error!("Dropping `{key}` — invalid schema default: {error}");
429                        false
430                    },
431                }
432            });
433        }
434
435        canonicalize_specs(&mut self.fields);
436        self
437    }
438
439    /// The variants of the [`ControlSpec::Enum`] owning `key`, if any.
440    pub fn enum_variants_of(&self, key: &str) -> Option<&[EnumVariant]> {
441        self.leaf_fields().into_iter().find_map(|spec| match spec {
442            ControlSpec::Enum {
443                key: k, variants, ..
444            } if k == key => Some(variants.as_slice()),
445            _ => None,
446        })
447    }
448
449    /// The CSS kind of the control owning `key`, if it is CSS-valued.
450    pub fn css_kind_of(&self, key: &str) -> Option<CssKind> {
451        self.leaf_fields().into_iter().find_map(|spec| match spec {
452            ControlSpec::Color { key: k, .. } if k == key => Some(CssKind::Color),
453            ControlSpec::Palette { key: k, .. } if k == key => Some(CssKind::Palette),
454            ControlSpec::GradientStops { key: k, .. } if k == key => Some(CssKind::Gradient),
455            _ => None,
456        })
457    }
458}
459
460impl ControlSpec {
461    /// Top-level JSON keys this control owns when its value is serialized
462    /// into a column's config map. For primitives this is just `[key]`;
463    /// for composites it's the set of fields the wrapped sub-struct
464    /// flattens. Used by [`ColumnConfigSchema::active_keys`] to filter the
465    /// `columns_config` blob passed to `plugin.restore()`.
466    pub fn serialized_keys(&self) -> Vec<&str> {
467        match self {
468            ControlSpec::DatetimeFormat { .. } => vec!["date_format"],
469            ControlSpec::Font {
470                key,
471                size,
472                bold,
473                italic,
474                ..
475            } => [
476                Some(key.as_str()),
477                size.as_ref().map(|s| s.key.as_str()),
478                bold.as_ref().map(|t| t.key.as_str()),
479                italic.as_ref().map(|t| t.key.as_str()),
480            ]
481            .into_iter()
482            .flatten()
483            .collect(),
484            ControlSpec::NumberSeriesStyle { .. } => vec!["chart_type", "stack"],
485            ControlSpec::Symbols { .. } => vec!["symbols"],
486            ControlSpec::NumberFormat { .. } => vec!["number_format"],
487            ControlSpec::AggregateDepth => vec!["aggregate_depth"],
488            ControlSpec::Enum { key, .. }
489            | ControlSpec::Alignment { key, .. }
490            | ControlSpec::Bool { key, .. }
491            | ControlSpec::Number { key, .. }
492            | ControlSpec::String { key, .. }
493            | ControlSpec::Color { key, .. }
494            | ControlSpec::Palette { key, .. }
495            | ControlSpec::GradientStops { key, .. } => vec![key.as_str()],
496            ControlSpec::Group { fields, .. } => {
497                fields.iter().flat_map(|f| f.serialized_keys()).collect()
498            },
499        }
500    }
501}
502
503/// One UI-emitted change to a single schema field. The emitting widget
504/// declares which top-level keys the update is allowed to write
505/// (`keys` — equivalent to the field's [`ControlSpec::serialized_keys`])
506/// and a partial new sub-state (`value`).
507#[derive(Clone, Debug, Deserialize, Serialize)]
508pub struct ColumnConfigFieldUpdate {
509    pub keys: Vec<String>,
510    pub value: serde_json::Map<String, Value>,
511}
512
513/// Filter a per-column config map to only the keys advertised by the
514/// active plugin's schema. Foreign keys (left over from a previous plugin)
515/// stay in the unfiltered presentation state but never reach `restore()`.
516pub fn filter_to_schema(
517    config: &serde_json::Map<String, Value>,
518    active_keys: &HashSet<String>,
519) -> serde_json::Map<String, Value> {
520    config
521        .iter()
522        .filter(|(k, _)| active_keys.contains(k.as_str()))
523        .map(|(k, v)| (k.clone(), v.clone()))
524        .collect()
525}