Skip to main content

perspective_viewer/components/
plugin_tab.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
13//! Plugin-scoped settings tab. Mirrors `style_tab` but operates on the
14//! active plugin's `save()`/`restore()` token rather than a per-column
15//! config map. The schema comes from `plugin.plugin_config_schema()`;
16//! field updates are dispatched through `tasks::send_plugin_config`.
17
18use itertools::Itertools;
19use perspective_client::config::ViewConfig;
20use yew::prelude::*;
21
22use crate::components::column_settings_sidebar::style_tab::primitive_field::{
23    BoolField, ColorField, ColorRangeField, EnumField, NumberFieldPrimitive,
24};
25use crate::components::style::LocalStyle;
26use crate::config::ControlSpec;
27use crate::css;
28use crate::queries::get_plugin_config_schema;
29use crate::renderer::Renderer;
30use crate::session::Session;
31use crate::tasks::send_plugin_config;
32use crate::utils::PtrEqRc;
33
34#[derive(Clone, PartialEq, Properties)]
35pub struct PluginTabProps {
36    /// View config snapshot — passed to the plugin schema callback in
37    /// case the plugin wants to gate fields based on it.
38    pub view_config: PtrEqRc<ViewConfig>,
39
40    /// Active plugin's `plugin_config` bucket — threaded as a value
41    /// snapshot from `RendererProps`. Changes on every mutation path
42    /// that fires `plugin_config_changed` (in-tab edit,
43    /// `restore_and_render` JSON paste, `reset_all` with `all=true`)
44    /// AND on plugin switch (the active bucket is keyed by plugin
45    /// name, so `to_props()` produces a fresh `Rc` after
46    /// `commit_plugin_idx`). PluginTab is a pure function of this
47    /// prop — no `Renderer::get_plugin_config()` reads against the
48    /// interior-mutable handle.
49    pub plugin_config: PtrEqRc<serde_json::Map<String, serde_json::Value>>,
50
51    // State
52    pub renderer: Renderer,
53    pub session: Session,
54}
55
56#[function_component]
57pub fn PluginTab(props: &PluginTabProps) -> Html {
58    // Memoize the JS-side `plugin_config_schema` call. The schema is a
59    // function of (active plugin, current plugin_config values,
60    // view_config); each of those arrives as a prop so the deps tuple
61    // uses cheap pointer-equality / value-equality. Yew re-runs the
62    // closure only when one of them actually changed, so the JS
63    // round-trip doesn't fire on unrelated re-renders.
64    //
65    // The closure captures `renderer` to dispatch `_plugin_config_schema`
66    // through the active plugin handle, but resolves it via the props
67    // at call time so the schema query is bound to the same atomic
68    // snapshot the rendered controls read from. No race window between
69    // a plugin swap and the schema fetch — both observe the same
70    // `RendererProps` value.
71    let schema = {
72        let renderer = props.renderer.clone();
73        let view_config = props.view_config.clone();
74        use_memo(
75            (props.plugin_config.clone(), props.view_config.clone()),
76            move |_| match get_plugin_config_schema(&renderer, &view_config) {
77                Ok(schema) => schema.fields,
78                Err(error) => {
79                    tracing::error!("{}", error);
80                    vec![]
81                },
82            },
83        )
84    };
85
86    let on_change = {
87        let session = props.session.clone();
88        let renderer = props.renderer.clone();
89        yew::Callback::from(move |update: crate::config::ColumnConfigFieldUpdate| {
90            // `send_plugin_config` emits `plugin_config_changed`,
91            // which the root component's subscription
92            // (`create_subscriptions`) turns into an `UpdateRenderer`
93            // dispatch carrying a fresh `RendererProps`. Yew's prop
94            // diff propagates the new `plugin_config` into this
95            // component automatically — no manual revision bump.
96            send_plugin_config(&session, &renderer, update);
97        })
98    };
99
100    let raw_config = &*props.plugin_config;
101    let components = schema
102        .iter()
103        .cloned()
104        .filter_map(|spec| {
105            let component = match spec {
106                ControlSpec::Enum {
107                    key,
108                    variants,
109                    default,
110                } => {
111                    let current = raw_config
112                        .get(&key)
113                        .and_then(|v| v.as_str().map(|s| s.to_string()));
114                    html! {
115                        <EnumField
116                            field_key={key}
117                            {variants}
118                            {default}
119                            {current}
120                            on_change={on_change.clone()}
121                        />
122                    }
123                },
124                ControlSpec::Bool { key, default } => {
125                    let current = raw_config.get(&key).and_then(|v| v.as_bool());
126                    html! {
127                        <BoolField
128                            field_key={key}
129                            {default}
130                            {current}
131                            on_change={on_change.clone()}
132                        />
133                    }
134                },
135                ControlSpec::Color { key, default } => {
136                    let current = raw_config
137                        .get(&key)
138                        .and_then(|v| v.as_str().map(|s| s.to_string()));
139                    html! {
140                        <ColorField
141                            field_key={key}
142                            {default}
143                            {current}
144                            on_change={on_change.clone()}
145                        />
146                    }
147                },
148                ControlSpec::ColorRange {
149                    key_pos,
150                    key_neg,
151                    default_pos,
152                    default_neg,
153                    is_gradient,
154                } => {
155                    let current_pos = raw_config
156                        .get(&key_pos)
157                        .and_then(|v| v.as_str().map(|s| s.to_string()));
158                    let current_neg = raw_config
159                        .get(&key_neg)
160                        .and_then(|v| v.as_str().map(|s| s.to_string()));
161                    html! {
162                        <ColorRangeField
163                            field_key_pos={key_pos}
164                            field_key_neg={key_neg}
165                            {default_pos}
166                            {default_neg}
167                            {current_pos}
168                            {current_neg}
169                            {is_gradient}
170                            on_change={on_change.clone()}
171                        />
172                    }
173                },
174                ControlSpec::Number {
175                    key,
176                    default,
177                    min,
178                    max,
179                    step,
180                    include,
181                } => {
182                    let current = raw_config.get(&key).and_then(|v| v.as_f64());
183                    html! {
184                        <NumberFieldPrimitive
185                            field_key={key}
186                            {default}
187                            {current}
188                            {min}
189                            {max}
190                            {step}
191                            {include}
192                            on_change={on_change.clone()}
193                        />
194                    }
195                },
196                // Column-scoped variants don't apply to
197                // plugin-level config; drop silently.
198                ControlSpec::AggregateDepth
199                | ControlSpec::NumberSeriesStyle { .. }
200                | ControlSpec::DatetimeFormat
201                | ControlSpec::StringFormat
202                | ControlSpec::Symbols { .. }
203                | ControlSpec::NumberFormat
204                | ControlSpec::String { .. } => {
205                    return None;
206                },
207            };
208
209            Some(html! { <fieldset class="style-control">{ component }</fieldset> })
210        })
211        .collect_vec();
212
213    html! {
214        <div id="plugin-tab" class="sidebar_column scrollable">
215            <LocalStyle href={css!("column-style")} />
216            <LocalStyle href={css!("plugin-settings-panel")} />
217            <LocalStyle href={css!("containers/tabs")} />
218            <div id="plugin-config-container" class="tab-section">{ components }</div>
219        </div>
220    }
221}