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}