use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use futures::future::join_all;
use perspective_client::config::ViewConfig;
use perspective_client::proto::ColumnType;
use perspective_js::utils::{ApiError, ApiResult, JsValueSerdeExt};
use serde_json::Value;
use wasm_bindgen::prelude::*;
use super::Renderer;
use super::state::PluginRef;
use crate::config::*;
use crate::js::plugin::JsPerspectiveViewerPlugin;
use crate::queries::{is_pivot_only_column, resolve_abs_max};
use crate::session::Session;
use crate::utils::{CssKind, CssLiteralUse, parse_var_ref, resolve_css_refs};
type ConfigMap = serde_json::Map<String, serde_json::Value>;
pub type SchemaResult = Result<Option<ColumnConfigSchema>, ValidationError>;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ColumnRole {
Column,
GroupBy,
SplitBy,
}
impl ColumnRole {
pub fn as_str(&self) -> &'static str {
match self {
Self::Column => "column",
Self::GroupBy => "group_by",
Self::SplitBy => "split_by",
}
}
}
struct ColumnSchemaEnv<'a> {
view_config: &'a ViewConfig,
session: &'a Session,
view_schema: Option<&'a HashMap<String, ColumnType>>,
column_stats: bool,
plugin_config: &'a ConfigMap,
}
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 PreparedRenderer {
pub plugin: Option<PluginRef>,
pub target_name: String,
pub plugin_config: ValidatedPluginConfig,
pub columns_config: ValidatedColumnsConfig,
pub theme: Option<Option<String>>,
}
pub struct RendererChanges {
pub plugin_config: bool,
pub columns_config: bool,
}
pub struct PluginTarget {
pub element: JsPerspectiveViewerPlugin,
pub static_config: Rc<PluginStaticConfig>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ValidatedPluginConfig {
Missing,
Clear,
Set {
map: ConfigMap,
remove: Vec<String>,
active: Option<HashSet<String>>,
},
}
#[derive(Debug, PartialEq)]
pub enum ValidatedColumnsConfig {
Missing,
Clear,
Set(Vec<(String, ConfigMap)>),
}
#[derive(Debug, PartialEq)]
pub struct ValidationError(pub String);
impl From<ValidationError> for ApiError {
fn from(err: ValidationError) -> Self {
ApiError::from(JsValue::from_str(&err.0))
}
}
pub fn validate_plugin_config(
current: &ConfigMap,
update: PluginConfigUpdate,
schema_of: &dyn Fn(&ConfigMap) -> SchemaResult,
) -> Result<ValidatedPluginConfig, ValidationError> {
match update {
OptionalUpdate::SetDefault => Ok(ValidatedPluginConfig::Clear),
OptionalUpdate::Missing => Ok(ValidatedPluginConfig::Missing),
OptionalUpdate::Update(mut map) => {
let mut merged = current.clone();
for (k, v) in &map {
merged.insert(k.clone(), v.clone());
}
let schema = schema_of(&merged)?;
let mut active = None;
let mut remove = vec![];
if let Some(s) = &schema {
let keys = s.active_keys();
map.retain(|k, _| keys.contains(k));
active = Some(keys);
let errors = normalize_values(s, &mut map);
if let Some((key, error)) = errors.first() {
return Err(ValidationError(format!(
"Invalid `plugin_config.{key}`: {error}"
)));
}
let leaves = s.leaf_fields();
map.retain(|key, value| {
let is_default = leaves
.iter()
.any(|spec| matches_declared_default(spec, key, value));
if is_default {
remove.push(key.clone());
}
!is_default
});
}
Ok(ValidatedPluginConfig::Set {
map,
remove,
active,
})
},
}
}
pub fn validate_columns_config(
update: ColumnConfigUpdate,
schema_of: &dyn Fn(&str, &ConfigMap) -> SchemaResult,
view_type_of: &dyn Fn(&str) -> Option<ColumnType>,
resolve_css: &dyn Fn(&str, &mut ConfigMap),
) -> Result<ValidatedColumnsConfig, ValidationError> {
match update {
OptionalUpdate::SetDefault => Ok(ValidatedColumnsConfig::Clear),
OptionalUpdate::Missing => Ok(ValidatedColumnsConfig::Missing),
OptionalUpdate::Update(map) => {
let mut stripped = Vec::with_capacity(map.len());
for (col, mut cfg) in map {
if let Some(schema) = schema_of(&col, &cfg)? {
let active = schema.active_keys();
cfg.retain(|k, _| active.contains(k));
let errors = normalize_values(&schema, &mut cfg);
if let Some((key, error)) = errors.first() {
let ty = view_type_of(&col)
.map(|t| format!(" for a {t} column"))
.unwrap_or_default();
return Err(ValidationError(format!(
"Invalid `columns_config[\"{col}\"].{key}`{ty}: {error}"
)));
}
resolve_css(&col, &mut cfg);
strip_default_values(&schema, &mut cfg);
}
stripped.push((col, cfg));
}
Ok(ValidatedColumnsConfig::Set(stripped))
},
}
}
fn declares(target: &PluginTarget, name: &str) -> bool {
js_sys::Reflect::get(&target.element, &JsValue::from_str(name))
.map(|f| f.is_function())
.unwrap_or(false)
}
pub fn apply_plugin_config_to(
bucket: &mut PluginScopedConfig,
validated: ValidatedPluginConfig,
) -> bool {
match validated {
ValidatedPluginConfig::Missing => false,
ValidatedPluginConfig::Clear => {
let changed = !bucket.plugin.is_empty();
bucket.plugin.clear();
changed
},
ValidatedPluginConfig::Set {
map,
remove,
active,
} => {
let mut changed = false;
for key in remove {
if bucket.plugin.remove(&key).is_some() {
changed = true;
}
}
for (k, v) in map {
let prev = bucket.plugin.insert(k, v.clone());
if prev.as_ref() != Some(&v) {
changed = true;
}
}
if let Some(active) = active {
let before = bucket.plugin.len();
bucket.plugin.retain(|k, _| active.contains(k));
changed |= bucket.plugin.len() != before;
}
changed
},
}
}
pub fn apply_columns_config_to(
bucket: &mut PluginScopedConfig,
validated: ValidatedColumnsConfig,
) -> bool {
match validated {
ValidatedColumnsConfig::Missing => false,
ValidatedColumnsConfig::Clear => {
let was_nonempty = !bucket.columns.is_empty();
bucket.columns.clear();
was_nonempty
},
ValidatedColumnsConfig::Set(entries) => {
let mut changed = false;
for (col, cfg) in entries {
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
},
}
}
impl Renderer {
fn active_plugin_name(&self) -> Option<String> {
Some(self.metadata().name.clone()).filter(|n| !n.is_empty())
}
fn edit_bucket<R>(&self, name: &str, f: impl FnOnce(&mut PluginScopedConfig) -> R) -> R {
let state = self.state();
let mut bucket = state.bucket(name);
let result = f(&mut bucket);
self.swap(state.with_bucket(name, bucket));
result
}
pub fn active_target(&self) -> ApiResult<PluginTarget> {
let element = self.ensure_plugin_selected()?;
Ok(PluginTarget {
element,
static_config: self.metadata(),
})
}
pub fn target_at(&self, idx: usize) -> ApiResult<PluginTarget> {
let mut st = self.borrow_mut();
let element = st.plugin_store.plugins().get(idx).cloned();
let static_config = st.plugin_store.plugin_configs().get(idx).cloned();
Ok(PluginTarget {
element: element.ok_or("No Plugin")?,
static_config: static_config.ok_or("No Plugin")?,
})
}
pub fn apply_plugin_config(
&self,
target: &PluginTarget,
validated: ValidatedPluginConfig,
) -> bool {
self.edit_bucket(&target.static_config.name, |bucket| {
apply_plugin_config_to(bucket, validated)
})
}
pub fn apply_columns_config(
&self,
target: &PluginTarget,
validated: ValidatedColumnsConfig,
) -> bool {
self.edit_bucket(&target.static_config.name, |bucket| {
apply_columns_config_to(bucket, validated)
})
}
pub fn resolve_target(
&self,
update: &PluginUpdate,
) -> ApiResult<(PluginTarget, Option<usize>)> {
if let Some((idx, _)) = self.resolve_plugin_update(update) {
return Ok((self.target_at(idx)?, Some(idx)));
}
if let Some(plugin) = self.state().plugin.as_ref() {
return Ok((self.target_at(plugin.idx)?, None));
}
let (idx, _) = self
.resolve_plugin_update(&OptionalUpdate::SetDefault)
.ok_or("No Plugin")?;
Ok((self.target_at(idx)?, Some(idx)))
}
pub fn commit(&self, prepared: PreparedRenderer) -> RendererChanges {
let PreparedRenderer {
plugin,
target_name,
plugin_config,
columns_config,
theme,
} = prepared;
let mut next = (*self.state()).clone();
if let Some(plugin) = plugin {
next = next.with_plugin(plugin);
}
let mut bucket = next.bucket(&target_name);
let changes = RendererChanges {
plugin_config: apply_plugin_config_to(&mut bucket, plugin_config),
columns_config: apply_columns_config_to(&mut bucket, columns_config),
};
if changes.plugin_config || changes.columns_config {
next = next.with_bucket(&target_name, bucket);
}
if let Some(theme) = theme {
next = next.with_theme(theme);
}
self.swap(next);
changes
}
#[allow(clippy::too_many_arguments)]
pub fn prepare_buckets(
&self,
target: &PluginTarget,
session: &Session,
view_config: &ViewConfig,
view_schema: Option<&HashMap<String, ColumnType>>,
rebinds: bool,
plugin_config: PluginConfigUpdate,
columns_config: ColumnConfigUpdate,
) -> ApiResult<(ValidatedPluginConfig, ValidatedColumnsConfig)> {
let current = self.state().bucket(&target.static_config.name);
let plugin = validate_plugin_config(¤t.plugin, plugin_config, &|merged| {
if !declares(target, "plugin_config_schema") {
return Ok(None);
}
self.query_plugin_config_schema_for(target, view_config, Some(merged))
.map(Some)
.map_err(|e| ValidationError(format!("`plugin_config` schema failed: {e}")))
})?;
let mut ends_with = current.clone();
apply_plugin_config_to(&mut ends_with, plugin.clone());
let columns = validate_columns_config(
columns_config,
&|col, cfg| {
let Some(view_schema) = view_schema else {
return Ok(None);
};
if !declares(target, "column_config_schema") {
return Ok(None);
}
let env = ColumnSchemaEnv {
view_config,
session,
view_schema: Some(view_schema),
column_stats: !rebinds,
plugin_config: &ends_with.plugin,
};
self.query_column_config_schema_for(target, &env, col, Some(cfg))
.map(Some)
.map_err(|e| {
ValidationError(format!("`columns_config[\"{col}\"]` schema failed: {e}"))
})
},
&|col| view_schema.and_then(|x| x.get(col).copied()),
&|col, cfg| self.resolve_css_refs_in(col, cfg),
)?;
Ok((plugin, columns))
}
pub fn all_columns_configs(&self) -> ColumnConfigMap {
self.active_plugin_name()
.map(|n| self.projected_bucket(&n).columns)
.unwrap_or_default()
}
pub fn committed_columns_configs(&self) -> ColumnConfigMap {
self.active_plugin_name()
.map(|n| self.state().bucket(&n).columns)
.unwrap_or_default()
}
fn resolve_css_refs_in(&self, column: &str, entry: &mut serde_json::Map<String, Value>) {
let host = self.borrow().viewer_elem.clone();
let lookup = |name: &str| crate::utils::read_custom_property(&host, name);
for (key, name) in resolve_css_refs(entry, &lookup) {
tracing::warn!(
"Dropping `columns_config[\"{column}\"].{key}`: `var({name})` is undefined or not \
a valid value of its kind"
);
}
}
pub fn css_literals_in_use(
&self,
view_config: &ViewConfig,
session: &Session,
) -> Vec<CssLiteralUse> {
let mut out = vec![];
for (column, entry) in self.all_columns_configs() {
let Ok(schema) =
self.query_column_config_schema(view_config, session, &column, Some(&entry))
else {
continue;
};
for (key, value) in &entry {
if let (Some(kind), Some(literal)) = (schema.css_kind_of(key), value.as_str())
&& parse_var_ref(literal).is_none()
{
out.push(CssLiteralUse {
column: column.clone(),
key: key.clone(),
kind,
literal: literal.to_owned(),
});
}
}
}
out.sort_by(|a, b| (&a.column, &a.key).cmp(&(&b.column, &b.key)));
out
}
pub async fn all_columns_configs_materialized(
&self,
view_config: &ViewConfig,
session: &Session,
) -> ColumnConfigMap {
let mut configs = self.committed_columns_configs();
let mut to_warm: Vec<String> = vec![];
for (col, entry) in &configs {
if session
.get_column_stats(col)
.and_then(|s| s.abs_max)
.is_some()
{
continue;
}
let Ok(schema) =
self.query_column_config_schema(view_config, session, col, Some(entry))
else {
continue;
};
let needs_warm = schema.leaf_fields().into_iter().any(|f| {
matches!(
f,
ControlSpec::Number {
key,
include: Some(true),
default_stat: Some(_),
..
} if !entry.contains_key(key)
)
});
if needs_warm {
to_warm.push(col.clone());
}
}
if !to_warm.is_empty() {
let metadata = session.metadata().clone();
let view = session.get_view();
let futs = to_warm
.iter()
.map(|c| resolve_abs_max(session, &metadata, view.as_ref(), c.as_str()));
join_all(futs).await;
}
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.leaf_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.edit_bucket(&n, |bucket| bucket.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.projected_bucket(&n).columns.get(column_name).cloned()
}
pub fn update_columns_configs(
&self,
view_config: &ViewConfig,
session: &Session,
update: ColumnConfigUpdate,
) -> ApiResult<bool> {
if self.active_plugin_name().is_none() {
return Ok(false);
}
let target = self.active_target()?;
let validated = validate_columns_config(
update,
&|col, cfg| {
let env = ColumnSchemaEnv {
view_config,
session,
view_schema: None,
column_stats: true,
plugin_config: &self.committed_plugin_config(),
};
Ok(self
.query_column_config_schema_for(&target, &env, col, Some(cfg))
.ok())
},
&|col| session.metadata().get_column_view_type(col),
&|col, cfg| self.resolve_css_refs_in(col, cfg),
)?;
Ok(self.apply_columns_config(&target, validated))
}
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.state().bucket(&n).columns.get(&column_name).cloned();
if let Ok(schema) = self.query_column_config_schema(
view_config,
session,
&column_name,
current_value.as_ref(),
) {
for (key, error) in normalize_values(&schema, &mut update.value) {
tracing::error!("Dropping `{column_name}`.`{key}`: {error}");
}
self.resolve_css_refs_in(&column_name, &mut update.value);
strip_default_values(&schema, &mut update.value);
}
let next = self.edit_bucket(&n, |bucket| {
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);
}
}
entry.clone()
});
let after = self
.query_column_config_schema(view_config, session, &column_name, Some(&next))
.ok();
self.edit_bucket(&n, |bucket| {
if let Some(entry) = bucket.columns.get_mut(&column_name) {
if let Some(schema) = &after {
let active = schema.active_keys();
entry.retain(|k, _| active.contains(k));
for (key, error) in normalize_values(schema, entry) {
tracing::warn!(
"Dropping `{column_name}`.`{key}` after a mode change: {error}"
);
}
}
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()
.map(|n| self.projected_bucket(&n).plugin)
.unwrap_or_default()
}
pub fn committed_plugin_config(&self) -> serde_json::Map<String, serde_json::Value> {
self.active_plugin_name()
.map(|n| self.state().bucket(&n).plugin)
.unwrap_or_default()
}
pub fn reset_plugin_config(&self) {
if let Some(n) = self.active_plugin_name() {
self.edit_bucket(&n, |bucket| bucket.plugin.clear());
}
}
fn query_plugin_config_schema(
&self,
view_config: &ViewConfig,
current_value: Option<&ConfigMap>,
) -> ApiResult<ColumnConfigSchema> {
let target = self.active_target()?;
self.query_plugin_config_schema_for(&target, view_config, current_value)
}
fn query_plugin_config_schema_for(
&self,
target: &PluginTarget,
view_config: &ViewConfig,
current_value: Option<&ConfigMap>,
) -> ApiResult<ColumnConfigSchema> {
let view_config_js = JsValue::from_serde_ext(view_config).unwrap_or(JsValue::NULL);
let current_js = JsValue::from_serde_ext(¤t_value).unwrap_or(JsValue::NULL);
let raw = target
.element
._plugin_config_schema(&view_config_js, ¤t_js)?;
serde_wasm_bindgen::from_value::<ColumnConfigSchema>(raw)
.map(|schema| schema.canonicalize())
.map_err(|e| e.into())
}
fn query_column_config_schema(
&self,
view_config: &ViewConfig,
session: &Session,
column_name: &str,
current_value: Option<&ConfigMap>,
) -> ApiResult<ColumnConfigSchema> {
let target = self.active_target()?;
let env = ColumnSchemaEnv {
view_config,
session,
view_schema: None,
column_stats: true,
plugin_config: &self.committed_plugin_config(),
};
self.query_column_config_schema_for(&target, &env, column_name, current_value)
}
fn query_column_config_schema_for(
&self,
target: &PluginTarget,
env: &ColumnSchemaEnv<'_>,
column_name: &str,
current_value: Option<&ConfigMap>,
) -> ApiResult<ColumnConfigSchema> {
let ColumnSchemaEnv {
view_config,
session,
view_schema,
column_stats,
plugin_config,
} = *env;
let names = &target.static_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 role = if !is_pivot_only_column(column_name, view_config) {
ColumnRole::Column
} else if view_config.split_by.iter().any(|x| x == column_name) {
ColumnRole::SplitBy
} else {
ColumnRole::GroupBy
};
let view_type = match view_schema {
Some(view_schema) => view_schema.get(column_name).copied(),
None => {
if !session.metadata().has_view_schema() {
return Err(JsValue::from("view_schema not initialized").into());
}
session.metadata().get_column_view_type(column_name)
},
};
let view_type = match role {
ColumnRole::Column => view_type,
_ => session
.metadata()
.get_column_table_type(column_name)
.or(view_type),
};
let Some(view_type) = view_type 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 plugin_config_js = JsValue::from_serde_ext(plugin_config).unwrap_or(JsValue::NULL);
let raw = target.element._column_config_schema(
&view_type.to_string(),
group,
column_name,
¤t_js,
&view_config_js,
&plugin_config_js,
role.as_str(),
)?;
let abs_max = column_stats
.then(|| {
session
.get_column_stats(column_name)
.and_then(|s| s.abs_max)
})
.flatten();
serde_wasm_bindgen::from_value::<ColumnConfigSchema>(raw)
.map(|schema| schema.canonicalize().resolve_stat_defaults(abs_max))
.map_err(|e| e.into())
}
pub fn update_plugin_config(
&self,
view_config: &ViewConfig,
update: PluginConfigUpdate,
) -> ApiResult<bool> {
if self.active_plugin_name().is_none() {
return Ok(false);
}
let target = self.active_target()?;
let current = self.committed_plugin_config();
let validated = validate_plugin_config(¤t, update, &|merged| {
Ok(self
.query_plugin_config_schema_for(&target, view_config, Some(merged))
.ok())
})?;
Ok(self.apply_plugin_config(&target, validated))
}
pub fn update_plugin_config_field(
&self,
view_config: &ViewConfig,
mut update: ColumnConfigFieldUpdate,
) -> bool {
let Some(n) = self.active_plugin_name() else {
return false;
};
let mut next = self.committed_plugin_config();
for k in &update.keys {
match update.value.get(k) {
Some(v) => {
next.insert(k.clone(), v.clone());
},
None => {
next.remove(k);
},
}
}
let schema = self
.query_plugin_config_schema(view_config, Some(&next))
.ok();
if let Some(schema) = &schema {
for (key, error) in normalize_values(schema, &mut update.value) {
tracing::error!("Dropping `plugin_config`.`{key}`: {error}");
}
strip_default_values(schema, &mut update.value);
}
self.edit_bucket(&n, |bucket| {
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;
}
}
if let Some(schema) = &schema {
let active = schema.active_keys();
let before = bucket.plugin.len();
bucket.plugin.retain(|k, _| active.contains(k));
changed |= bucket.plugin.len() != before;
}
changed
})
}
}
fn normalize_values(
schema: &ColumnConfigSchema,
map: &mut serde_json::Map<String, serde_json::Value>,
) -> Vec<(String, String)> {
let mut errors = vec![];
for (key, value) in map.iter_mut() {
if let Some(kind) = schema.css_kind_of(key) {
let canonical = match value.as_str() {
Some(src) => kind.canonicalize(src),
None => Err(format!("expected a CSS string, got `{value}`")),
};
match canonical {
Ok(canonical) => *value = Value::String(canonical),
Err(error) => errors.push((key.clone(), error)),
}
} else if let Some(variants) = schema.enum_variants_of(key) {
let accepted = variants.iter().any(|v| value.as_str() == Some(&v.value));
if !accepted {
let expected = variants
.iter()
.map(|v| format!("`{}`", v.value))
.collect::<Vec<_>>()
.join(", ");
errors.push((key.clone(), format!("{value} is not one of {expected}")));
}
}
}
for (key, _) in &errors {
map.remove(key);
}
errors
}
fn strip_default_values(
schema: &ColumnConfigSchema,
map: &mut serde_json::Map<String, serde_json::Value>,
) {
let leaves = schema.leaf_fields();
map.retain(|key, value| {
!leaves
.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, ..
}
| ControlSpec::Font {
key: k, default, ..
} if k == key => value.as_str() == Some(default.as_str()),
ControlSpec::Font {
size: Some(size), ..
} if size.key == key => value.as_f64() == Some(size.default),
ControlSpec::Font { bold, italic, .. } => [bold, italic]
.into_iter()
.flatten()
.any(|toggle| toggle.key == key && value.as_bool() == Some(toggle.default)),
ControlSpec::Alignment {
key: k,
default: Some(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 => css_matches(CssKind::Color, value, default),
ControlSpec::Palette {
key: k, default, ..
} if k == key => css_matches(CssKind::Palette, value, default),
ControlSpec::GradientStops {
key: k, default, ..
} if k == key => css_matches(CssKind::Gradient, value, default),
_ => false,
}
}
fn css_matches(kind: CssKind, value: &Value, default: &str) -> bool {
value
.as_str()
.and_then(|src| kind.canonicalize(src).ok())
.is_some_and(|canonical| canonical == default)
}