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}