use serde_json::Value;
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)
}
pub(crate) fn to_capability_value(
value: &Value,
declared: Option<PropertyValueKind>,
) -> 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).map(CapabilityValue::Records),
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 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) -> 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(_) => return None,
};
record = record.with(name.clone(), field_value);
}
records.push(record);
}
Some(RecordList::from_items(&crate::widget::capability::EMPTY_RECORD_SCHEMA, records))
}
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)
}
pub(crate) fn apply_widget_property(
widget: &mut dyn crate::widget::Widget,
key: &str,
value: &Value,
) -> 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(value, declared.as_ref().map(|schema| schema.value_kind))
else {
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;
}
}
match widget_property_set(widget, published, capability_value) {
Ok(()) => ApplyOutcome::Applied,
Err(_) => 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 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
);
}
}