mod activate;
pub mod limits;
mod plugin_store;
mod props;
mod registry;
mod render_timer;
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::future::Future;
use std::ops::Deref;
use std::pin::Pin;
use std::rc::Rc;
use futures::future::select_all;
use perspective_client::config::ViewConfig;
use perspective_client::utils::*;
use perspective_client::{View, ViewWindow};
use perspective_js::json;
use perspective_js::utils::{ApiResult, JsValueSerdeExt, ResultTApiErrorExt};
use serde_json::Value;
use wasm_bindgen::prelude::*;
use web_sys::*;
use yew::html::ImplicitClone;
use yew::prelude::*;
use self::activate::*;
pub use self::limits::RenderLimits;
use self::limits::*;
use self::plugin_store::*;
pub use self::props::RendererProps;
pub use self::registry::*;
use self::render_timer::*;
use crate::config::*;
use crate::js::plugin::*;
use crate::session::Session;
use crate::utils::*;
pub type ColumnConfigMap = HashMap<String, serde_json::Map<String, serde_json::Value>>;
#[derive(Clone, Debug, Default)]
pub struct PluginScopedConfig {
pub columns: ColumnConfigMap,
pub plugin: serde_json::Map<String, serde_json::Value>,
}
pub struct RendererData {
plugin_data: RefCell<RendererMutData>,
draw_lock: DebounceMutex,
pub plugin_changed: PubSub<JsPerspectiveViewerPlugin>,
pub style_changed: PubSub<()>,
pub reset_changed: PubSub<()>,
pub selection_changed: PubSub<Option<ViewWindow>>,
pub column_style_changed: PubSub<ColumnConfigMap>,
pub plugin_config_changed: PubSub<serde_json::Map<String, serde_json::Value>>,
pub render_warning: Cell<bool>,
pub on_render_limits_changed: RefCell<Option<Callback<RenderLimits>>>,
}
pub struct RendererMutData {
viewer_elem: HtmlElement,
metadata: Rc<PluginStaticConfig>,
plugin_store: PluginStore,
plugins_idx: Option<usize>,
timer: MovingWindowRenderTimer,
selection: Option<ViewWindow>,
pending_plugin: Option<usize>,
plugin_states: HashMap<String, PluginScopedConfig>,
}
#[derive(Clone)]
pub struct Renderer(Rc<RendererData>);
impl Deref for Renderer {
type Target = RendererData;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl PartialEq for Renderer {
fn eq(&self, other: &Self) -> bool {
Rc::ptr_eq(&self.0, &other.0)
}
}
impl ImplicitClone for Renderer {}
impl Deref for RendererData {
type Target = RefCell<RendererMutData>;
fn deref(&self) -> &Self::Target {
&self.plugin_data
}
}
type TaskResult = ApiResult<JsValue>;
type TimeoutTask<'a> = Pin<Box<dyn Future<Output = Option<TaskResult>> + 'a>>;
static PRESIZE_TIMEOUT: i32 = 500;
impl Renderer {
pub fn new(viewer_elem: &HtmlElement) -> Self {
Self(Rc::new(RendererData {
plugin_data: RefCell::new(RendererMutData {
viewer_elem: viewer_elem.clone(),
metadata: Rc::new(PluginStaticConfig::default()),
plugin_store: PluginStore::default(),
plugins_idx: None,
selection: None,
timer: MovingWindowRenderTimer::default(),
pending_plugin: None,
plugin_states: HashMap::default(),
}),
draw_lock: Default::default(),
plugin_changed: Default::default(),
style_changed: Default::default(),
reset_changed: Default::default(),
selection_changed: Default::default(),
column_style_changed: Default::default(),
plugin_config_changed: Default::default(),
render_warning: Cell::new(true),
on_render_limits_changed: Default::default(),
}))
}
pub async fn reset(&self, columns_config: Option<&ColumnConfigMap>) -> ApiResult<()> {
self.0.borrow_mut().plugins_idx = None;
if let Ok(plugin) = self.get_active_plugin() {
plugin.restore(&json!({}), columns_config)?;
}
Ok(())
}
pub fn delete(&self) -> ApiResult<()> {
self.get_active_plugin().map(|x| x.delete()).unwrap_or_log();
self.plugin_data.borrow().viewer_elem.set_inner_text("");
let new_state = Self::new(&self.plugin_data.borrow().viewer_elem);
std::mem::swap(
&mut *self.plugin_data.borrow_mut(),
&mut *new_state.plugin_data.borrow_mut(),
);
Ok(())
}
pub fn metadata(&self) -> Rc<PluginStaticConfig> {
self.borrow().metadata.clone()
}
pub fn is_chart(&self) -> bool {
self.metadata().name.as_str() != "Datagrid"
}
pub fn can_render_column_styles(&self) -> bool {
self.metadata().can_render_column_styles
}
fn active_plugin_name(&self) -> Option<String> {
Some(self.borrow().metadata.name.clone()).filter(|n| !n.is_empty())
}
pub fn all_columns_configs(&self) -> ColumnConfigMap {
self.active_plugin_name()
.and_then(|n| {
self.borrow()
.plugin_states
.get(&n)
.map(|b| b.columns.clone())
})
.unwrap_or_default()
}
pub fn all_columns_configs_materialized(
&self,
view_config: &ViewConfig,
session: &Session,
) -> ColumnConfigMap {
let mut configs = self.all_columns_configs();
for (col, entry) in &mut configs {
let Ok(schema) =
self.query_column_config_schema(view_config, session, col, Some(entry))
else {
continue;
};
for field in &schema.fields {
let ControlSpec::Number {
key,
default,
include: Some(true),
..
} = field
else {
continue;
};
if entry.contains_key(key) {
continue;
}
let Some(num) = serde_json::Number::from_f64(*default) else {
continue;
};
entry.insert(key.clone(), serde_json::Value::Number(num));
}
}
configs
}
pub fn reset_columns_configs(&self) {
if let Some(n) = self.active_plugin_name() {
self.borrow_mut()
.plugin_states
.entry(n)
.or_default()
.columns
.clear();
}
}
pub fn get_columns_config(
&self,
column_name: &str,
) -> Option<serde_json::Map<String, serde_json::Value>> {
let n = self.active_plugin_name()?;
self.borrow()
.plugin_states
.get(&n)?
.columns
.get(column_name)
.cloned()
}
pub fn update_columns_configs(
&self,
view_config: &ViewConfig,
session: &Session,
update: ColumnConfigUpdate,
) -> bool {
let Some(n) = self.active_plugin_name() else {
return false;
};
match update {
OptionalUpdate::SetDefault => {
let mut st = self.borrow_mut();
let bucket = st.plugin_states.entry(n).or_default();
let was_nonempty = !bucket.columns.is_empty();
bucket.columns.clear();
was_nonempty
},
OptionalUpdate::Missing => false,
OptionalUpdate::Update(map) => {
let stripped: Vec<(String, serde_json::Map<String, serde_json::Value>)> = map
.into_iter()
.map(|(col, mut cfg)| {
if let Ok(schema) =
self.query_column_config_schema(view_config, session, &col, Some(&cfg))
{
strip_default_values(&schema, &mut cfg);
}
(col, cfg)
})
.collect();
let mut st = self.borrow_mut();
let bucket = st.plugin_states.entry(n).or_default();
let mut changed = false;
for (col, cfg) in stripped {
if cfg.is_empty() {
if bucket.columns.remove(&col).is_some() {
changed = true;
}
} else {
match bucket.columns.insert(col, cfg.clone()) {
None => changed = true,
Some(old) if old != cfg => changed = true,
_ => {},
}
}
}
changed
},
}
}
pub fn update_columns_config_field(
&self,
view_config: &ViewConfig,
session: &Session,
column_name: String,
mut update: ColumnConfigFieldUpdate,
) {
let Some(n) = self.active_plugin_name() else {
return;
};
let current_value = self.get_columns_config(&column_name);
if let Ok(schema) = self.query_column_config_schema(
view_config,
session,
&column_name,
current_value.as_ref(),
) {
strip_default_values(&schema, &mut update.value);
}
let mut st = self.borrow_mut();
let bucket = st.plugin_states.entry(n).or_default();
let entry = bucket.columns.entry(column_name.clone()).or_default();
for k in &update.keys {
entry.remove(k);
}
for (k, v) in update.value {
if update.keys.contains(&k) {
entry.insert(k, v);
}
}
if entry.is_empty() {
bucket.columns.remove(&column_name);
}
}
pub fn get_plugin_config(&self) -> serde_json::Map<String, serde_json::Value> {
self.active_plugin_name()
.and_then(|n| {
self.borrow()
.plugin_states
.get(&n)
.map(|b| b.plugin.clone())
})
.unwrap_or_default()
}
pub fn reset_plugin_config(&self) {
if let Some(n) = self.active_plugin_name() {
self.borrow_mut()
.plugin_states
.entry(n)
.or_default()
.plugin
.clear();
}
}
fn query_plugin_config_schema(
&self,
view_config: &ViewConfig,
) -> ApiResult<ColumnConfigSchema> {
let plugin = self.get_active_plugin()?;
let view_config_js = JsValue::from_serde_ext(view_config).unwrap_or(JsValue::NULL);
let raw = plugin._plugin_config_schema(&view_config_js)?;
serde_wasm_bindgen::from_value(raw).map_err(|e| e.into())
}
fn query_column_config_schema(
&self,
view_config: &ViewConfig,
session: &Session,
column_name: &str,
current_value: Option<&serde_json::Map<String, serde_json::Value>>,
) -> ApiResult<ColumnConfigSchema> {
let plugin = self.get_active_plugin()?;
let plugin_config = self.metadata();
let names = &plugin_config.config_column_names;
let group = view_config
.columns
.iter()
.position(|maybe_s| maybe_s.as_deref() == Some(column_name))
.and_then(|idx| names.get(idx))
.map(|s| s.as_str());
let Some(view_type) = session.metadata().get_column_view_type(column_name) else {
return Ok(ColumnConfigSchema { fields: vec![] });
};
let current_js = JsValue::from_serde_ext(¤t_value).unwrap_or(JsValue::NULL);
let view_config_js = JsValue::from_serde_ext(view_config).unwrap_or(JsValue::NULL);
let stats = session.get_column_stats(column_name).unwrap_or_default();
let stats_json = serde_json::json!({
"abs_max": stats.abs_max,
});
let stats_js = JsValue::from_serde_ext(&stats_json).unwrap_or(JsValue::NULL);
let raw = plugin._column_config_schema(
&view_type.to_string(),
group,
column_name,
¤t_js,
&view_config_js,
&stats_js,
)?;
serde_wasm_bindgen::from_value(raw).map_err(|e| e.into())
}
pub fn update_plugin_config(
&self,
view_config: &ViewConfig,
update: PluginConfigUpdate,
) -> bool {
let Some(n) = self.active_plugin_name() else {
return false;
};
let schema = self.query_plugin_config_schema(view_config).ok();
let mut st = self.borrow_mut();
let bucket = st.plugin_states.entry(n).or_default();
match update {
OptionalUpdate::SetDefault => {
let changed = !bucket.plugin.is_empty();
bucket.plugin.clear();
changed
},
OptionalUpdate::Missing => false,
OptionalUpdate::Update(mut map) => {
let mut changed = false;
if let Some(s) = &schema {
map.retain(|key, value| {
let is_default = s
.fields
.iter()
.any(|spec| matches_declared_default(spec, key, value));
if is_default {
if bucket.plugin.remove(key).is_some() {
changed = true;
}
false
} else {
true
}
});
}
for (k, v) in map {
let prev = bucket.plugin.insert(k, v.clone());
if prev.as_ref() != Some(&v) {
changed = true;
}
}
changed
},
}
}
pub fn update_plugin_config_field(
&self,
view_config: &ViewConfig,
mut update: ColumnConfigFieldUpdate,
) -> bool {
let Some(n) = self.active_plugin_name() else {
return false;
};
if let Ok(schema) = self.query_plugin_config_schema(view_config) {
strip_default_values(&schema, &mut update.value);
}
let mut st = self.borrow_mut();
let bucket = st.plugin_states.entry(n).or_default();
let mut changed = false;
for k in &update.keys {
if let Some(v) = update.value.get(k) {
let prev = bucket.plugin.insert(k.to_string(), v.clone());
if prev.as_ref() != Some(v) {
changed = true;
}
} else if bucket.plugin.remove(k).is_some() {
changed = true;
}
}
changed
}
pub fn is_render_warning_enabled(&self) -> bool {
self.0.render_warning.get()
}
pub fn get_all_plugins(&self) -> Vec<JsPerspectiveViewerPlugin> {
self.0.borrow_mut().plugin_store.plugins().clone()
}
pub fn get_all_plugin_categories(&self) -> HashMap<String, Vec<String>> {
self.0.borrow_mut().plugin_store.plugin_records().clone()
}
pub fn get_all_plugin_configs(&self) -> Vec<Rc<PluginStaticConfig>> {
self.0.borrow_mut().plugin_store.plugin_configs().clone()
}
pub fn get_active_plugin(&self) -> ApiResult<JsPerspectiveViewerPlugin> {
if self.0.borrow().plugins_idx.is_none() {
let _ = self.apply_pending_plugin()?;
}
let idx = self.0.borrow().plugins_idx.unwrap_or(0);
let result = self.0.borrow_mut().plugin_store.plugins().get(idx).cloned();
Ok(result.ok_or("No Plugin")?)
}
pub fn get_plugin(&self, name: &str) -> ApiResult<JsPerspectiveViewerPlugin> {
let idx = self.find_plugin_idx(name);
let idx = idx.ok_or_else(|| JsValue::from(format!("No Plugin `{name}`")))?;
let result = self.0.borrow_mut().plugin_store.plugins().get(idx).cloned();
Ok(result.unwrap())
}
pub fn is_plugin_activated(&self) -> ApiResult<bool> {
Ok(self
.get_active_plugin()?
.unchecked_ref::<HtmlElement>()
.is_connected())
}
pub async fn restyle_all(&self, view: &perspective_client::View) -> ApiResult<JsValue> {
let plugin = self.get_active_plugin()?;
let meta = self.metadata();
plugin.restyle();
let mut limits =
get_row_and_col_limits(view, &meta, self.is_render_warning_enabled()).await?;
limits.is_update = false;
plugin
.draw(view.clone().into(), limits.max_cols, limits.max_rows, false)
.await?;
Ok(JsValue::UNDEFINED)
}
pub fn set_throttle(&self, val: Option<f64>) {
self.0.borrow_mut().timer.set_throttle(val);
}
pub fn set_selection(&self, window: Option<ViewWindow>) {
if self.borrow().selection == window {
return;
}
self.borrow_mut().selection = window.clone();
self.selection_changed.emit(window);
}
pub fn get_selection(&self) -> Option<ViewWindow> {
self.borrow().selection.clone()
}
pub fn disable_active_plugin_render_warning(&self) {
self.0.render_warning.set(false);
}
pub fn get_next_plugin_metadata(
&self,
update: &PluginUpdate,
) -> Option<Rc<PluginStaticConfig>> {
let default_plugin_name = PLUGIN_REGISTRY.default_plugin_name();
let name = match update {
PluginUpdate::Missing => return None,
PluginUpdate::SetDefault => default_plugin_name.as_str(),
PluginUpdate::Update(plugin) => plugin,
};
let idx = self.find_plugin_idx(name)?;
let changed = !matches!(
self.0.borrow().plugins_idx,
Some(selected_idx) if selected_idx == idx
);
if changed {
self.borrow_mut().pending_plugin = Some(idx);
self.0
.borrow_mut()
.plugin_store
.plugin_configs()
.get(idx)
.cloned()
} else {
None
}
}
pub fn apply_pending_plugin(&self) -> ApiResult<bool> {
let xxx = self.borrow_mut().pending_plugin.take();
if let Some(idx) = xxx {
let changed = !matches!(
self.0.borrow().plugins_idx,
Some(selected_idx) if selected_idx == idx
);
if changed {
self.commit_plugin_idx(idx)?;
}
Ok(changed)
} else {
if self.0.borrow().plugins_idx.is_none() {
self.set_plugin(Some(&PLUGIN_REGISTRY.default_plugin_name()))?;
}
Ok(false)
}
}
fn set_plugin(&self, name: Option<&str>) -> ApiResult<bool> {
self.borrow_mut().pending_plugin = None;
let default_plugin_name = PLUGIN_REGISTRY.default_plugin_name();
let name = name.unwrap_or(default_plugin_name.as_str());
let idx = self
.find_plugin_idx(name)
.ok_or_else(|| JsValue::from(format!("Unknown plugin '{name}'")))?;
let changed = !matches!(
self.0.borrow().plugins_idx,
Some(selected_idx) if selected_idx == idx
);
if changed {
self.commit_plugin_idx(idx)?;
}
Ok(changed)
}
fn commit_plugin_idx(&self, idx: usize) -> ApiResult<()> {
self.borrow_mut().plugins_idx = Some(idx);
let config = self
.0
.borrow_mut()
.plugin_store
.plugin_configs()
.get(idx)
.cloned()
.ok_or("No Plugin")?;
self.borrow_mut().metadata = config.clone();
self.0.render_warning.set(true);
let plugin: JsPerspectiveViewerPlugin = self.get_active_plugin()?;
let bucket = self
.borrow()
.plugin_states
.get(&config.name)
.cloned()
.unwrap_or_default();
let token = JsValue::from_serde_ext(&bucket.plugin).unwrap_or(JsValue::NULL);
if let Err(e) = plugin.restore(&token, Some(&bucket.columns)) {
tracing::warn!("plugin.restore on swap failed: {:?}", e);
}
self.plugin_changed.emit(plugin);
Ok(())
}
pub async fn with_lock<T>(self, task: impl Future<Output = ApiResult<T>>) -> ApiResult<T> {
let draw_mutex = self.draw_lock();
draw_mutex.lock(task).await
}
pub async fn resize(&self) -> ApiResult<()> {
let draw_mutex = self.draw_lock();
let timer = self.render_timer();
draw_mutex
.debounce(async {
set_timeout(timer.get_throttle()).await?;
let jsplugin = self.get_active_plugin()?;
jsplugin.resize().await?;
Ok(())
})
.await
}
pub async fn resize_with_dimensions(&self, width: f64, height: f64) -> ApiResult<()> {
let draw_mutex = self.draw_lock();
let timer = self.render_timer();
draw_mutex
.debounce(async {
set_timeout(timer.get_throttle()).await?;
let plugin = self.get_active_plugin()?;
let main_panel: &web_sys::HtmlElement = plugin.unchecked_ref();
let rect = main_panel.get_bounding_client_rect();
if (height - rect.height()).abs() > 0.5 || (width - rect.width()).abs() > 0.5 {
let new_width = format!("{}px", width);
let new_height = format!("{}px", height);
main_panel.style().set_property("width", &new_width)?;
main_panel.style().set_property("height", &new_height)?;
let result = plugin.resize().await;
main_panel.style().set_property("width", "")?;
main_panel.style().set_property("height", "")?;
result?;
}
Ok(())
})
.await
}
pub async fn draw(
&self,
session: impl Future<Output = ApiResult<Option<View>>>,
) -> ApiResult<()> {
self.draw_plugin(session, false).await
}
pub async fn update(&self, session: Option<View>) -> ApiResult<()> {
self.draw_plugin(async { Ok(session) }, true).await
}
async fn draw_plugin(
&self,
session: impl Future<Output = ApiResult<Option<View>>>,
is_update: bool,
) -> ApiResult<()> {
let timer = self.render_timer();
let task = async move {
if is_update {
set_timeout(timer.get_throttle()).await?;
}
if let Some(view) = session.await? {
timer.capture_time(self.draw_view(&view, is_update)).await
} else {
tracing::debug!("Render skipped, no `View` attached");
Ok(())
}
};
let draw_mutex = self.draw_lock();
if is_update {
draw_mutex.debounce(task).await
} else {
draw_mutex.lock(task).await
}
}
async fn draw_view(&self, view: &perspective_client::View, is_update: bool) -> ApiResult<()> {
let plugin = self.get_active_plugin()?;
let meta = self.metadata();
let mut limits =
get_row_and_col_limits(view, &meta, self.is_render_warning_enabled()).await?;
limits.is_update = is_update;
if let Some(cb) = self.0.on_render_limits_changed.borrow().as_ref() {
cb.emit(limits);
}
let viewer_elem = &self.0.borrow().viewer_elem.clone();
let result = if is_update {
let task = plugin.update(view.clone().into(), limits.max_cols, limits.max_rows, false);
activate_plugin(viewer_elem, &plugin, task).await
} else {
let task = plugin.draw(view.clone().into(), limits.max_cols, limits.max_rows, false);
activate_plugin(viewer_elem, &plugin, task).await
};
if let Err(error) = result.ignore_view_delete() {
tracing::warn!("{}", error);
}
remove_inactive_plugin(
viewer_elem,
&plugin,
self.plugin_data.borrow_mut().plugin_store.plugins(),
)
}
pub async fn presize(
&self,
open: bool,
panel_task: impl Future<Output = ApiResult<()>>,
) -> ApiResult<JsValue> {
let render_task = self.resize_with_timeout(open);
let result = if open {
panel_task.await?;
render_task.await
} else {
let result = render_task.await;
panel_task.await?;
result
};
match result {
Ok(x) => x,
Err(cont) => {
tracing::warn!("Presize took longer than {}ms", PRESIZE_TIMEOUT);
cont.await.unwrap()
},
}
}
async fn resize_with_timeout(&self, open: bool) -> Result<TaskResult, TimeoutTask<'_>> {
let task = async move {
if open {
self.get_active_plugin()?.resize().await
} else {
self.resize_with_explicit_dimensions().await
}
};
let draw_lock = self.draw_lock();
let tasks: [TimeoutTask<'_>; 2] = [
Box::pin(async move { Some(draw_lock.lock(task).await) }),
Box::pin(async {
set_timeout(PRESIZE_TIMEOUT).await.unwrap();
None
}),
];
let (x, _, y) = select_all(tasks.into_iter()).await;
x.ok_or_else(|| y.into_iter().next().unwrap())
}
async fn resize_with_explicit_dimensions(&self) -> TaskResult {
let plugin = self.get_active_plugin()?;
let main_panel: &web_sys::HtmlElement = plugin.unchecked_ref();
let new_width = format!("{}px", &self.0.borrow().viewer_elem.client_width());
let new_height = format!("{}px", &self.0.borrow().viewer_elem.client_height());
main_panel.style().set_property("width", &new_width)?;
main_panel.style().set_property("height", &new_height)?;
let result = plugin.resize().await;
main_panel.style().set_property("width", "")?;
main_panel.style().set_property("height", "")?;
result
}
fn draw_lock(&self) -> DebounceMutex {
self.draw_lock.clone()
}
pub fn render_timer(&self) -> MovingWindowRenderTimer {
self.0.borrow().timer.clone()
}
fn find_plugin_idx(&self, name: &str) -> Option<usize> {
let short_name = make_short_name(name);
let mut borrowed = self.0.borrow_mut();
let configs = borrowed.plugin_store.plugin_configs();
let short_names: Vec<String> = configs.iter().map(|c| make_short_name(&c.name)).collect();
if let Some(i) = short_names.iter().position(|n| n == &short_name) {
return Some(i);
}
short_names
.iter()
.position(|n: &String| n.contains(&short_name))
}
}
fn make_short_name(name: &str) -> String {
name.to_lowercase()
.chars()
.filter(|x| x.is_alphabetic())
.collect()
}
impl Renderer {
pub fn to_props(&self, render_limits: Option<RenderLimits>) -> RendererProps {
let has_plugin = self.0.borrow().plugins_idx.is_some();
if has_plugin {
let config = self.metadata();
let plugin_name = Some(config.name.clone());
let is_chart = config.name.as_str() != "Datagrid";
let available_plugins = self
.get_all_plugin_configs()
.into_iter()
.map(|c| c.name.clone())
.collect::<Vec<_>>()
.into();
let plugin_config = PtrEqRc::new(self.get_plugin_config());
RendererProps {
plugin_name,
config,
render_limits,
available_plugins,
is_chart,
plugin_config,
}
} else {
RendererProps {
plugin_name: None,
config: Rc::new(PluginStaticConfig::default()),
render_limits,
available_plugins: PtrEqRc::new(vec![]),
is_chart: false,
plugin_config: PtrEqRc::default(),
}
}
}
}
fn strip_default_values(
schema: &ColumnConfigSchema,
map: &mut serde_json::Map<String, serde_json::Value>,
) {
map.retain(|key, value| {
!schema
.fields
.iter()
.any(|spec| matches_declared_default(spec, key, value))
});
}
fn matches_declared_default(spec: &ControlSpec, key: &str, value: &Value) -> bool {
match spec {
ControlSpec::Enum {
key: k, default, ..
} if k == key => value.as_str() == Some(default.as_str()),
ControlSpec::Bool {
key: k, default, ..
} if k == key => value.as_bool() == Some(*default),
ControlSpec::Number {
key: k,
include: Some(true),
..
} if k == key => false,
ControlSpec::Number {
key: k, default, ..
} if k == key => value.as_f64() == Some(*default),
ControlSpec::String {
key: k, default, ..
} if k == key => value.as_str() == Some(default.as_str()),
ControlSpec::Color {
key: k, default, ..
} if k == key => value.as_str() == Some(default.as_str()),
ControlSpec::ColorRange {
key_pos,
default_pos,
..
} if key_pos == key => value.as_str() == Some(default_pos.as_str()),
ControlSpec::ColorRange {
key_neg,
default_neg,
..
} if key_neg == key => value.as_str() == Some(default_neg.as_str()),
_ => false,
}
}