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::alignment_field::AlignmentField;
23use crate::components::column_settings_sidebar::style_tab::font_field::FontField;
24use crate::components::column_settings_sidebar::style_tab::primitive_field::{
25    BoolField, ColorField, EnumField, NumberFieldPrimitive,
26};
27use crate::config::{Alignment, ControlSpec, FontToggle};
28use crate::presentation::Presentation;
29use crate::queries::get_plugin_config_schema;
30use crate::renderer::Renderer;
31use crate::session::Session;
32use crate::tasks::send_plugin_config;
33use crate::ui::ControlGroup;
34use crate::utils::PtrEqRc;
35
36#[derive(Clone, PartialEq, Properties)]
37pub struct PluginTabProps {
38    /// View config snapshot — passed to the plugin schema callback in
39    /// case the plugin wants to gate fields based on it.
40    pub view_config: PtrEqRc<ViewConfig>,
41
42    /// Active plugin's `plugin_config` bucket — threaded as a value
43    /// snapshot from `RendererProps`. Changes on every mutation path
44    /// that fires `plugin_config_changed` (in-tab edit,
45    /// `restore_and_render` JSON paste, `reset_all` with `all=true`)
46    /// AND on plugin switch (the active bucket is keyed by plugin
47    /// name, so `to_props()` produces a fresh `Rc` after
48    /// `commit_plugin_idx`). PluginTab is a pure function of this
49    /// prop — no `Renderer::get_plugin_config()` reads against the
50    /// interior-mutable handle.
51    pub plugin_config: PtrEqRc<serde_json::Map<String, serde_json::Value>>,
52
53    // State
54    pub presentation: Presentation,
55    pub renderer: Renderer,
56    pub session: Session,
57}
58
59#[function_component]
60pub fn PluginTab(props: &PluginTabProps) -> Html {
61    // Memoize the JS-side `plugin_config_schema` call. The schema is a
62    // function of (active plugin, current plugin_config values,
63    // view_config); each of those arrives as a prop so the deps tuple
64    // uses cheap pointer-equality / value-equality. Yew re-runs the
65    // closure only when one of them actually changed, so the JS
66    // round-trip doesn't fire on unrelated re-renders.
67    //
68    // The closure captures `renderer` to dispatch `_plugin_config_schema`
69    // through the active plugin handle, but resolves it via the props
70    // at call time so the schema query is bound to the same atomic
71    // snapshot the rendered controls read from. No race window between
72    // a plugin swap and the schema fetch — both observe the same
73    // `RendererProps` value.
74    let schema = {
75        let renderer = props.renderer.clone();
76        let view_config = props.view_config.clone();
77        let plugin_config = props.plugin_config.clone();
78        use_memo(
79            (props.plugin_config.clone(), props.view_config.clone()),
80            move |_| match get_plugin_config_schema(&renderer, &view_config, Some(&plugin_config)) {
81                Ok(schema) => schema.fields,
82                Err(error) => {
83                    tracing::error!("{}", error);
84                    vec![]
85                },
86            },
87        )
88    };
89
90    let on_change = {
91        let session = props.session.clone();
92        let renderer = props.renderer.clone();
93        yew::Callback::from(move |update: crate::config::ColumnConfigFieldUpdate| {
94            // `send_plugin_config` emits `plugin_config_changed`,
95            // which the root component's subscription
96            // (`create_active_subscriptions`) turns into an `UpdateRenderer`
97            // dispatch carrying a fresh `RendererProps`. Yew's prop
98            // diff propagates the new `plugin_config` into this
99            // component automatically — no manual revision bump.
100            send_plugin_config(&session, &renderer, update);
101        })
102    };
103
104    let on_group_toggle = {
105        let presentation = props.presentation.clone();
106        yew::Callback::from(move |(key, open): (String, bool)| {
107            presentation.set_control_group_collapsed(&key, !open);
108        })
109    };
110
111    let raw_config = &*props.plugin_config;
112    let components = render_specs(
113        &schema,
114        raw_config,
115        &on_change,
116        &props.presentation,
117        &on_group_toggle,
118    );
119    html! {
120        <div id="plugin-tab" class="sidebar_column scrollable">
121            <div id="plugin-config-container" class="tab-section">{ components }</div>
122        </div>
123    }
124}
125
126fn render_specs(
127    specs: &[ControlSpec],
128    raw_config: &serde_json::Map<String, serde_json::Value>,
129    on_change: &Callback<crate::config::ColumnConfigFieldUpdate>,
130    presentation: &Presentation,
131    on_group_toggle: &Callback<(String, bool)>,
132) -> Vec<Html> {
133    specs
134        .iter()
135        .filter_map(|spec| match spec {
136            ControlSpec::Group { key, fields } => {
137                let children =
138                    render_specs(fields, raw_config, on_change, presentation, on_group_toggle);
139
140                (!children.is_empty()).then(|| {
141                    html! {
142                        <ControlGroup
143                            key={format!("group::{key}")}
144                            group_key={key.clone()}
145                            open={!presentation.is_control_group_collapsed(key)}
146                            on_toggle={on_group_toggle.clone()}
147                        >
148                            { children }
149                        </ControlGroup>
150                    }
151                })
152            },
153            leaf => {
154                let key = leaf.serialized_keys().join("+");
155                let component = render_leaf(leaf, raw_config, on_change)?;
156                Some(html! { <fieldset class="style-control" {key}>{ component }</fieldset> })
157            },
158        })
159        .collect_vec()
160}
161
162fn render_leaf(
163    spec: &ControlSpec,
164    raw_config: &serde_json::Map<String, serde_json::Value>,
165    on_change: &Callback<crate::config::ColumnConfigFieldUpdate>,
166) -> Option<Html> {
167    match spec.clone() {
168        ControlSpec::Enum {
169            key,
170            variants,
171            default,
172        } => {
173            let current = raw_config
174                .get(&key)
175                .and_then(|v| v.as_str().map(|s| s.to_string()));
176            Some(html! {
177                <EnumField
178                    field_key={key}
179                    {variants}
180                    {default}
181                    {current}
182                    on_change={on_change.clone()}
183                />
184            })
185        },
186        ControlSpec::Font {
187            key,
188            default,
189            size,
190            bold,
191            italic,
192        } => {
193            let current = raw_config
194                .get(&key)
195                .and_then(|v| v.as_str().map(|s| s.to_string()));
196            let toggle = |t: &FontToggle| raw_config.get(&t.key).and_then(|v| v.as_bool());
197            let size_current = size
198                .as_ref()
199                .and_then(|s| raw_config.get(&s.key))
200                .and_then(|v| v.as_f64());
201            let bold_current = bold.as_ref().and_then(toggle);
202            let italic_current = italic.as_ref().and_then(toggle);
203            Some(html! {
204                <FontField
205                    field_key={key}
206                    {default}
207                    {current}
208                    {size}
209                    {size_current}
210                    {bold}
211                    {bold_current}
212                    {italic}
213                    {italic_current}
214                    on_change={on_change.clone()}
215                />
216            })
217        },
218        ControlSpec::Alignment {
219            key,
220            default,
221            corners,
222        } => {
223            let current = raw_config
224                .get(&key)
225                .and_then(|v| v.as_str())
226                .and_then(Alignment::parse);
227
228            Some(html! {
229                <AlignmentField
230                    field_key={key}
231                    {default}
232                    {corners}
233                    {current}
234                    on_change={on_change.clone()}
235                />
236            })
237        },
238        ControlSpec::Bool { key, default } => {
239            let current = raw_config.get(&key).and_then(|v| v.as_bool());
240            Some(html! {
241                <BoolField field_key={key} {default} {current} on_change={on_change.clone()} />
242            })
243        },
244        ControlSpec::Color { key, default } => {
245            let current = raw_config
246                .get(&key)
247                .and_then(|v| v.as_str().map(|s| s.to_string()));
248            Some(html! {
249                <ColorField field_key={key} {default} {current} on_change={on_change.clone()} />
250            })
251        },
252        ControlSpec::Number {
253            key,
254            default,
255            min,
256            max,
257            step,
258            include,
259        } => {
260            let current = raw_config.get(&key).and_then(|v| v.as_f64());
261            Some(html! {
262                <NumberFieldPrimitive
263                    field_key={key}
264                    {default}
265                    {current}
266                    {min}
267                    {max}
268                    {step}
269                    {include}
270                    on_change={on_change.clone()}
271                />
272            })
273        },
274        ControlSpec::Group { .. }
275        | ControlSpec::AggregateDepth
276        | ControlSpec::NumberSeriesStyle { .. }
277        | ControlSpec::DatetimeFormat { .. }
278        | ControlSpec::Symbols { .. }
279        | ControlSpec::NumberFormat { .. }
280        | ControlSpec::String { .. }
281        | ControlSpec::Palette { .. }
282        | ControlSpec::GradientStops { .. } => None,
283    }
284}