Skip to main content

perspective_viewer/config/
workspace_config.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::BTreeMap;
14
15use perspective_client::config::Filter;
16
17use crate::config::{PanelViewerConfig, ViewerConfigInitial};
18use crate::utils::CssKind;
19
20/// The workspace config format (`{version, active?, layout, panels}`) —
21/// the multi-panel counterpart of the single-panel [`ViewerConfig`] — as
22/// emitted by [`PerspectiveViewerElement::save`].
23///
24/// - `panels` entries are [`PanelViewerConfig`]s: per-panel state only, no
25///   `settings` key (element-level state).
26/// - `active` names the panel targeted by the *open* settings sidebar; it is
27///   omitted when the sidebar is closed.
28#[derive(serde::Serialize, ts_rs::TS)]
29pub struct WorkspaceConfig {
30    pub version: String,
31
32    #[serde(skip_serializing_if = "Option::is_none")]
33    #[ts(optional)]
34    pub active: Option<String>,
35
36    pub layout: Option<crate::js::Layout>,
37
38    /// `BTreeMap` (not `HashMap`) so `save()` serializes panels in a
39    /// DETERMINISTIC (sorted) key order — a fresh `HashMap` per call
40    /// iterates in a per-instance random order, which made consecutive
41    /// `save()` outputs byte-unstable.
42    pub panels: BTreeMap<String, PanelViewerConfig>,
43
44    /// The element-level global (master/detail cross-) filters. A transient
45    /// overlay on every detail panel's view — persisted here, never in a
46    /// per-panel entry. Omitted when empty.
47    #[serde(skip_serializing_if = "Vec::is_empty")]
48    #[ts(as = "Option<_>")]
49    #[ts(optional)]
50    pub global_filters: Vec<Filter>,
51
52    /// The MASTER (filter-source) panels' ids, referencing `panels` keys.
53    /// Roles are layout state (like the panel arrangement), so they persist;
54    /// which master contributed which clause does not — restored
55    /// `global_filters` are one unattributed bucket. Omitted when empty.
56    #[serde(skip_serializing_if = "Vec::is_empty")]
57    #[ts(as = "Option<_>")]
58    #[ts(optional)]
59    pub masters: Vec<String>,
60
61    /// Named color-scale definitions shared by every panel: CSS custom
62    /// property name (`--psp-user--<kind>-<name>`) → canonical CSS
63    /// value.
64    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
65    #[ts(as = "Option<_>")]
66    #[ts(optional)]
67    pub palette: BTreeMap<String, String>,
68}
69
70/// The parse target of a workspace config in
71/// [`PerspectiveViewerElement::restoreWorkspace`]. Mirrors
72/// [`WorkspaceConfig`], but `panels` entries are [`ViewerConfigInitial`]s —
73/// every entry creates a NEW panel, so `table` is required by type (a
74/// stray per-panel `settings` key is ignored; it is element-level state,
75/// carried by the top-level `active` field).
76#[derive(serde::Deserialize, ts_rs::TS)]
77pub struct WorkspaceConfigUpdate {
78    #[serde(default)]
79    #[ts(optional)]
80    pub active: Option<String>,
81
82    #[serde(default)]
83    #[ts(optional)]
84    pub layout: Option<crate::js::Layout>,
85
86    pub panels: BTreeMap<String, ViewerConfigInitial>,
87
88    /// The element-level global (master/detail cross-) filters to re-apply as
89    /// a transient overlay on every DETAIL panel. Restored as one
90    /// unattributed bucket: the next selection on any master replaces it.
91    /// `Option` so an explicit `undefined` property deserializes as
92    /// `None` like an absent key (also `masters` / `palette` below).
93    #[serde(default)]
94    #[ts(as = "Option<_>")]
95    #[ts(optional)]
96    pub global_filters: Option<Vec<Filter>>,
97
98    /// The master (filter-source) panels, by saved `panels` key. An id not in
99    /// `panels` warns and is dropped.
100    #[serde(default)]
101    #[ts(as = "Option<_>")]
102    #[ts(optional)]
103    pub masters: Option<Vec<String>>,
104
105    /// Named color-scale definitions to apply to the host (see
106    /// [`WorkspaceConfig::palette`]), replacing any previously restored
107    /// palette.
108    #[serde(default)]
109    #[ts(as = "Option<_>")]
110    #[ts(optional)]
111    pub palette: Option<BTreeMap<String, String>>,
112}
113
114/// Validate a restored palette map: each key's `--psp-user--<kind>-`
115/// prefix selects the reader that canonicalizes its value.
116pub fn validate_palette(
117    palette: BTreeMap<String, String>,
118) -> Result<BTreeMap<String, String>, String> {
119    palette
120        .into_iter()
121        .map(|(name, value)| {
122            let kind = CssKind::of_var(&name).ok_or_else(|| {
123                format!(
124                    "`palette` key `{name}` must start with `--psp-user--gradient-`, \
125                     `--psp-user--palette-` or `--psp-user--color-`"
126                )
127            })?;
128
129            let canonical = kind
130                .canonicalize(&value)
131                .map_err(|error| format!("`palette[\"{name}\"]`: {error}"))?;
132
133            Ok((name, canonical))
134        })
135        .collect()
136}
137
138#[cfg(test)]
139mod tests {
140    use serde_json::json;
141
142    use super::*;
143
144    #[test]
145    fn palette_serializes_only_when_present() {
146        let config = WorkspaceConfig {
147            version: "x".to_owned(),
148            active: None,
149            layout: None,
150            panels: BTreeMap::new(),
151            global_filters: vec![],
152            masters: vec![],
153            palette: BTreeMap::new(),
154        };
155
156        assert_eq!(
157            serde_json::to_value(&config).unwrap(),
158            json!({ "version": "x", "layout": null, "panels": {} })
159        );
160
161        let mut palette = BTreeMap::new();
162        palette.insert("--psp-user--color-hot".to_owned(), "#ff0000".to_owned());
163
164        let config = WorkspaceConfig { palette, ..config };
165        assert_eq!(
166            serde_json::to_value(&config).unwrap(),
167            json!({
168                "version": "x",
169                "layout": null,
170                "panels": {},
171                "palette": { "--psp-user--color-hot": "#ff0000" },
172            })
173        );
174    }
175
176    #[test]
177    fn update_palette_defaults_empty_and_validates_by_prefix() {
178        let update: WorkspaceConfigUpdate =
179            serde_json::from_value(json!({ "panels": {} })).unwrap();
180        assert!(update.palette.is_none());
181
182        let update: WorkspaceConfigUpdate = serde_json::from_value(json!({
183            "panels": {},
184            "palette": {
185                "--psp-user--gradient-1": "linear-gradient(#000, #fff)",
186                "--psp-user--palette-warm": "linear-gradient(90deg, RGB(255,0,0), #ff0)",
187                "--psp-user--color-hot": "#F00",
188            },
189        }))
190        .unwrap();
191
192        let valid = validate_palette(update.palette.unwrap()).unwrap();
193        assert_eq!(
194            valid.get("--psp-user--gradient-1").unwrap(),
195            "linear-gradient(to right, #000000 0%, #ffffff 100%)"
196        );
197        assert_eq!(
198            valid.get("--psp-user--palette-warm").unwrap(),
199            "linear-gradient(to right, #ff0000, #ffff00)"
200        );
201        assert_eq!(valid.get("--psp-user--color-hot").unwrap(), "#ff0000");
202
203        let bad = |name: &str, value: &str| {
204            let mut map = BTreeMap::new();
205            map.insert(name.to_owned(), value.to_owned());
206            validate_palette(map).unwrap_err()
207        };
208
209        assert!(bad("--psp-user--other-1", "#ff0000").contains("--psp-user--other-1"));
210        assert!(bad("--psp-charts--gradient", "#ff0000").contains("must start with"));
211        assert!(
212            bad(
213                "--psp-user--palette-1",
214                "linear-gradient(#000 0%, #fff 100%)"
215            )
216            .contains("--psp-user--palette-1")
217        );
218        assert!(bad("--psp-user--gradient-1", "#ff0000").contains("linear-gradient"));
219        assert!(bad("--psp-user--color-1", "red").contains("red"));
220    }
221}