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