perspective_client/config/viewer/viewer_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::HashMap;
14use std::ops::Deref;
15
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use ts_rs::TS;
19
20use super::{
21 ColumnConfigUpdate, OptionalUpdate, PluginConfigUpdate, PluginUpdate, SettingsUpdate, TableRef,
22 TableUpdate, ThemeUpdate, TitleUpdate, UnknownKeys, VersionUpdate,
23};
24use crate::config::{ViewConfig, ViewConfigUpdate};
25
26/// Per-column plugin styling keyed by column name, each value opaque to the
27/// viewer and shaped by the active plugin.
28pub type ColumnConfigMap = HashMap<String, serde_json::Map<String, Value>>;
29
30/// The message of the error raised when a panel is created without a `table`.
31pub const CREATE_REQUIRES_TABLE: &str = "Cannot create a panel without a `table` — `load()` a \
32 `Client` and include `table` in the config, or use \
33 `addPanel()`";
34
35/// Why a [`ViewerConfigUpdate`] could not become a panel's
36/// [`ViewerConfigInitial`].
37#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
38pub enum ViewerConfigError {
39 /// The update names no `table`.
40 #[error("{}", CREATE_REQUIRES_TABLE)]
41 CreateRequiresTable,
42
43 /// The `table` is an inline derived-table definition which no restore
44 /// boundary has resolved to a name.
45 #[error("Unresolved inline derived table")]
46 UnresolvedTableRef,
47}
48
49/// The state of an entire `<perspective-viewer>` element and its plugin: the
50/// element-level `settings` flag plus the per-panel [`PanelViewerConfig`].
51/// The split exists so the workspace config format can serialize panel
52/// entries *without* a `settings` key (it is element-level state there,
53/// carried by the top-level `active` field instead), while the single-panel
54/// format flattens back to the legacy shape.
55#[derive(Debug, Default, Deserialize, Serialize, PartialEq, TS)]
56pub struct ViewerConfig<V: TS = String, T: TS = TableRef> {
57 /// Whether the settings sidebar is open.
58 pub settings: bool,
59
60 /// The panel state.
61 #[serde(flatten)]
62 pub panel: PanelViewerConfig<V, T>,
63}
64
65/// The per-panel state of a [`ViewerConfig`] — everything except the
66/// element-level `settings` flag. This is the `panels` entry type of the
67/// workspace config format.
68#[derive(Debug, Default, Deserialize, Serialize, PartialEq, TS)]
69pub struct PanelViewerConfig<V: TS = String, T: TS = TableRef> {
70 /// The `@perspective-dev/viewer` version that wrote this config,
71 /// stamped on save and named in the error when such a config is rejected.
72 /// Callers do not set it.
73 pub version: V,
74
75 /// Per-column styling, keyed by column name. The viewer treats the
76 /// values as opaque — their shape is defined by the ACTIVE plugin,
77 /// so a config written for one plugin may carry keys another
78 /// ignores. Query the live shape with the `get_style_schema` agent
79 /// tool, or the plugin's `column_config_schema()`.
80 pub columns_config: ColumnConfigMap,
81
82 /// Name of the visualization plugin, from the set registered on the
83 /// page (the `list_plugins` agent tool, or the plugin picker). This
84 /// also decides what the view fields MEAN visually: `columns` is
85 /// positional and each plugin reads the positions differently, and
86 /// `group_by`/`split_by` draw different things per plugin.
87 pub plugin: String,
88
89 /// Plugin-wide settings (as opposed to the per-column
90 /// [`Self::columns_config`]). Opaque to the viewer and defined by
91 /// the active plugin; see `get_style_schema`.
92 pub plugin_config: serde_json::Map<String, Value>,
93
94 /// The `Table` this panel renders: its hosted name in a
95 /// [`WorkspaceConfig`](super::WorkspaceConfig) `panels` entry
96 /// (`T = String`), and a [`TableRef`] — a name, or a recursively inlined
97 /// derived-table definition — in the single-panel `save()` format.
98 pub table: T,
99
100 /// Selected theme NAME (e.g. `"Pro Dark"`) — not a CSS value. Valid
101 /// names are the Perspective themes loaded on the page, which
102 /// `resetThemes()` re-scans. `None` selects the first available.
103 pub theme: Option<String>,
104
105 /// Panel title, shown in its tab. `None` renders the default title.
106 pub title: Option<String>,
107
108 /// The engine view config.
109 #[serde(flatten)]
110 pub view_config: ViewConfig,
111
112 /// Rejects keys no field above consumed.
113 #[serde(flatten, skip_serializing)]
114 #[ts(skip)]
115 pub unknown: UnknownKeys,
116}
117
118impl<V: TS, T: TS> Deref for ViewerConfig<V, T> {
119 type Target = PanelViewerConfig<V, T>;
120
121 fn deref(&self) -> &Self::Target {
122 &self.panel
123 }
124}
125
126impl<V: TS, T: TS> PanelViewerConfig<V, T> {
127 /// The same config with the `table` slot mapped, which is how the
128 /// single-panel emitters convert a bound name to its inline form.
129 pub fn map_table<U: TS>(self, f: impl FnOnce(T) -> U) -> PanelViewerConfig<V, U> {
130 PanelViewerConfig {
131 version: self.version,
132 columns_config: self.columns_config,
133 plugin: self.plugin,
134 plugin_config: self.plugin_config,
135 table: f(self.table),
136 theme: self.theme,
137 title: self.title,
138 view_config: self.view_config,
139 unknown: self.unknown,
140 }
141 }
142}
143
144impl<V: TS, T: TS> ViewerConfig<V, T> {
145 /// The same config with the `table` slot mapped.
146 pub fn map_table<U: TS>(self, f: impl FnOnce(T) -> U) -> ViewerConfig<V, U> {
147 ViewerConfig {
148 settings: self.settings,
149 panel: self.panel.map_table(f),
150 }
151 }
152}
153
154/// An opaque plugin-defined settings value.
155#[derive(Clone, Debug, TS, Deserialize, PartialEq, Serialize)]
156#[serde(transparent)]
157pub struct PluginConfig(serde_json::Value);
158
159impl Deref for PluginConfig {
160 type Target = Value;
161
162 fn deref(&self) -> &Self::Target {
163 &self.0
164 }
165}
166
167/// The `restore()` argument: a field-wise update of a panel's state, where an
168/// absent key is unchanged, `null` resets and a value replaces.
169#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, TS)]
170pub struct ViewerConfigUpdate {
171 /// The `@perspective-dev/viewer` version a saved config was written
172 /// by, named in the error when such a config is rejected. Omit it — the
173 /// viewer stamps the current version on save.
174 #[serde(default)]
175 #[ts(as = "Option<_>")]
176 #[ts(optional)]
177 pub version: VersionUpdate,
178
179 /// Name of the visualization plugin to switch to, from the set
180 /// registered on the page (the `list_plugins` agent tool, or the
181 /// plugin picker). Changing it changes what the view fields MEAN:
182 /// `columns` is positional and every plugin reads the positions
183 /// differently, and `group_by`/`split_by` draw different things per
184 /// plugin — so read the new plugin's roles before writing `columns`
185 /// for it.
186 #[serde(default)]
187 #[ts(as = "Option<_>")]
188 #[ts(optional)]
189 pub plugin: PluginUpdate,
190
191 /// Panel title, shown in its tab. `null` restores the default
192 /// title; omitting the field leaves the current one.
193 #[serde(default)]
194 #[ts(as = "Option<_>")]
195 #[ts(optional)]
196 pub title: TitleUpdate,
197
198 /// The `Table` to render: a name as hosted on this panel's `Client`,
199 /// or an inline derived-table definition to materialize (reusing a
200 /// structurally identical derived `Table` when one exists). Rebinding
201 /// an existing panel keeps the rest of the config, so column names
202 /// that do not exist in the new table will fail validation.
203 #[serde(default)]
204 #[ts(as = "Option<_>")]
205 #[ts(optional)]
206 pub table: TableUpdate,
207
208 /// Theme NAME (e.g. `"Pro Dark"`) — not a CSS value. Valid names are
209 /// the Perspective themes loaded on the page, re-scanned by
210 /// `resetThemes()`. `null` selects the default.
211 #[serde(default)]
212 #[ts(as = "Option<_>")]
213 #[ts(optional)]
214 pub theme: ThemeUpdate,
215
216 /// Whether the settings sidebar is OPEN. Purely cosmetic chrome —
217 /// it does not affect what the viewer renders, and it is
218 /// element-level rather than per-panel.
219 #[serde(default)]
220 #[ts(as = "Option<_>")]
221 #[ts(optional)]
222 pub settings: SettingsUpdate,
223
224 /// Plugin-wide settings (as opposed to the per-column
225 /// [`Self::columns_config`]). The viewer passes these through
226 /// opaquely — their valid keys are defined by the ACTIVE plugin and
227 /// vary by plugin and by state, so query them with the
228 /// `get_style_schema` agent tool rather than guessing.
229 #[serde(default)]
230 #[ts(as = "Option<_>")]
231 #[ts(optional)]
232 pub plugin_config: PluginConfigUpdate,
233
234 /// Per-column styling — formatting, colors, and other per-column
235 /// controls — keyed by column name. Opaque to the viewer and
236 /// plugin-defined like [`Self::plugin_config`]; `get_style_schema`
237 /// reports the valid keys for a given column under the active
238 /// plugin.
239 #[serde(default)]
240 #[ts(as = "Option<_>")]
241 #[ts(optional)]
242 pub columns_config: ColumnConfigUpdate,
243
244 /// The engine view config update.
245 #[serde(flatten)]
246 pub view_config: ViewConfigUpdate,
247
248 /// Rejects keys no field above consumed.
249 #[serde(flatten, skip_serializing)]
250 #[ts(skip)]
251 pub unknown: UnknownKeys,
252}
253
254/// The initial configuration of a NEW panel (`addPanel`, `restore`'s
255/// panel-creating upsert, `restoreWorkspace` `panels` entries). Unlike
256/// [`ViewerConfigUpdate`] — a patch against existing state — creation has
257/// no prior state: `table` is REQUIRED (a placed panel without a table
258/// binding would be permanently blank), absent fields mean "default"
259/// rather than "leave unchanged" (so no [`OptionalUpdate`] tri-state), and
260/// there is no `settings` field (element-level, not per-panel).
261#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
262pub struct ViewerConfigInitial {
263 /// Name of the `Table` the new panel renders, as hosted on the
264 /// `Client`. REQUIRED: a placed panel with no table binding would be
265 /// permanently blank.
266 pub table: String,
267
268 /// The `@perspective-dev/viewer` version a saved config was written
269 /// by. Omit it when creating a panel.
270 #[ts(optional)]
271 pub version: Option<String>,
272
273 /// Name of the visualization plugin, from the set registered on the
274 /// page (the `list_plugins` agent tool, or the plugin picker).
275 /// Decides what the view fields MEAN: `columns` is positional and
276 /// every plugin reads the positions differently, and
277 /// `group_by`/`split_by` draw different things per plugin. Absent
278 /// uses the default plugin.
279 #[ts(optional)]
280 pub plugin: Option<String>,
281
282 /// Panel title, shown in its tab. Absent renders the default title.
283 #[ts(optional = nullable)]
284 pub title: Option<String>,
285
286 /// Theme NAME (e.g. `"Pro Dark"`) — not a CSS value. Valid names are
287 /// the Perspective themes loaded on the page. Absent uses the
288 /// default.
289 #[ts(optional = nullable)]
290 pub theme: Option<String>,
291
292 /// Plugin-wide settings (as opposed to the per-column
293 /// [`Self::columns_config`]). Opaque to the viewer and defined by
294 /// the ACTIVE plugin; query the valid keys with `get_style_schema`
295 /// rather than guessing.
296 #[ts(optional)]
297 pub plugin_config: Option<serde_json::Map<String, Value>>,
298
299 /// Per-column styling — formatting, colors, and other per-column
300 /// controls — keyed by column name. Plugin-defined like
301 /// [`Self::plugin_config`]; see `get_style_schema`.
302 #[ts(optional)]
303 pub columns_config: Option<ColumnConfigMap>,
304
305 /// The engine view config update.
306 #[serde(flatten)]
307 pub view_config: ViewConfigUpdate,
308
309 /// Rejects keys no field above consumed.
310 #[serde(flatten, skip_serializing)]
311 #[ts(skip)]
312 pub unknown: UnknownKeys,
313}
314
315impl ViewerConfigInitial {
316 /// A default panel bound to `table`.
317 pub fn new(table: impl Into<String>) -> Self {
318 Self {
319 table: table.into(),
320 version: None,
321 plugin: None,
322 title: None,
323 theme: None,
324 plugin_config: None,
325 columns_config: None,
326 view_config: ViewConfigUpdate::default(),
327 unknown: UnknownKeys,
328 }
329 }
330}
331
332fn up<T: Clone>(value: Option<T>) -> OptionalUpdate<T> {
333 match value {
334 Some(value) => OptionalUpdate::Update(value),
335 None => OptionalUpdate::Missing,
336 }
337}
338
339impl From<ViewerConfigInitial> for ViewerConfigUpdate {
340 fn from(value: ViewerConfigInitial) -> Self {
341 ViewerConfigUpdate {
342 version: up(value.version),
343 plugin: up(value.plugin),
344 title: up(value.title),
345 table: OptionalUpdate::Update(value.table.into()),
346 theme: up(value.theme),
347 settings: OptionalUpdate::Missing,
348 plugin_config: up(value.plugin_config),
349 columns_config: up(value.columns_config),
350 view_config: value.view_config,
351 unknown: value.unknown,
352 }
353 }
354}
355
356fn down<T: Clone>(value: OptionalUpdate<T>) -> Option<T> {
357 match value {
358 OptionalUpdate::Update(value) => Some(value),
359 OptionalUpdate::Missing | OptionalUpdate::SetDefault => None,
360 }
361}
362
363impl TryFrom<ViewerConfigUpdate> for ViewerConfigInitial {
364 type Error = ViewerConfigError;
365
366 fn try_from(value: ViewerConfigUpdate) -> Result<Self, Self::Error> {
367 let ViewerConfigUpdate {
368 version,
369 plugin,
370 title,
371 table,
372 theme,
373 settings: _settings,
374 plugin_config,
375 columns_config,
376 view_config,
377 unknown,
378 } = value;
379
380 let OptionalUpdate::Update(table) = table else {
381 return Err(ViewerConfigError::CreateRequiresTable);
382 };
383
384 let table = table.name()?.to_owned();
385
386 Ok(Self {
387 table,
388 version: down(version),
389 plugin: down(plugin),
390 title: down(title),
391 theme: down(theme),
392 plugin_config: down(plugin_config),
393 columns_config: down(columns_config),
394 view_config,
395 unknown,
396 })
397 }
398}
399
400impl std::fmt::Display for ViewerConfigUpdate {
401 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
402 write!(
403 f,
404 "{}",
405 serde_json::to_string(self).map_err(|_| std::fmt::Error)?
406 )
407 }
408}