Skip to main content

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