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}