Skip to main content

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}