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, EnumField, NumberFieldPrimitive,
24};
25use crate::config::ControlSpec;
26use crate::presentation::Presentation;
27use crate::queries::get_plugin_config_schema;
28use crate::renderer::Renderer;
29use crate::session::Session;
30use crate::tasks::send_plugin_config;
31use crate::ui::ControlGroup;
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 presentation: Presentation,
53    pub renderer: Renderer,
54    pub session: Session,
55}
56
57#[function_component]
58pub fn PluginTab(props: &PluginTabProps) -> Html {
59    // Memoize the JS-side `plugin_config_schema` call. The schema is a
60    // function of (active plugin, current plugin_config values,
61    // view_config); each of those arrives as a prop so the deps tuple
62    // uses cheap pointer-equality / value-equality. Yew re-runs the
63    // closure only when one of them actually changed, so the JS
64    // round-trip doesn't fire on unrelated re-renders.
65    //
66    // The closure captures `renderer` to dispatch `_plugin_config_schema`
67    // through the active plugin handle, but resolves it via the props
68    // at call time so the schema query is bound to the same atomic
69    // snapshot the rendered controls read from. No race window between
70    // a plugin swap and the schema fetch — both observe the same
71    // `RendererProps` value.
72    let schema = {
73        let renderer = props.renderer.clone();
74        let view_config = props.view_config.clone();
75        use_memo(
76            (props.plugin_config.clone(), props.view_config.clone()),
77            move |_| match get_plugin_config_schema(&renderer, &view_config) {
78                Ok(schema) => schema.fields,
79                Err(error) => {
80                    tracing::error!("{}", error);
81                    vec![]
82                },
83            },
84        )
85    };
86
87    let on_change = {
88        let session = props.session.clone();
89        let renderer = props.renderer.clone();
90        yew::Callback::from(move |update: crate::config::ColumnConfigFieldUpdate| {
91            // `send_plugin_config` emits `plugin_config_changed`,
92            // which the root component's subscription
93            // (`create_active_subscriptions`) turns into an `UpdateRenderer`
94            // dispatch carrying a fresh `RendererProps`. Yew's prop
95            // diff propagates the new `plugin_config` into this
96            // component automatically — no manual revision bump.
97            send_plugin_config(&session, &renderer, update);
98        })
99    };
100
101    let on_group_toggle = {
102        let presentation = props.presentation.clone();
103        yew::Callback::from(move |(key, open): (String, bool)| {
104            presentation.set_control_group_collapsed(&key, !open);
105        })
106    };
107
108    let raw_config = &*props.plugin_config;
109    let components = render_specs(
110        &schema,
111        raw_config,
112        &on_change,
113        &props.presentation,
114        &on_group_toggle,
115    );
116    html! {
117        <div id="plugin-tab" class="sidebar_column scrollable">
118            <div id="plugin-config-container" class="tab-section">{ components }</div>
119        </div>
120    }
121}
122
123fn render_specs(
124    specs: &[ControlSpec],
125    raw_config: &serde_json::Map<String, serde_json::Value>,
126    on_change: &Callback<crate::config::ColumnConfigFieldUpdate>,
127    presentation: &Presentation,
128    on_group_toggle: &Callback<(String, bool)>,
129) -> Vec<Html> {
130    specs
131        .iter()
132        .filter_map(|spec| match spec {
133            ControlSpec::Group { key, fields } => {
134                let children =
135                    render_specs(fields, raw_config, on_change, presentation, on_group_toggle);
136
137                (!children.is_empty()).then(|| {
138                    html! {
139                        <ControlGroup
140                            key={format!("group::{key}")}
141                            group_key={key.clone()}
142                            open={!presentation.is_control_group_collapsed(key)}
143                            on_toggle={on_group_toggle.clone()}
144                        >
145                            { children }
146                        </ControlGroup>
147                    }
148                })
149            },
150            leaf => {
151                let key = leaf.serialized_keys().join("+");
152                let component = render_leaf(leaf, raw_config, on_change)?;
153                Some(html! { <fieldset class="style-control" {key}>{ component }</fieldset> })
154            },
155        })
156        .collect_vec()
157}
158
159fn render_leaf(
160    spec: &ControlSpec,
161    raw_config: &serde_json::Map<String, serde_json::Value>,
162    on_change: &Callback<crate::config::ColumnConfigFieldUpdate>,
163) -> Option<Html> {
164    match spec.clone() {
165        ControlSpec::Enum {
166            key,
167            variants,
168            default,
169        } => {
170            let current = raw_config
171                .get(&key)
172                .and_then(|v| v.as_str().map(|s| s.to_string()));
173            Some(html! {
174                <EnumField
175                    field_key={key}
176                    {variants}
177                    {default}
178                    {current}
179                    on_change={on_change.clone()}
180                />
181            })
182        },
183        ControlSpec::Bool { key, default } => {
184            let current = raw_config.get(&key).and_then(|v| v.as_bool());
185            Some(html! {
186                <BoolField field_key={key} {default} {current} on_change={on_change.clone()} />
187            })
188        },
189        ControlSpec::Color { key, default } => {
190            let current = raw_config
191                .get(&key)
192                .and_then(|v| v.as_str().map(|s| s.to_string()));
193            Some(html! {
194                <ColorField field_key={key} {default} {current} on_change={on_change.clone()} />
195            })
196        },
197        ControlSpec::Number {
198            key,
199            default,
200            min,
201            max,
202            step,
203            include,
204        } => {
205            let current = raw_config.get(&key).and_then(|v| v.as_f64());
206            Some(html! {
207                <NumberFieldPrimitive
208                    field_key={key}
209                    {default}
210                    {current}
211                    {min}
212                    {max}
213                    {step}
214                    {include}
215                    on_change={on_change.clone()}
216                />
217            })
218        },
219        ControlSpec::Group { .. }
220        | ControlSpec::AggregateDepth
221        | ControlSpec::NumberSeriesStyle { .. }
222        | ControlSpec::DatetimeFormat { .. }
223        | ControlSpec::StringFormat
224        | ControlSpec::Symbols { .. }
225        | ControlSpec::NumberFormat { .. }
226        | ControlSpec::String { .. }
227        | ControlSpec::Palette { .. }
228        | ControlSpec::GradientStops { .. } => None,
229    }
230}