use serde_json::Value;
use crate::compat::String;
use crate::widget::capability::{
widget_property_names, widget_property_set, CapabilityValue, PropertyValueKind,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ApplyOutcome {
Applied,
NotAProperty,
Rejected,
}
use crate::widget::capability::coercion::normalize_key as normalize;
pub fn is_widget_property(widget: &dyn crate::widget::Widget, name: &str) -> bool {
let Some(published) = widget_property_names(widget) else {
return false;
};
let wanted = normalize(name);
published.iter().any(|candidate| normalize(candidate) == wanted)
}
pub(crate) fn resolve_property_name(
widget: &dyn crate::widget::Widget,
name: &str,
) -> Option<&'static str> {
let published = widget_property_names(widget)?;
let wanted = normalize(name);
published.iter().copied().find(|candidate| normalize(candidate) == wanted)
}
#[cfg(any(test, feature = "designer"))]
pub(crate) fn to_capability_value(
value: &Value,
declared: Option<PropertyValueKind>,
) -> Option<CapabilityValue> {
to_capability_value_with_record_schema(value, declared, None)
}
pub(crate) fn to_capability_value_with_record_schema(
value: &Value,
declared: Option<PropertyValueKind>,
record_schema: Option<&crate::widget::capability::RecordSchema>,
) -> Option<CapabilityValue> {
match declared {
Some(PropertyValueKind::Bool) => value.as_bool().map(CapabilityValue::Bool),
Some(PropertyValueKind::Int) => value.as_i64().map(CapabilityValue::Int),
Some(PropertyValueKind::UInt) => value.as_u64().map(CapabilityValue::UInt),
Some(PropertyValueKind::Float) => value.as_f64().map(CapabilityValue::Float),
Some(PropertyValueKind::Number) => {
let n = value.as_f64()?;
if n.fract() == 0.0 && n >= i64::MIN as f64 && n < i64::MAX as f64 {
Some(CapabilityValue::Int(n as i64))
} else {
Some(CapabilityValue::Float(n))
}
}
Some(PropertyValueKind::String) | Some(PropertyValueKind::Enum) => {
value.as_str().map(|s| CapabilityValue::String(s.to_string()))
}
Some(PropertyValueKind::Color) => value
.as_str()
.and_then(|s| crate::style::CssParser::parse_color(s).ok())
.map(CapabilityValue::Color),
Some(PropertyValueKind::Rect) => rect_from_json(value).map(CapabilityValue::Rect),
Some(PropertyValueKind::Records) => {
records_from_json(value, record_schema).map(CapabilityValue::Records)
}
Some(PropertyValueKind::IconSpec) => icon_spec_from_json(value)
.map(CapabilityValue::IconSpec)
.or_else(|| value.is_null().then_some(CapabilityValue::Null)),
None => match value {
Value::Null => Some(CapabilityValue::Null),
Value::Bool(b) => Some(CapabilityValue::Bool(*b)),
Value::Number(n) => {
if let Some(u) = n.as_u64() {
Some(CapabilityValue::UInt(u))
} else if let Some(i) = n.as_i64() {
Some(CapabilityValue::Int(i))
} else {
n.as_f64().map(CapabilityValue::Float)
}
}
Value::String(s) => Some(CapabilityValue::String(s.clone())),
Value::Array(_) | Value::Object(_) => None,
},
}
}
fn icon_spec_from_json(value: &Value) -> Option<crate::widget::icon_source::IconSpec> {
use crate::widget::icon_source::{IconColorMode, IconSpec};
let object = value.as_object()?;
let kind = object.get("kind")?.as_str()?;
match kind {
"builtin" if object.len() == 2 => {
let name = object.get("name")?.as_str()?;
crate::widget::display_widgets::icon::IconName::from_name(name).map(IconSpec::Builtin)
}
"resource" if object.len() == 3 => {
let id = object.get("id")?.as_str()?;
if id.trim().is_empty() {
return None;
}
let color_mode = match object.get("color_mode")?.as_str()? {
"monochrome" => IconColorMode::Monochrome,
"original" => IconColorMode::Original,
_ => return None,
};
Some(IconSpec::Resource { id: id.to_string(), color_mode })
}
_ => None,
}
}
fn rect_from_json(value: &Value) -> Option<crate::core::Rect> {
let components: [i64; 4] = match value {
Value::String(text) => {
let mut parts = text.split(',');
let mut parsed = [0i64; 4];
for slot in parsed.iter_mut() {
*slot = parts.next()?.trim().parse().ok()?;
}
if parts.next().is_some() {
return None;
}
parsed
}
Value::Array(items) => {
if items.len() != 4 {
return None;
}
let mut parsed = [0i64; 4];
for (slot, item) in parsed.iter_mut().zip(items) {
*slot = item.as_i64()?;
}
parsed
}
_ => return None,
};
let width = u32::try_from(components[2]).ok()?;
let height = u32::try_from(components[3]).ok()?;
let x = i32::try_from(components[0]).ok()?;
let y = i32::try_from(components[1]).ok()?;
Some(crate::core::Rect::new(x, y, width, height))
}
fn records_from_json(
value: &Value,
schema: Option<&crate::widget::capability::RecordSchema>,
) -> Option<crate::widget::capability::RecordList> {
use crate::widget::capability::{CapabilityValue, RecordList, RecordValue};
let Value::Array(items) = value else {
return None;
};
let mut records = crate::compat::Vec::new();
for item in items {
let Value::Object(fields) = item else {
return None;
};
let mut record = RecordValue::new();
for (name, field) in fields {
let field_value = match field {
Value::Null => CapabilityValue::Null,
Value::Bool(flag) => CapabilityValue::Bool(*flag),
Value::Number(number) => {
match (number.as_u64(), number.as_i64(), number.as_f64()) {
(Some(unsigned), _, _) => CapabilityValue::UInt(unsigned),
(None, Some(signed), _) => CapabilityValue::Int(signed),
(None, None, Some(float)) => CapabilityValue::Float(float),
_ => return None,
}
}
Value::String(text) => CapabilityValue::String(text.clone()),
Value::Array(_) | Value::Object(_) if declares_icon_spec_field(schema, name) => {
CapabilityValue::IconSpec(icon_spec_from_json(field)?)
}
Value::Array(_) | Value::Object(_) => return None,
};
record = record.with(name.clone(), field_value);
}
records.push(record);
}
Some(RecordList::from_items(&crate::widget::capability::EMPTY_RECORD_SCHEMA, records))
}
fn declares_icon_spec_field(
schema: Option<&crate::widget::capability::RecordSchema>,
name: &str,
) -> bool {
let Some(schema) = schema else {
return false;
};
schema.fields.iter().any(|field| {
field.name == name && field.kind == crate::widget::capability::PropertyValueKind::IconSpec
})
}
pub fn records_to_json(value: &CapabilityValue) -> Option<Value> {
let CapabilityValue::Records(list) = value else {
return None;
};
let mut items = crate::compat::Vec::new();
for record in list.items() {
let mut object = serde_json::Map::new();
for (name, field) in record.fields() {
object.insert(name.clone(), capability_value_to_json(field)?);
}
items.push(Value::Object(object));
}
Some(Value::Array(items))
}
fn capability_value_to_json(value: &CapabilityValue) -> Option<Value> {
match value {
CapabilityValue::Null => Some(Value::Null),
CapabilityValue::Bool(flag) => Some(Value::Bool(*flag)),
CapabilityValue::Int(number) => Some(Value::Number((*number).into())),
CapabilityValue::UInt(number) => Some(Value::Number((*number).into())),
CapabilityValue::Float(number) => serde_json::Number::from_f64(*number).map(Value::Number),
CapabilityValue::String(text) => Some(Value::String(text.clone())),
CapabilityValue::IconSpec(spec) => Some(icon_spec_to_json(spec)),
_ => None,
}
}
fn icon_spec_to_json(spec: &crate::widget::icon_source::IconSpec) -> Value {
use crate::widget::icon_source::{IconColorMode, IconSpec};
let mut object = serde_json::Map::new();
match spec {
IconSpec::Builtin(name) => {
object.insert(String::from("kind"), Value::String(String::from("builtin")));
object.insert(String::from("name"), Value::String(String::from(name.as_str())));
}
IconSpec::Resource { id, color_mode } => {
object.insert(String::from("kind"), Value::String(String::from("resource")));
object.insert(String::from("id"), Value::String(id.clone()));
object.insert(
String::from("color_mode"),
Value::String(String::from(match color_mode {
IconColorMode::Monochrome => "monochrome",
IconColorMode::Original => "original",
})),
);
}
}
Value::Object(object)
}
fn declared_schema(
widget: &dyn crate::widget::Widget,
name: &str,
) -> Option<crate::widget::capability::PropertySchema> {
let factory = crate::json::schema_factory();
let capability = factory.capability_for_kind_instance(widget)?;
let wanted = normalize(name);
capability.properties.iter().find(|schema| normalize(schema.name) == wanted).copied()
}
pub(crate) fn declares_record_list(widget: &dyn crate::widget::Widget, name: &str) -> bool {
let Some(published) = resolve_property_name(widget, name) else {
return false;
};
declared_schema(widget, published)
.is_some_and(|schema| schema.value_kind == PropertyValueKind::Records)
}
#[cfg(test)]
pub(crate) fn apply_widget_property(
widget: &mut dyn crate::widget::Widget,
key: &str,
value: &Value,
) -> ApplyOutcome {
apply_widget_property_with_icon_resolver(widget, key, value, None)
}
pub(crate) fn apply_widget_property_with_icon_resolver(
widget: &mut dyn crate::widget::Widget,
key: &str,
value: &Value,
resolver: Option<&dyn crate::widget::icon_source::IconResourceResolver>,
) -> ApplyOutcome {
let Some(published) = resolve_property_name(widget, key) else {
return ApplyOutcome::NotAProperty;
};
let declared = declared_schema(widget, published);
let Some(capability_value) = to_capability_value_with_record_schema(
value,
declared.as_ref().map(|schema| schema.value_kind),
declared.as_ref().and_then(|schema| schema.record_schema()),
) else {
log::warn!(
"JSON layout: property {published:?} rejected value {value}: it could not be \
converted to the declared capability value kind"
);
return ApplyOutcome::Rejected;
};
if let Some(schema) = declared {
if let Err(violation) = crate::widget::capability::validate_property_value(
published,
&schema,
&capability_value,
) {
log::warn!(
"JSON layout: property {published:?} rejected the value {value}: {violation}"
);
return ApplyOutcome::Rejected;
}
}
let result = match (&capability_value, resolver) {
(
CapabilityValue::IconSpec(crate::widget::icon_source::IconSpec::Resource {
id,
color_mode,
}),
Some(resolver),
) => resolver.resolve_icon(id, *color_mode).and_then(|source| {
crate::widget::capability::properties_trait::widget_set_resolved_icon_source(
widget,
Some(source),
)
.map_err(|error| format!("{error:?}"))
}),
_ => widget_property_set(widget, published, capability_value)
.map_err(|error| format!("{error:?}")),
};
match result {
Ok(()) => ApplyOutcome::Applied,
Err(error) => {
log::warn!("JSON layout: property {published:?} rejected the value {value}: {error}");
ApplyOutcome::Rejected
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Rect;
use crate::widget::Button;
#[test]
fn normalize_folds_case_and_separators() {
assert_eq!(normalize("Font-Size"), "fontsize");
assert_eq!(normalize("font_size"), "fontsize");
assert_eq!(normalize("font size"), "fontsize");
assert_eq!(normalize("fontsize"), "fontsize");
}
#[test]
fn to_capability_value_reads_a_record_list_structurally() {
let json = serde_json::json!([
{"id": "a", "label": "Alpha", "selected": true},
{"id": "b", "label": "Beta"}
]);
let Some(CapabilityValue::Records(list)) =
to_capability_value(&json, Some(PropertyValueKind::Records))
else {
panic!("an array of objects must read as a record list");
};
assert_eq!(list.len(), 2);
assert_eq!(list.get(0).expect("first").text("id"), "a");
assert_eq!(list.get(0).expect("first").text("label"), "Alpha");
assert!(matches!(
list.get(0).expect("first").get("selected"),
Some(CapabilityValue::Bool(true))
));
assert!(list.get(1).expect("second").get("selected").is_none());
assert!(list.schema().fields.is_empty());
}
#[test]
fn to_capability_value_refuses_a_shape_that_is_not_a_record_list() {
let kind = Some(PropertyValueKind::Records);
assert_eq!(to_capability_value(&serde_json::json!({"id": "a"}), kind), None);
assert_eq!(to_capability_value(&serde_json::json!("[]"), kind), None);
assert_eq!(to_capability_value(&serde_json::json!(3), kind), None);
assert_eq!(to_capability_value(&serde_json::json!(["a", "b"]), kind), None);
assert_eq!(to_capability_value(&serde_json::json!([{"id": {"nested": 1}}]), kind), None);
assert_eq!(to_capability_value(&serde_json::json!([{"id": [1, 2]}]), kind), None);
let Some(CapabilityValue::Records(empty)) =
to_capability_value(&serde_json::json!([]), kind)
else {
panic!("an empty array is an empty record list");
};
assert!(empty.is_empty());
assert_eq!(to_capability_value(&serde_json::json!([{"id": "a"}]), None), None);
}
#[test]
fn icon_spec_json_is_structured_and_strict() {
let kind = Some(PropertyValueKind::IconSpec);
let built_in = serde_json::json!({"kind": "builtin", "name": "info"});
assert_eq!(
to_capability_value(&built_in, kind),
Some(CapabilityValue::IconSpec(crate::widget::icon_source::IconSpec::Builtin(
crate::widget::display_widgets::icon::IconName::Info
)))
);
let resource = serde_json::json!({
"kind": "resource",
"id": "project/icons/open",
"color_mode": "monochrome"
});
assert_eq!(
to_capability_value(&resource, kind),
Some(CapabilityValue::IconSpec(crate::widget::icon_source::IconSpec::Resource {
id: "project/icons/open".into(),
color_mode: crate::widget::icon_source::IconColorMode::Monochrome,
}))
);
for invalid in [
serde_json::json!({"kind": "builtin", "name": "not-a-built-in"}),
serde_json::json!({"kind": "builtin", "name": "info", "id": "conflict"}),
serde_json::json!({"kind": "resource", "id": " ", "color_mode": "original"}),
serde_json::json!({"kind": "resource", "id": "icons/open", "color_mode": "tint"}),
serde_json::json!({"kind": "resource", "id": "icons/open", "color_mode": "original", "name": "conflict"}),
] {
assert_eq!(to_capability_value(&invalid, kind), None, "accepted {invalid}");
}
assert_eq!(to_capability_value(&Value::Null, kind), Some(CapabilityValue::Null));
}
#[test]
fn icon_spec_property_apply_preserves_builtin_and_rejects_unresolved_resources() {
let mut button = Button::new("Save".into(), Rect::new(0, 0, 100, 30));
assert_eq!(
apply_widget_property(
&mut button,
"icon_source",
&serde_json::json!({"kind": "builtin", "name": "save"})
),
ApplyOutcome::Applied
);
assert_eq!(
button.icon_source(),
Some(crate::widget::IconSource::Builtin(
crate::widget::display_widgets::icon::IconName::Save
))
);
assert_eq!(
apply_widget_property(
&mut button,
"icon_source",
&serde_json::json!({
"kind": "resource",
"id": "project/icons/save",
"color_mode": "monochrome"
})
),
ApplyOutcome::Rejected
);
assert_eq!(
button.icon_source(),
Some(crate::widget::IconSource::Builtin(
crate::widget::display_widgets::icon::IconName::Save
)),
"a rejected resource must not replace the last valid source"
);
assert_eq!(
apply_widget_property(&mut button, "icon_source", &Value::Null),
ApplyOutcome::Applied
);
assert_eq!(button.icon_source(), None);
}
#[test]
fn a_record_list_the_schema_refuses_is_rejected_not_applied() {
use crate::widget::capability::{RecordList, RecordValue, EMPTY_RECORD_SCHEMA};
let mut chip = crate::widget::special_widgets::chip::Chip::new(Rect::new(0, 0, 200, 36));
assert_eq!(
apply_widget_property(
&mut chip,
"items",
&serde_json::json!([{"id": "a", "label": "Alpha"}])
),
ApplyOutcome::Applied
);
assert_eq!(chip.items().len(), 1);
assert_eq!(chip.items()[0].id, "a");
let mut order = RecordList::new(&EMPTY_RECORD_SCHEMA);
order.push(
RecordValue::new()
.with("id", CapabilityValue::String(String::from("x")))
.with("label", CapabilityValue::String(String::from("X"))),
);
assert_eq!(
order.validate_against(&crate::widget::special_widgets::chip::CHIP_ITEM_SCHEMA),
Ok(())
);
assert_eq!(
apply_widget_property(&mut chip, "items", &serde_json::json!([{"id": "b"}])),
ApplyOutcome::Rejected
);
assert_eq!(chip.items().len(), 1, "a refused list must not replace the live items");
assert_eq!(chip.items()[0].id, "a");
}
#[test]
fn only_a_record_list_property_claims_a_loader_owned_key() {
let chip = crate::widget::special_widgets::chip::Chip::new(Rect::new(0, 0, 200, 36));
assert!(declares_record_list(&chip, "items"));
let combo = crate::widget::ComboBox::new(Rect::new(0, 0, 200, 36));
assert!(!declares_record_list(&combo, "items"));
assert!(!declares_record_list(&chip, "children"));
assert!(!declares_record_list(&chip, "not_a_property"));
}
#[test]
fn to_capability_value_honours_declared_kind() {
let json = serde_json::json!(50);
assert_eq!(
to_capability_value(&json, Some(PropertyValueKind::Int)),
Some(CapabilityValue::Int(50))
);
assert_eq!(
to_capability_value(&json, Some(PropertyValueKind::UInt)),
Some(CapabilityValue::UInt(50))
);
assert_eq!(to_capability_value(&json, Some(PropertyValueKind::String)), None);
let quoted = serde_json::json!("50");
assert_eq!(to_capability_value("ed, Some(PropertyValueKind::Int)), None);
}
#[test]
fn to_capability_value_number_keeps_the_json_shape() {
assert_eq!(
to_capability_value(&serde_json::json!(3), Some(PropertyValueKind::Number)),
Some(CapabilityValue::Int(3)),
"a whole number must stay an Int, so integer callers are unaffected"
);
assert_eq!(
to_capability_value(&serde_json::json!(3.5), Some(PropertyValueKind::Number)),
Some(CapabilityValue::Float(3.5))
);
assert_eq!(
to_capability_value(&serde_json::json!(3.0), Some(PropertyValueKind::Number)),
Some(CapabilityValue::Int(3))
);
assert_eq!(
to_capability_value(&serde_json::json!("3"), Some(PropertyValueKind::Number)),
None
);
}
#[test]
fn to_capability_value_without_schema_prefers_unsigned_for_counts() {
assert_eq!(
to_capability_value(&serde_json::json!(7), None),
Some(CapabilityValue::UInt(7))
);
assert_eq!(
to_capability_value(&serde_json::json!(-7), None),
Some(CapabilityValue::Int(-7))
);
assert_eq!(
to_capability_value(&serde_json::json!(7.5), None),
Some(CapabilityValue::Float(7.5))
);
assert_eq!(to_capability_value(&serde_json::json!([1, 2]), None), None);
assert_eq!(to_capability_value(&serde_json::json!({"a": 1}), None), None);
}
#[test]
fn button_publishes_text_and_accepts_a_write_by_name() {
let mut button = Button::new("before".to_string(), Rect::new(0, 0, 10, 10));
assert!(is_widget_property(&button, "text"));
assert!(is_widget_property(&button, "Text"), "lookup is case-insensitive");
assert!(!is_widget_property(&button, "no_such_property"));
let outcome = apply_widget_property(&mut button, "text", &serde_json::json!("after"));
assert_eq!(outcome, ApplyOutcome::Applied);
assert_eq!(button.text(), "after");
}
#[test]
fn unknown_name_is_reported_as_not_a_property() {
let mut button = Button::new("x".to_string(), Rect::new(0, 0, 10, 10));
assert_eq!(
apply_widget_property(&mut button, "colour", &serde_json::json!("#ff0000")),
ApplyOutcome::NotAProperty
);
}
#[test]
fn wrong_json_type_is_rejected_not_ignored() {
let mut button = Button::new("x".to_string(), Rect::new(0, 0, 10, 10));
assert_eq!(
apply_widget_property(&mut button, "enabled", &serde_json::json!("yes")),
ApplyOutcome::Rejected
);
assert_eq!(
apply_widget_property(&mut button, "enabled", &serde_json::json!(false)),
ApplyOutcome::Applied
);
}
#[test]
fn resolve_property_name_returns_the_published_spelling() {
let button = Button::new("x".to_string(), Rect::new(0, 0, 10, 10));
assert_eq!(resolve_property_name(&button, "Text"), Some("text"));
assert_eq!(resolve_property_name(&button, "not_there"), None);
}
#[test]
fn a_constraint_violation_is_rejected_not_applied() {
use crate::widget::capability::WidgetFactory;
let factory = WidgetFactory::new_with_defaults();
let Some(mut circle) = factory.create("progress_circle", Rect::new(0, 0, 64, 64), "")
else {
return;
};
assert_eq!(
apply_widget_property(circle.as_mut(), "value", &serde_json::json!(1.5)),
ApplyOutcome::Rejected,
"the loader must refuse a value the schema's bound excludes"
);
assert_eq!(
apply_widget_property(circle.as_mut(), "value", &serde_json::json!(1.0)),
ApplyOutcome::Applied
);
assert_eq!(
apply_widget_property(circle.as_mut(), "value", &serde_json::json!(0.25)),
ApplyOutcome::Applied
);
}
}