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, ViewConfig};
16
17use crate::config::{OptionalUpdate, PanelViewerConfig, ViewerConfigInitial};
18use crate::utils::CssKind;
19
20/// A derived `Table`'s definition: the `Table` its source `View` reads, and
21/// the config of that `View`; generic over the `table` slot, which is a name
22/// in the workspace `derived_tables` map and a recursive [`TableRef`] when
23/// inlined in a single-panel config.
24#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)]
25pub struct DerivedTableConfig<T: ts_rs::TS = String> {
26    /// The `Table` this derivation reads, which may itself be another
27    /// derived `Table`.
28    pub table: T,
29
30    #[serde(flatten)]
31    pub view_config: ViewConfig,
32}
33
34/// What may occupy a `table` position in a single-panel config: a hosted
35/// `Table`'s name, or an inline derived-table definition recursing through
36/// its own `table` field until a concrete name.
37#[derive(Clone, Debug, PartialEq, serde::Serialize)]
38#[serde(untagged)]
39pub enum TableRef {
40    Name(String),
41    Derived(Box<DerivedTableConfig<TableRef>>),
42}
43
44/// Hand-written because the derived decl's recursion sits in a generic alias
45/// instantiation (`DerivedTableConfig<TableRef>`), which TypeScript expands
46/// eagerly and rejects as circular; recursion is legal only deferred through
47/// an object-literal node.
48impl ts_rs::TS for TableRef {
49    type OptionInnerType = Self;
50    type WithoutGenerics = Self;
51
52    fn docs() -> Option<String> {
53        Some(
54            "/**\n * What may occupy a `table` position in a single-panel config: a hosted\n * \
55             `Table`'s name, or an inline derived-table definition recursing through\n * its own \
56             `table` field until a concrete name.\n */\n"
57                .to_owned(),
58        )
59    }
60
61    fn name() -> String {
62        "TableRef".to_owned()
63    }
64
65    fn decl() -> String {
66        format!(
67            "type TableRef = string | (Omit<{}, \"table\"> & {{ table: TableRef }});",
68            <DerivedTableConfig as ts_rs::TS>::ident()
69        )
70    }
71
72    fn decl_concrete() -> String {
73        <Self as ts_rs::TS>::decl()
74    }
75
76    fn inline() -> String {
77        format!(
78            "string | (Omit<{}, \"table\"> & {{ table: TableRef }})",
79            <DerivedTableConfig as ts_rs::TS>::ident()
80        )
81    }
82
83    fn inline_flattened() -> String {
84        panic!("`TableRef` cannot be flattened")
85    }
86
87    fn visit_dependencies(visitor: &mut impl ts_rs::TypeVisitor)
88    where
89        Self: 'static,
90    {
91        visitor.visit::<DerivedTableConfig>();
92    }
93
94    fn output_path() -> Option<std::path::PathBuf> {
95        Some(std::path::PathBuf::from("TableRef.ts"))
96    }
97}
98
99impl Default for TableRef {
100    fn default() -> Self {
101        Self::Name(String::default())
102    }
103}
104
105impl From<String> for TableRef {
106    fn from(value: String) -> Self {
107        Self::Name(value)
108    }
109}
110
111impl TableRef {
112    /// The concrete `Table` name, or an error for an inline definition which
113    /// a restore boundary has not resolved.
114    pub fn name(&self) -> Result<&str, perspective_js::utils::ApiError> {
115        match self {
116            Self::Name(name) => Ok(name),
117            Self::Derived(_) => Err(perspective_js::utils::ApiError::new(
118                "Unresolved inline derived table",
119            )),
120        }
121    }
122}
123
124impl<'de> serde::Deserialize<'de> for TableRef {
125    /// Hand-written (not `untagged`) so a malformed inline definition
126    /// reports its own error instead of "data did not match any variant".
127    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
128    where
129        D: serde::Deserializer<'de>,
130    {
131        struct TableRefVisitor;
132        impl<'de> serde::de::Visitor<'de> for TableRefVisitor {
133            type Value = TableRef;
134
135            fn expecting(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136                fmt.write_str("a table name or an inline derived table definition")
137            }
138
139            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
140            where
141                E: serde::de::Error,
142            {
143                Ok(TableRef::Name(value.to_owned()))
144            }
145
146            fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
147            where
148                A: serde::de::MapAccess<'de>,
149            {
150                let config = <DerivedTableConfig<TableRef> as serde::Deserialize>::deserialize(
151                    serde::de::value::MapAccessDeserializer::new(map),
152                )?;
153
154                Ok(TableRef::Derived(Box::new(config)))
155            }
156        }
157
158        deserializer.deserialize_any(TableRefVisitor)
159    }
160}
161
162/// The workspace config format (`{version, active?, layout, panels}`) —
163/// the multi-panel counterpart of the single-panel [`ViewerConfig`] — as
164/// emitted by [`PerspectiveViewerElement::save`].
165///
166/// - `panels` entries are [`PanelViewerConfig`]s: per-panel state only, no
167///   `settings` key (element-level state).
168/// - `active` names the panel targeted by the *open* settings sidebar; it is
169///   omitted when the sidebar is closed.
170#[derive(serde::Serialize, ts_rs::TS)]
171pub struct WorkspaceConfig {
172    pub version: String,
173
174    #[serde(skip_serializing_if = "Option::is_none")]
175    #[ts(optional)]
176    pub active: Option<String>,
177
178    pub layout: Option<crate::js::Layout>,
179
180    /// `BTreeMap` (not `HashMap`) so `save()` serializes panels in a
181    /// DETERMINISTIC (sorted) key order — a fresh `HashMap` per call
182    /// iterates in a per-instance random order, which made consecutive
183    /// `save()` outputs byte-unstable.
184    pub panels: BTreeMap<String, PanelViewerConfig<String, String>>,
185
186    /// Named derived `Table`s, each a `View` of its `parent` promoted to a
187    /// `Table`. A `BTreeMap` for the same determinism reason as `panels`.
188    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
189    #[ts(as = "Option<_>")]
190    #[ts(optional)]
191    pub derived_tables: BTreeMap<String, DerivedTableConfig>,
192
193    /// The element-level global (master/detail cross-) filters. A transient
194    /// overlay on every detail panel's view — persisted here, never in a
195    /// per-panel entry. Omitted when empty.
196    #[serde(skip_serializing_if = "Vec::is_empty")]
197    #[ts(as = "Option<_>")]
198    #[ts(optional)]
199    pub global_filters: Vec<Filter>,
200
201    /// The MASTER (filter-source) panels' ids, referencing `panels` keys.
202    /// Roles are layout state (like the panel arrangement), so they persist;
203    /// which master contributed which clause does not — restored
204    /// `global_filters` are one unattributed bucket. Omitted when empty.
205    #[serde(skip_serializing_if = "Vec::is_empty")]
206    #[ts(as = "Option<_>")]
207    #[ts(optional)]
208    pub masters: Vec<String>,
209
210    /// Named color-scale definitions shared by every panel: CSS custom
211    /// property name (`--psp-user--<kind>-<name>`) → canonical CSS
212    /// value.
213    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
214    #[ts(as = "Option<_>")]
215    #[ts(optional)]
216    pub palette: BTreeMap<String, String>,
217}
218
219/// The [`PerspectiveViewerElement::restoreWorkspace`] argument: a field-wise
220/// update of the element's workspace state, like [`ViewerConfigUpdate`] for a
221/// panel, where an absent key is unchanged, `null` resets and a value
222/// replaces.
223#[derive(serde::Deserialize, ts_rs::TS)]
224pub struct WorkspaceConfigUpdate {
225    /// The panel to activate with the settings sidebar open, by `panels` key
226    /// or existing panel id; `null` closes the sidebar.
227    #[serde(default)]
228    #[ts(as = "Option<_>")]
229    #[ts(optional)]
230    pub active: OptionalUpdate<String>,
231
232    /// The layout tree to stage, naming `panels` keys or existing panel ids.
233    #[serde(default)]
234    #[ts(optional)]
235    pub layout: Option<crate::js::Layout>,
236
237    /// The complete replacement panel set, each [`ViewerConfigInitial`] entry
238    /// creating a new panel (`{}` empties the element); absent keeps the
239    /// existing panels.
240    #[serde(default)]
241    #[ts(optional)]
242    pub panels: Option<BTreeMap<String, ViewerConfigInitial>>,
243
244    /// The complete replacement set of derived `Table`s, in any order (they
245    /// are built parent-first); absent keeps the current ones, `{}` deletes
246    /// them all.
247    #[serde(default)]
248    #[ts(optional)]
249    pub derived_tables: Option<BTreeMap<String, DerivedTableConfig>>,
250
251    /// The element-level cross-filter set applied to every detail panel as
252    /// one unattributed bucket, replacing the current set; `null` clears it.
253    #[serde(default)]
254    #[ts(as = "Option<_>")]
255    #[ts(optional)]
256    pub global_filters: OptionalUpdate<Vec<Filter>>,
257
258    /// The master (filter-source) panels by `panels` key or existing panel
259    /// id, unknown ids dropped with a warning; `null` demotes every panel.
260    #[serde(default)]
261    #[ts(as = "Option<_>")]
262    #[ts(optional)]
263    pub masters: OptionalUpdate<Vec<String>>,
264
265    /// Named color-scale definitions applied to the host (see
266    /// [`WorkspaceConfig::palette`]), replacing the previous palette; `null`
267    /// clears it.
268    #[serde(default)]
269    #[ts(as = "Option<_>")]
270    #[ts(optional)]
271    pub palette: OptionalUpdate<BTreeMap<String, String>>,
272}
273
274/// Validate a restored palette map: each key's `--psp-user--<kind>-`
275/// prefix selects the reader that canonicalizes its value.
276pub fn validate_palette(
277    palette: BTreeMap<String, String>,
278) -> Result<BTreeMap<String, String>, String> {
279    palette
280        .into_iter()
281        .map(|(name, value)| {
282            let kind = CssKind::of_var(&name).ok_or_else(|| {
283                format!(
284                    "`palette` key `{name}` must start with `--psp-user--gradient-`, \
285                     `--psp-user--palette-` or `--psp-user--color-`"
286                )
287            })?;
288
289            let canonical = kind
290                .canonicalize(&value)
291                .map_err(|error| format!("`palette[\"{name}\"]`: {error}"))?;
292
293            Ok((name, canonical))
294        })
295        .collect()
296}