use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Default, Deserialize)]
struct DefaultFontShape {
#[serde(default)]
family: Option<String>,
}
pub fn parse_iced_settings(settings: &Value) -> iced::Settings {
let antialiasing = settings
.get("antialiasing")
.and_then(Value::as_bool)
.unwrap_or(false);
let vsync = settings
.get("vsync")
.and_then(Value::as_bool)
.unwrap_or(true);
let default_text_size = settings
.get("default_text_size")
.and_then(Value::as_f64)
.map(|s| iced::Pixels(s as f32));
let default_font = settings
.get("default_font")
.cloned()
.and_then(|v| serde_json::from_value::<DefaultFontShape>(v).ok())
.map(|font| match font.family.as_deref() {
Some("monospace") => iced::Font::MONOSPACE,
_ => iced::Font::DEFAULT,
});
let mut iced_settings = iced::Settings {
antialiasing,
vsync,
..Default::default()
};
if let Some(size) = default_text_size {
iced_settings.default_text_size = size;
}
if let Some(font) = default_font {
iced_settings.default_font = font;
}
iced_settings
}
pub fn validate_required_widgets(settings: &Value, native_widgets: &[&str]) {
let Some(required) = settings.get("required_widgets").and_then(|v| v.as_array()) else {
return;
};
if required.is_empty() {
return;
}
let builtin = plushie_widget_sdk::runtime::IcedWidgetSet::type_names();
let mut known: std::collections::HashSet<&str> = builtin.iter().map(|s| s.as_ref()).collect();
known.extend(native_widgets.iter().copied());
let mut missing = Vec::new();
for item in required {
if let Some(name) = item.as_str()
&& !known.contains(name)
{
missing.push(name.to_string());
}
}
if !missing.is_empty() {
plushie_widget_sdk::diagnostics::warn(plushie_core::Diagnostic::RequiredWidgetsMissing {
missing,
});
}
}
pub fn apply_validate_props(settings: &Value) -> bool {
let requested = settings
.get("validate_props")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if requested {
plushie_widget_sdk::runtime::set_validate_props(true);
log::info!("prop validation enabled via settings");
}
requested
}
pub fn decode_font_data(value: &Value) -> Option<Vec<u8>> {
match value {
Value::String(s) => {
use base64::Engine;
base64::engine::general_purpose::STANDARD.decode(s).ok()
}
Value::Array(arr) => {
let bytes: Vec<u8> = arr
.iter()
.filter_map(|v| v.as_u64().and_then(|n| u8::try_from(n).ok()))
.collect();
if bytes.len() == arr.len() {
Some(bytes)
} else {
None
}
}
_ => None,
}
}
pub fn parse_inline_fonts(settings: &Value) -> Vec<Vec<u8>> {
use std::sync::atomic::Ordering;
let Some(fonts) = settings.get("fonts").and_then(|v| v.as_array()) else {
return Vec::new();
};
let mut decoded = Vec::new();
for font_val in fonts {
if let Some(obj) = font_val.as_object()
&& let Some(data_val) = obj.get("data")
{
match decode_font_data(data_val) {
Some(bytes) if bytes.is_empty() => {
log::warn!("fonts: empty inline font data, skipping");
}
Some(bytes) => {
log::info!("loaded inline font ({} bytes)", bytes.len());
decoded.push(bytes);
}
None => {
log::warn!("fonts: failed to decode inline font data");
}
}
}
}
let requested = decoded.len() as u32;
if requested == 0 {
return decoded;
}
let max = crate::constants::MAX_LOADED_FONTS;
let counter = &crate::constants::LOADED_FONT_COUNT;
let mut current = counter.load(Ordering::Relaxed);
let granted = loop {
let budget = max.saturating_sub(current);
let grant = requested.min(budget);
if grant == 0 {
break 0;
}
match counter.compare_exchange(
current,
current + grant,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => break grant,
Err(actual) => current = actual,
}
};
if granted < requested {
let dropped = requested - granted;
plushie_widget_sdk::diagnostics::warn(plushie_core::Diagnostic::FontCapExceeded {
max,
requested,
granted,
dropped,
});
decoded.truncate(granted as usize);
}
decoded
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use std::sync::atomic::Ordering;
static FONT_TEST_LOCK: Mutex<()> = Mutex::new(());
fn reset_font_counter() {
crate::constants::LOADED_FONT_COUNT.store(0, Ordering::Relaxed);
}
#[test]
fn inline_fonts_under_cap_pass_through() {
let _guard = FONT_TEST_LOCK.lock().unwrap();
reset_font_counter();
let b64 = base64::engine::general_purpose::STANDARD.encode(b"font-bytes");
let settings = serde_json::json!({
"fonts": [
{"data": b64.clone()},
{"data": b64.clone()},
{"data": b64},
]
});
let out = parse_inline_fonts(&settings);
assert_eq!(out.len(), 3);
assert_eq!(
crate::constants::LOADED_FONT_COUNT.load(Ordering::Relaxed),
3
);
}
#[test]
fn inline_fonts_past_cap_are_dropped_with_diagnostic() {
let _guard = FONT_TEST_LOCK.lock().unwrap();
reset_font_counter();
let max = crate::constants::MAX_LOADED_FONTS;
crate::constants::LOADED_FONT_COUNT.store(max - 2, Ordering::Relaxed);
let b64 = base64::engine::general_purpose::STANDARD.encode(b"font-bytes");
let mut fonts = Vec::new();
for _ in 0..10 {
fonts.push(serde_json::json!({"data": b64.clone()}));
}
let settings = serde_json::json!({"fonts": fonts});
let out = parse_inline_fonts(&settings);
assert_eq!(out.len(), 2, "only 2 slots remained under the cap");
assert_eq!(
crate::constants::LOADED_FONT_COUNT.load(Ordering::Relaxed),
max
);
}
use base64::Engine;
#[test]
fn apply_validate_props_returns_false_when_unset() {
let settings = serde_json::json!({});
assert!(!apply_validate_props(&settings));
}
#[test]
fn apply_validate_props_returns_false_when_explicitly_false() {
let settings = serde_json::json!({"validate_props": false});
assert!(!apply_validate_props(&settings));
}
#[test]
fn apply_validate_props_returns_true_when_requested() {
let settings = serde_json::json!({"validate_props": true});
assert!(apply_validate_props(&settings));
}
}