diff --git a/Cargo.lock b/Cargo.lock index 9644bba..8167654 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5021,6 +5021,7 @@ dependencies = [ "palette", "rfd", "rustmotion", + "schemars", "serde_json", "tokio", ] diff --git a/crates/rustmotion-html/src/lib.rs b/crates/rustmotion-html/src/lib.rs index 0ac625e..dcfd7eb 100644 --- a/crates/rustmotion-html/src/lib.rs +++ b/crates/rustmotion-html/src/lib.rs @@ -258,6 +258,82 @@ pub fn set_text_content(html: &str, pointer: &str, text: &str) -> Option Some(serialize_element(&root)) } +/// Set or replace a plain attribute on the element addressed by the JSON +/// pointer (into the transpiled scenario), returning the rewritten HTML. Used +/// by the studio inspector for component root fields (``). +/// Attributes are strings; the transpiler's coercion re-types them on load. +/// An EMPTY `value` REMOVES the attribute (unset, not empty-string — the +/// transpiler skips empty attributes anyway). `anim`, `style` and every other +/// attribute are preserved (mirrors [`set_inline_style`]). Returns `None` if +/// the pointer doesn't resolve to an element. +pub fn set_attribute(html: &str, pointer: &str, name: &str, value: &str) -> Option { + let dom = parse_fragment_dom(html); + let root = find_element(&dom.document, "rustmotion")?; + let target = resolve_pointer(&root, pointer)?; + set_attr(&target, name, value)?; + Some(serialize_element(&root)) +} + +/// Remove one inline `style` property from the element addressed by the JSON +/// pointer, returning the rewritten HTML (an emptied inspector control unsets +/// the declaration). Other declarations and attributes are preserved. Returns +/// `None` if the pointer doesn't resolve to an element. +pub fn remove_inline_style(html: &str, pointer: &str, prop: &str) -> Option { + let dom = parse_fragment_dom(html); + let root = find_element(&dom.document, "rustmotion")?; + let target = resolve_pointer(&root, pointer)?; + remove_style_decl(&target, prop)?; + Some(serialize_element(&root)) +} + +/// Upsert (or, for an empty value, remove) a plain attribute on an element. +fn set_attr(handle: &Handle, name: &str, value: &str) -> Option<()> { + let NodeData::Element { attrs, .. } = &handle.data else { + return None; + }; + let mut attrs = attrs.borrow_mut(); + if value.is_empty() { + attrs.retain(|a| a.name.local.as_ref() != name); + return Some(()); + } + if let Some(a) = attrs.iter_mut().find(|a| a.name.local.as_ref() == name) { + a.value = value.into(); + } else { + attrs.push(Attribute { + name: QualName::new(None, ns!(), name.into()), + value: value.into(), + }); + } + Some(()) +} + +/// Drop one `prop: value` declaration from an element's `style` attribute. +fn remove_style_decl(handle: &Handle, prop: &str) -> Option<()> { + let NodeData::Element { attrs, .. } = &handle.data else { + return None; + }; + let mut attrs = attrs.borrow_mut(); + let Some(a) = attrs.iter_mut().find(|a| a.name.local.as_ref() == "style") else { + return Some(()); // no style attribute → nothing to remove + }; + let kept: Vec = a + .value + .split(';') + .filter_map(|decl| { + let decl = decl.trim(); + let (k, v) = decl.split_once(':')?; + let k = k.trim(); + if k == prop || k.is_empty() { + None + } else { + Some(format!("{k}:{}", v.trim())) + } + }) + .collect(); + a.value = kept.join("; ").as_str().into(); + Some(()) +} + /// Replace an element's children with a single text node. fn set_text(handle: &Handle, text: &str) -> Option<()> { if !matches!(handle.data, NodeData::Element { .. }) { @@ -448,6 +524,51 @@ mod lib_tests { assert!(crate::html_to_scenario_value("
no root
").is_err()); } + // --- set_attribute (studio schema inspector) --- + + #[test] + fn set_attribute_updates_counter_from_and_retypes_on_transpile() { + let html = r##""##; + let out = crate::set_attribute(html, "/scenes/0/children/0", "from", "250").unwrap(); + let v = crate::html_to_scenario_value(&out).unwrap(); + let child = &v["scenes"][0]["children"][0]; + // The attribute string is re-typed to a number by the transpiler. + assert_eq!(child["from"], json!(250)); + assert!(child["from"].is_number()); + // Other attributes are preserved: to, anim (→ style.animation), style. + assert_eq!(child["to"], json!(100)); + assert_eq!(child["style"]["font-size"], json!(64)); + assert_eq!(child["style"]["color"], json!("#fff")); + assert_eq!(child["style"]["animation"][0]["name"], json!("fade_in")); + } + + #[test] + fn set_attribute_inserts_when_absent() { + let html = r##""##; + let out = crate::set_attribute(html, "/scenes/0/children/0", "suffix", "%").unwrap(); + let v = crate::html_to_scenario_value(&out).unwrap(); + assert_eq!(v["scenes"][0]["children"][0]["suffix"], json!("%")); + } + + #[test] + fn set_attribute_empty_value_removes_the_attribute() { + let html = r##""##; + let out = crate::set_attribute(html, "/scenes/0/children/0", "suffix", "").unwrap(); + assert!(!out.contains("suffix"), "attribute removed: {out}"); + let v = crate::html_to_scenario_value(&out).unwrap(); + assert!(v["scenes"][0]["children"][0].get("suffix").is_none()); + } + + #[test] + fn remove_inline_style_drops_only_that_declaration() { + let html = r##"

Hi

"##; + let out = crate::remove_inline_style(html, "/scenes/0/children/0", "color").unwrap(); + let v = crate::html_to_scenario_value(&out).unwrap(); + let style = &v["scenes"][0]["children"][0]["style"]; + assert!(style.get("color").is_none(), "color removed"); + assert_eq!(style["font-size"], json!(96), "other declarations kept"); + } + // --- anim round-trip (studio) --- #[test] diff --git a/crates/rustmotion-studio/Cargo.toml b/crates/rustmotion-studio/Cargo.toml index 8695c2f..63698f5 100644 --- a/crates/rustmotion-studio/Cargo.toml +++ b/crates/rustmotion-studio/Cargo.toml @@ -24,3 +24,4 @@ dioxus-icons = { version = "0.1.0", default-features = false } rfd = "0.17" dirs = "6.0.0" palette = { version = "0.7.6", default-features = false } +schemars = "0.8" diff --git a/crates/rustmotion-studio/src/editor/inspector.rs b/crates/rustmotion-studio/src/editor/inspector.rs index 0f3cfc3..f292d1e 100644 --- a/crates/rustmotion-studio/src/editor/inspector.rs +++ b/crates/rustmotion-studio/src/editor/inspector.rs @@ -12,9 +12,12 @@ use crate::components::color_picker::ColorPicker; use crate::components::select::{Select, SelectOption}; use crate::components::slider::Slider; use crate::components::switch::Switch; -use crate::scenario::{set_field, set_style, Shared}; +use crate::scenario::{set_field, set_field_value, set_style, set_style_value, Shared}; use super::annotations::AnnotationBox; +use super::properties::{ + component_props, css_family, css_section_props, slider_range, visible_sections, PropKind, +}; // ── Debounce context ───────────────────────────────────────────────────────── @@ -573,10 +576,12 @@ fn num_display(value: &str, step: f64) -> String { // ── Panel ──────────────────────────────────────────────────────────────────── -/// The right-hand inspector for the selected element: header, the content -/// editor (text family only), the per-type style sections, and the -/// "comment for the agent" box. Driven by frame-independent values, so it stays -/// memoized and doesn't re-render on playback. +/// The right-hand inspector for the selected element: header, the schema-driven +/// Properties section (component root fields), the content editor (text family +/// only), the curated per-type style sections, the generic (schema-complete) +/// CSS sections, and the "comment for the agent" box. Driven by +/// frame-independent values, so it stays memoized and doesn't re-render on +/// playback. #[component] pub fn InspectorPanel( selected: Signal>, @@ -585,6 +590,7 @@ pub fn InspectorPanel( current: Signal, content: Option, style: serde_json::Value, + element: serde_json::Value, ) -> Element { // Provide the debounce handle so all child write helpers share one slot. use_context_provider(|| WriteDebounce(Rc::new(RefCell::new(None)))); @@ -606,6 +612,7 @@ pub fn InspectorPanel( "✕" } } + RootPropsSection { pointer: pointer.clone(), kind: kind.clone(), element } if fam == Family::Text { ContentEditor { pointer: pointer.clone(), content: content.clone().unwrap_or_default() } } @@ -617,11 +624,355 @@ pub fn InspectorPanel( style: style.clone(), } } + GenericCssSections { pointer: pointer.clone(), kind: kind.clone(), style: style.clone() } AnnotationBox { pointer, kind, current } } } } +/// Names already handled by the curated controls for a family — the generic +/// sections skip these so no property appears twice. +fn curated_names(fam: Family) -> std::collections::BTreeSet<&'static str> { + let mut set = std::collections::BTreeSet::new(); + for section in sections(fam) { + for field in section.fields { + set.insert(field.name); + if matches!(field.ctrl, Ctrl::StyleToggles) { + set.insert("font-weight"); + set.insert("font-style"); + } + } + } + set +} + +/// Schema-driven "Properties" section: the component's editable root fields +/// (counter `from`/`to`/`decimals`…, badge `label`…), typed writes. `content` +/// is skipped for the text family (the Content editor owns it). +#[component] +fn RootPropsSection(pointer: String, kind: String, element: serde_json::Value) -> Element { + let Some(props) = component_props(&kind) else { + return rsx! {}; + }; + let skip_content = family(&kind) == Family::Text; + let rows: Vec<_> = props + .iter() + .filter(|p| !(skip_content && p.name == "content")) + .collect(); + if rows.is_empty() { + return rsx! {}; + } + rsx! { + CollapsibleSection { title: "Properties".to_string(), start_open: true, + for spec in rows { + GenericRow { + key: "{pointer}-root-{spec.name}", + pointer: pointer.clone(), + name: spec.name.clone(), + prop_kind: spec.kind.clone(), + value: prop_str(&element, &spec.name), + is_style: false, + } + } + } + } +} + +/// The schema-complete CSS sections for the component's family, minus every +/// property the curated controls already expose. Collapsed by default. +#[component] +fn GenericCssSections(pointer: String, kind: String, style: serde_json::Value) -> Element { + let curated = curated_names(family(&kind)); + let sections_for_family = visible_sections(css_family(&kind)); + rsx! { + for section in sections_for_family { + { + let props: Vec<_> = css_section_props(section) + .into_iter() + .filter(|p| !curated.contains(p.name.as_str())) + .collect(); + rsx! { + if !props.is_empty() { + CollapsibleSection { + key: "{pointer}-{section.label()}", + title: section.label().to_string(), + start_open: false, + for spec in props { + GenericRow { + key: "{pointer}-css-{spec.name}", + pointer: pointer.clone(), + name: spec.name.clone(), + prop_kind: spec.kind.clone(), + value: prop_str(&style, &spec.name), + is_style: true, + } + } + } + } + } + } + } + } +} + +/// A section with a clickable header that folds its rows. +#[component] +fn CollapsibleSection(title: String, start_open: bool, children: Element) -> Element { + let mut open = use_signal(|| start_open); + let chevron = if open() { "▾" } else { "▸" }; + rsx! { + div { style: "display:flex; flex-direction:column; gap:8px; padding:12px 14px; border-top:1px solid var(--rm-border);", + div { + style: "{SECTION_HEADER} cursor:pointer; user-select:none;", + onclick: move |_| open.set(!open()), + "{chevron} {title}" + } + if open() { + {children} + } + } + } +} + +/// Parse a generic control's text into the TYPED JSON value written to a root +/// field. Empty → `Null` (remove). Numbers stay numbers (integral floats +/// become JSON integers so `u8`-style fields deserialize), bools bools, +/// Complex must be valid JSON. +fn parse_root_value(kind: &PropKind, text: &str) -> Result { + let t = text.trim(); + if t.is_empty() { + return Ok(serde_json::Value::Null); + } + Ok(match kind { + PropKind::Number => { + let f: f64 = t.parse().map_err(|_| ())?; + if f.fract() == 0.0 && f.abs() < 9_007_199_254_740_992.0 { + serde_json::Value::from(f as i64) + } else { + serde_json::Value::from(f) + } + } + PropKind::Bool => serde_json::Value::Bool(t == "true"), + PropKind::Complex => serde_json::from_str(t).map_err(|_| ())?, + _ => serde_json::Value::String(text.to_string()), + }) +} + +/// One generic (schema-driven) property row. `is_style` picks the write path: +/// style properties write CSS strings (an emptied control REMOVES the +/// declaration); root fields write typed JSON (empty removes the field / +/// HTML attribute). +#[component] +fn GenericRow( + pointer: String, + name: String, + prop_kind: PropKind, + value: String, + is_style: bool, +) -> Element { + let shared = use_context::(); + + // Single write route shared by every control variant. + let commit: Rc = { + let shared = shared.clone(); + let p = pointer.clone(); + let n = name.clone(); + let k = prop_kind.clone(); + Rc::new(move |text: &str| { + if is_style { + if text.trim().is_empty() { + write_style_removal(&shared, &p, &n); + } else { + write_prop(&shared, &p, &n, text); + } + } else if let Ok(v) = parse_root_value(&k, text) { + write_root_field(&shared, &p, &n, v); + } + }) + }; + + let control = match prop_kind.clone() { + PropKind::Bool => { + let commit = commit.clone(); + rsx! { + Switch { + default_checked: value == "true", + on_checked_change: move |checked: bool| { + commit(if checked { "true" } else { "false" }) + }, + } + } + } + PropKind::Enum(variants) => { + let mut items = variants; + if !value.is_empty() && !items.iter().any(|o| o == &value) { + items.insert(0, value.clone()); + } + let options = items.iter().enumerate().map(|(i, opt)| { + rsx! { + SelectOption:: { key: "{opt}", index: i, value: opt.clone(), text_value: "{opt}", "{opt}" } + } + }); + let commit = commit.clone(); + rsx! { + Select:: { + width: "100%", + default_value: if value.is_empty() { None } else { Some(value.clone()) }, + on_value_change: move |v: Option| { + if let Some(v) = v { + commit(&v); + } + }, + {options} + } + } + } + PropKind::Color => { + let mut color = use_signal(|| parse_hsv(&value)); + let pick_commit = commit.clone(); + let hex_commit = commit.clone(); + rsx! { + div { style: "display:flex; align-items:center; gap:6px; width:100%;", + ColorPicker { + color: color(), + on_color_change: move |c: Hsv| { + color.set(c); + pick_commit(&hsv_to_hex(c)); + }, + } + input { + r#type: "text", + style: "{HEX_STYLE}", + value: "{value}", + oninput: move |e: FormEvent| { + let v = e.value(); + if v.trim_start_matches('#').len() >= 6 { + color.set(parse_hsv(&v)); + } + hex_commit(&v); + }, + } + } + } + } + PropKind::Number => { + if let Some((min, max, step)) = slider_range(&name) { + let mut num = use_signal(|| parse_num(&value).unwrap_or(min)); + let mut txt = use_signal(|| num_display(&value, step)); + let slide_commit = commit.clone(); + let type_commit = commit.clone(); + rsx! { + div { style: "display:flex; align-items:center; gap:8px; width:100%;", + div { style: "flex:1; min-width:0;", + Slider { + value: Some(num()), + min, + max, + step, + on_value_change: move |v: f64| { + num.set(v); + txt.set(fmt_num(v, step)); + slide_commit(&fmt_num(v, step)); + }, + } + } + input { + r#type: "text", + style: "{NUM_STYLE}", + value: "{txt}", + oninput: move |e: FormEvent| { + let raw = e.value(); + txt.set(raw.clone()); + if let Some(v) = parse_num(&raw) { + num.set(v); + } + type_commit(&raw); + }, + } + } + } + } else { + let commit = commit.clone(); + rsx! { + input { + r#type: "text", + style: "{INPUT_STYLE}", + value: "{value}", + oninput: move |e: FormEvent| commit(&e.value()), + } + } + } + } + PropKind::Unit | PropKind::String => { + let commit = commit.clone(); + rsx! { + input { + r#type: "text", + style: "{INPUT_STYLE}", + value: "{value}", + oninput: move |e: FormEvent| commit(&e.value()), + } + } + } + PropKind::Complex => { + let commit = commit.clone(); + rsx! { + JsonArea { + initial: value.clone(), + on_commit: move |text: String| commit(&text), + } + } + } + }; + + rsx! { + div { style: "display:flex; align-items:center; gap:8px; min-height:26px;", + span { + style: "width:76px; flex:none; color:var(--rm-text-muted); font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;", + title: "{name}", + "{name}" + } + div { style: "flex:1; min-width:0; display:flex; justify-content:flex-end;", {control} } + } + } +} + +/// Folded JSON editor for Complex values: parse-checked on blur; invalid input +/// shows an error border and never writes. +#[component] +fn JsonArea(initial: String, on_commit: EventHandler) -> Element { + let mut txt = use_signal(|| initial.clone()); + let mut bad = use_signal(|| false); + let border = if bad() { + "var(--rm-error)" + } else { + "var(--rm-border-2)" + }; + rsx! { + textarea { + style: "width:100%; box-sizing:border-box; min-height:44px; resize:vertical; padding:6px 8px; background:var(--rm-bg); color:var(--rm-text); border:1px solid {border}; border-radius:6px; font-size:11px; font-family:monospace;", + value: "{txt}", + oninput: move |e: FormEvent| { + txt.set(e.value()); + bad.set(false); + }, + onblur: move |_| { + let t = txt(); + let trimmed = t.trim().to_string(); + if trimmed.is_empty() { + bad.set(false); + on_commit.call(String::new()); + } else if serde_json::from_str::(&trimmed).is_ok() { + bad.set(false); + on_commit.call(trimmed); + } else { + bad.set(true); + } + }, + } + } +} + /// Editable text content for text-bearing elements (writes the `content` field). #[component] fn ContentEditor(pointer: String, content: String) -> Element { @@ -970,7 +1321,7 @@ enum WritePayload { path: std::path::PathBuf, raw: serde_json::Value, pointer: String, - prop: &'static str, + prop: String, value: String, }, Content { @@ -979,16 +1330,45 @@ enum WritePayload { pointer: String, text: String, }, + /// Typed root-field write (`Value::Null` removes the field / attribute). + RootField { + path: std::path::PathBuf, + raw: serde_json::Value, + pointer: String, + field: String, + value: serde_json::Value, + }, + /// Remove one style property (emptied generic control). + StyleRemove { + path: std::path::PathBuf, + raw: serde_json::Value, + pointer: String, + prop: String, + }, } impl WritePayload { fn path(&self) -> &std::path::Path { match self { - WritePayload::Prop { path, .. } | WritePayload::Content { path, .. } => path, + WritePayload::Prop { path, .. } + | WritePayload::Content { path, .. } + | WritePayload::RootField { path, .. } + | WritePayload::StyleRemove { path, .. } => path, } } } +/// A root-field JSON value as an HTML attribute string. `Null` → empty (which +/// [`rustmotion::loader::set_html_attribute`] treats as "remove"); complex +/// values are compact JSON (attributes are strings; the transpiler coerces). +fn root_value_to_attr(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Null => String::new(), + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + } +} + /// Schedule a debounced disk write (~250 ms). Any previously scheduled write is /// cancelled first so only the last value in a burst reaches the disk. /// @@ -1093,14 +1473,63 @@ fn perform_write(payload: &WritePayload) -> Result { } Ok(false) } + WritePayload::RootField { + path, + raw, + pointer, + field, + value, + } => { + if rustmotion::loader::is_html_path(path) { + let html = std::fs::read_to_string(path).map_err(|e| format!("read: {e}"))?; + let attr = root_value_to_attr(value); + if let Some(updated) = + rustmotion::loader::set_html_attribute(&html, pointer, field, &attr) + { + std::fs::write(path, updated).map_err(|e| format!("write: {e}"))?; + return Ok(true); + } + } else if let Some(updated) = + set_field_value(raw.clone(), pointer, field, value.clone()) + { + let s = serde_json::to_string_pretty(&updated).map_err(|e| format!("json: {e}"))?; + std::fs::write(path, s).map_err(|e| format!("write: {e}"))?; + return Ok(true); + } + Ok(false) + } + WritePayload::StyleRemove { + path, + raw, + pointer, + prop, + } => { + if rustmotion::loader::is_html_path(path) { + let html = std::fs::read_to_string(path).map_err(|e| format!("read: {e}"))?; + if let Some(updated) = + rustmotion::loader::remove_html_inline_style(&html, pointer, prop) + { + std::fs::write(path, updated).map_err(|e| format!("write: {e}"))?; + return Ok(true); + } + } else if let Some(updated) = + set_style_value(raw.clone(), pointer, prop, serde_json::Value::Null) + { + let s = serde_json::to_string_pretty(&updated).map_err(|e| format!("json: {e}"))?; + std::fs::write(path, s).map_err(|e| format!("write: {e}"))?; + return Ok(true); + } + Ok(false) + } } } /// Write a single style property back to the scenario file. Schedules a debounced /// write (~250 ms) so rapid slider drags and keystrokes coalesce into one flush. /// Empty values are ignored so clearing a field mid-edit doesn't collapse the -/// element. Write errors are stored in the model and surfaced in the topbar. -fn write_prop(shared: &Shared, pointer: &str, prop: &'static str, value: &str) { +/// element (generic controls route empties through [`write_style_removal`] +/// instead). Write errors are stored in the model and surfaced in the topbar. +fn write_prop(shared: &Shared, pointer: &str, prop: &str, value: &str) { if value.trim().is_empty() { return; } @@ -1119,12 +1548,60 @@ fn write_prop(shared: &Shared, pointer: &str, prop: &'static str, value: &str) { path, raw, pointer: pointer.to_string(), - prop, + prop: prop.to_string(), value: value.to_string(), }, ); } +/// Typed write of a component root field (schema-driven Properties section). +/// `Value::Null` removes the field (JSON) / the attribute (HTML). Debounced +/// with the same guarantees as [`write_prop`]. +fn write_root_field(shared: &Shared, pointer: &str, field: &str, value: serde_json::Value) { + let (path, raw) = { + let m = shared.lock().unwrap_or_else(|e| e.into_inner()); + (m.path.clone(), m.raw.clone()) + }; + let Some(path) = path else { + return; + }; + let debounce = consume_context::(); + schedule_write( + &debounce, + shared.clone(), + WritePayload::RootField { + path, + raw, + pointer: pointer.to_string(), + field: field.to_string(), + value, + }, + ); +} + +/// Remove one style property (an emptied generic control unsets the key / +/// declaration rather than writing an empty string). Debounced. +fn write_style_removal(shared: &Shared, pointer: &str, prop: &str) { + let (path, raw) = { + let m = shared.lock().unwrap_or_else(|e| e.into_inner()); + (m.path.clone(), m.raw.clone()) + }; + let Some(path) = path else { + return; + }; + let debounce = consume_context::(); + schedule_write( + &debounce, + shared.clone(), + WritePayload::StyleRemove { + path, + raw, + pointer: pointer.to_string(), + prop: prop.to_string(), + }, + ); +} + /// Write the element's text `content` back to the scenario file. Unlike /// [`write_prop`], an empty value is allowed (clearing the text is valid). /// Schedules a debounced write (~250 ms); errors are surfaced in the topbar. diff --git a/crates/rustmotion-studio/src/editor/mod.rs b/crates/rustmotion-studio/src/editor/mod.rs index 8b84b8c..8872daf 100644 --- a/crates/rustmotion-studio/src/editor/mod.rs +++ b/crates/rustmotion-studio/src/editor/mod.rs @@ -8,6 +8,7 @@ pub mod frames; mod inspector; mod playback; mod prefetch; +mod properties; mod topbar; mod view; diff --git a/crates/rustmotion-studio/src/editor/properties.rs b/crates/rustmotion-studio/src/editor/properties.rs new file mode 100644 index 0000000..21fd760 --- /dev/null +++ b/crates/rustmotion-studio/src/editor/properties.rs @@ -0,0 +1,611 @@ +//! Schema-driven property registry for the inspector. +//! +//! Derived lazily (OnceLock) from `schemars::schema_for!(Component)` — the +//! same source as `rustmotion validate`'s unknown-attribute check — and from +//! `schema_for!(CssStyle)` for the CSS sections. Two invariants: +//! +//! 1. Component root fields: every non-excluded property of every `oneOf` +//! variant gets a typed [`PropSpec`]; the exclusion list below is the only +//! curation. +//! 2. CSS completeness BY CONSTRUCTION: every property of the `CssStyle` +//! schema lands in exactly one [`CssSection`] — unmapped ones fall into +//! `Advanced` automatically, so schema evolution can never silently drop a +//! property from the inspector (locked by a test). + +use std::collections::BTreeMap; +use std::sync::OnceLock; + +use serde_json::Value; + +use rustmotion::components::Component; +use rustmotion::core::css::CssStyle; + +/// Root component fields NEVER shown as generic controls: +/// - `type`: the component identity, not editable. +/// - `style`: the whole CSS block (has its own sections). +/// - `children`: structural. +/// - `position`, `x`, `y`, `z-index`: `ChildComponent` wrapper fields. +/// - `animation`, `timeline`: structured animation config (dedicated tooling +/// later; a generic control would invite corruption). +/// - Structured data arrays (chart `data`, rich_text `spans`, …): no sensible +/// generic control in v1. +pub const EXCLUDED_FIELDS: &[&str] = &[ + "type", + "style", + "children", + "position", + "x", + "y", + "z-index", + "animation", + "timeline", + // structured data arrays + "data", + "spans", + "words", + "lines", + "steps", + "items", + "avatars", + "points", + "headers", + "rows", + "tags", + "keyframes", + "cells", + "columns", + "series", + "segments", + "stops", + "states", +]; + +/// How a schema field is edited by the generic control factory. +#[derive(Debug, Clone, PartialEq)] +pub enum PropKind { + Number, + Bool, + String, + /// String enum with the exact variants from the schema. + Enum(Vec), + /// String whose name contains "color" → color picker. + Color, + /// Untagged number|string (Length, LengthPercentage, Size, …) → unit input. + Unit, + /// Objects/arrays (border, box-shadow, fill, …) → JSON textarea. + Complex, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PropSpec { + pub name: String, + pub kind: PropKind, +} + +/// Root fields (name + kind) for a component tag, schema order. `None` for +/// unknown tags. +pub fn component_props(tag: &str) -> Option<&'static Vec> { + component_registry().get(tag) +} + +/// tag → root PropSpecs, from the `oneOf` variants of the Component schema +/// (same walk as the CLI's unknown-attribute check). +fn component_registry() -> &'static BTreeMap> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| { + let schema = serde_json::to_value(schemars::schema_for!(Component)) + .expect("Component schema serializes"); + let defs = schema.get("definitions").cloned().unwrap_or(Value::Null); + let mut map = BTreeMap::new(); + if let Some(one_of) = schema["oneOf"].as_array() { + for variant in one_of { + let Some(tag) = variant["properties"]["type"]["enum"][0].as_str() else { + continue; + }; + let Some(props) = variant["properties"].as_object() else { + continue; + }; + let specs: Vec = props + .iter() + .filter(|(name, _)| !EXCLUDED_FIELDS.contains(&name.as_str())) + .map(|(name, prop_schema)| PropSpec { + name: name.clone(), + kind: kind_of_schema(name, prop_schema, &defs, 0), + }) + .collect(); + map.insert(tag.to_string(), specs); + } + } + map + }) +} + +// ── Schema walking ─────────────────────────────────────────────────────────── + +/// Resolve one property schema to a [`PropKind`]. Handles `$ref` into +/// definitions, single-arm `allOf` wrappers, nullable `anyOf [T, null]`, and +/// untagged unions (`anyOf` of several arms). +fn kind_of_schema(name: &str, schema: &Value, defs: &Value, depth: u8) -> PropKind { + if depth > 8 { + return PropKind::Complex; + } + // $ref → definitions lookup. + if let Some(r) = schema.get("$ref").and_then(|r| r.as_str()) { + let key = r.rsplit('/').next().unwrap_or_default(); + return match defs.get(key) { + Some(target) => kind_of_schema(name, target, defs, depth + 1), + None => PropKind::Complex, + }; + } + // allOf: [X] wrapper (schemars uses it to attach descriptions to refs). + if let Some(all) = schema.get("allOf").and_then(|a| a.as_array()) { + if all.len() == 1 { + return kind_of_schema(name, &all[0], defs, depth + 1); + } + } + // Direct string enum. + if let Some(variants) = string_enum(schema) { + return PropKind::Enum(variants); + } + // anyOf/oneOf: drop null arms; single arm → recurse; several → union, + // flattened recursively (Size nests LengthPercentage which nests + // number|string). + for key in ["anyOf", "oneOf"] { + if let Some(arms) = schema.get(key).and_then(|a| a.as_array()) { + let non_null: Vec<&Value> = arms.iter().filter(|a| !is_null_schema(a)).collect(); + if non_null.len() == 1 { + return kind_of_schema(name, non_null[0], defs, depth + 1); + } + let mut info = UnionInfo::default(); + for arm in &non_null { + collect_union(arm, defs, depth + 1, &mut info); + } + // Decision order (see the doc comment on `UnionInfo`). + if info.has_string && (name.contains("color") || name.contains("colour")) { + return PropKind::Color; + } + if info.has_number && (info.has_string || !info.variants.is_empty()) { + return PropKind::Unit; + } + if !info.variants.is_empty() && !info.has_string && !info.has_number { + return PropKind::Enum(info.variants); + } + if info.has_string { + return PropKind::String; + } + return PropKind::Complex; + } + } + match primary_type(schema) { + Some("number") | Some("integer") => PropKind::Number, + Some("boolean") => PropKind::Bool, + Some("string") if name.contains("color") || name.contains("colour") => PropKind::Color, + Some("string") => PropKind::String, + _ => PropKind::Complex, + } +} + +/// What a (recursively flattened) union offers. Decision order in +/// `kind_of_schema`: +/// 1. string arm + color-ish name → `Color` (untagged string|rgba). +/// 2. number + (string or keywords) → `Unit` (Length/Size: free text accepts +/// numbers, "12px" and keywords alike). +/// 3. keywords only → `Enum` (an unreachable object arm like cubic-bezier is +/// accepted collateral — the picker covers the keyword variants). +/// 4. any string arm → `String` (free text is always a valid input). +/// 5. otherwise `Complex` (JSON textarea). +#[derive(Default)] +struct UnionInfo { + variants: Vec, + has_number: bool, + has_string: bool, +} + +/// Recursively flatten a union arm into `UnionInfo`. +fn collect_union(arm: &Value, defs: &Value, depth: u8, info: &mut UnionInfo) { + if depth > 8 { + return; + } + let s = resolve_arm(arm, defs, depth); + if let Some(v) = string_enum(&s) { + info.variants.extend(v); + return; + } + for key in ["anyOf", "oneOf"] { + if let Some(arms) = s.get(key).and_then(|a| a.as_array()) { + for nested in arms.iter().filter(|a| !is_null_schema(a)) { + collect_union(nested, defs, depth + 1, info); + } + return; + } + } + match primary_type(&s) { + Some("number") | Some("integer") => info.has_number = true, + Some("string") => info.has_string = true, + _ => {} + } +} + +/// Follow refs/allOf for one union arm (no kind decision). +fn resolve_arm(arm: &Value, defs: &Value, depth: u8) -> Value { + if depth > 8 { + return arm.clone(); + } + if let Some(r) = arm.get("$ref").and_then(|r| r.as_str()) { + let key = r.rsplit('/').next().unwrap_or_default(); + if let Some(target) = defs.get(key) { + return resolve_arm(target, defs, depth + 1); + } + } + if let Some(all) = arm.get("allOf").and_then(|a| a.as_array()) { + if all.len() == 1 { + return resolve_arm(&all[0], defs, depth + 1); + } + } + arm.clone() +} + +/// The string variants of `{"enum": ["a", "b"]}` schemas, if all-string. +fn string_enum(schema: &Value) -> Option> { + let arr = schema.get("enum")?.as_array()?; + let variants: Vec = arr + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect(); + if variants.len() == arr.len() && !variants.is_empty() { + Some(variants) + } else { + None + } +} + +fn is_null_schema(schema: &Value) -> bool { + schema.get("type").and_then(|t| t.as_str()) == Some("null") +} + +/// The non-null primary type of a schema (`"type": "x"` or `["x", "null"]`). +fn primary_type(schema: &Value) -> Option<&str> { + match schema.get("type") { + Some(Value::String(s)) => Some(s.as_str()), + Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str()).find(|s| *s != "null"), + _ => None, + } +} + +// ── CSS sections ───────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CssSection { + Typography, + Layout, + Sizing, + Spacing, + Position, + Visual, + Effects, + Overflow, + Advanced, +} + +impl CssSection { + pub fn label(self) -> &'static str { + match self { + CssSection::Typography => "Typography", + CssSection::Layout => "Layout", + CssSection::Sizing => "Sizing", + CssSection::Spacing => "Spacing", + CssSection::Position => "Position", + CssSection::Visual => "Visual", + CssSection::Effects => "Effects", + CssSection::Overflow => "Overflow", + CssSection::Advanced => "Advanced", + } + } +} + +/// Section for a CSS property name. Every KNOWN property is mapped explicitly; +/// anything else (including future schema additions) lands in `Advanced`. +pub fn section_for(prop: &str) -> CssSection { + use CssSection::*; + match prop { + // Typography + "font-family" | "font-size" | "font-weight" | "font-style" | "line-height" + | "letter-spacing" | "text-align" | "color" | "white-space" | "text-decoration" + | "text-shadow" => Typography, + // Layout (flex + grid) + "display" + | "flex-direction" + | "flex-wrap" + | "justify-content" + | "align-items" + | "align-self" + | "align-content" + | "gap" + | "flex-grow" + | "flex-shrink" + | "flex-basis" + | "order" + | "grid-template-columns" + | "grid-template-rows" + | "grid-column" + | "grid-row" + | "grid-auto-flow" + | "justify-items" + | "justify-self" => Layout, + // Sizing + "width" | "height" | "min-width" | "min-height" | "max-width" | "max-height" + | "aspect-ratio" | "box-sizing" => Sizing, + // Spacing + "padding" | "margin" => Spacing, + // Position + "position" | "top" | "right" | "bottom" | "left" | "z-index" => Position, + // Visual + "background" | "border" | "border-radius" | "box-shadow" | "opacity" | "mix-blend-mode" + | "visibility" | "clip-path" => Visual, + // Effects + "filter" | "backdrop-filter" | "transform" | "transform-origin" | "perspective" + | "perspective-origin" | "transition" | "audio-reactive" => Effects, + // Overflow + "overflow" | "overflow-x" | "overflow-y" | "text-overflow" | "overflow-wrap" => Overflow, + // Everything else — including future schema additions — by construction. + _ => Advanced, + } +} + +/// All `CssStyle` schema properties with kinds, in schema order. +pub fn css_props() -> &'static Vec { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| { + let schema = serde_json::to_value(schemars::schema_for!(CssStyle)) + .expect("CssStyle schema serializes"); + let defs = schema.get("definitions").cloned().unwrap_or(Value::Null); + schema["properties"] + .as_object() + .map(|props| { + props + .iter() + .map(|(name, prop_schema)| PropSpec { + name: name.clone(), + kind: kind_of_schema(name, prop_schema, &defs, 0), + }) + .collect() + }) + .unwrap_or_default() + }) +} + +/// The properties of one section, schema order. +pub fn css_section_props(section: CssSection) -> Vec<&'static PropSpec> { + css_props() + .iter() + .filter(|p| section_for(&p.name) == section) + .collect() +} + +// ── Family → visible sections ──────────────────────────────────────────────── + +/// Which CSS sections a component family gets: Typography for text-likes, +/// Layout for containers, the common trunk for everyone. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CssFamily { + TextLike, + Container, + Plain, +} + +/// Classify a component tag for CSS-section visibility (independent from the +/// inspector's curated-section `Family`). +pub fn css_family(tag: &str) -> CssFamily { + match tag { + "text" | "caption" | "gradient_text" | "rich_text" | "counter" | "kbd" | "badge" + | "marquee" | "callout" | "tooltip" | "codeblock" | "terminal" | "list" | "tag_cloud" => { + CssFamily::TextLike + } + "container" | "div" | "card" | "flex" | "grid" | "positioned" => CssFamily::Container, + _ => CssFamily::Plain, + } +} + +/// The CSS sections shown for a family, display order: the family-specific +/// section first, then the common trunk. +pub fn visible_sections(family: CssFamily) -> Vec { + use CssSection::*; + let mut out = Vec::new(); + match family { + CssFamily::TextLike => out.push(Typography), + CssFamily::Container => out.push(Layout), + CssFamily::Plain => {} + } + out.extend([ + Sizing, Spacing, Position, Visual, Effects, Overflow, Advanced, + ]); + out +} + +/// Heuristic slider ranges by property name: `(min, max, step)`. Anything not +/// listed gets a bare input. +pub fn slider_range(prop: &str) -> Option<(f64, f64, f64)> { + match prop { + "opacity" => Some((0.0, 1.0, 0.01)), + "font-size" => Some((8.0, 300.0, 1.0)), + "line-height" => Some((0.8, 3.0, 0.1)), + "letter-spacing" => Some((-5.0, 20.0, 0.5)), + "flex-grow" | "flex-shrink" => Some((0.0, 10.0, 0.1)), + "aspect-ratio" => Some((0.1, 4.0, 0.05)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn kind_of<'a>(props: &'a [PropSpec], name: &str) -> Option<&'a PropKind> { + props.iter().find(|p| p.name == name).map(|p| &p.kind) + } + + // ── Component registry ────────────────────────────────────────────── + + #[test] + fn counter_exposes_its_root_fields_with_kinds() { + let props = component_props("counter").expect("counter in schema"); + assert_eq!(kind_of(props, "from"), Some(&PropKind::Number)); + assert_eq!(kind_of(props, "to"), Some(&PropKind::Number)); + assert_eq!(kind_of(props, "decimals"), Some(&PropKind::Number)); + assert_eq!(kind_of(props, "separator"), Some(&PropKind::String)); + assert_eq!(kind_of(props, "prefix"), Some(&PropKind::String)); + assert_eq!(kind_of(props, "suffix"), Some(&PropKind::String)); + // The exact enum variants come from the schema (snake_case). + match kind_of(props, "easing") { + Some(PropKind::Enum(variants)) => { + assert!(variants.contains(&"linear".to_string()), "{variants:?}"); + assert!( + variants.contains(&"ease_in_out".to_string()), + "{variants:?}" + ); + } + other => panic!("easing should be Enum, got {other:?}"), + } + } + + #[test] + fn excluded_fields_never_appear() { + for tag in ["text", "counter", "card", "chart", "rich_text"] { + let Some(props) = component_props(tag) else { + panic!("{tag} missing from schema registry"); + }; + for excluded in EXCLUDED_FIELDS { + assert!( + !props.iter().any(|p| &p.name == excluded), + "{tag} must not expose '{excluded}'" + ); + } + } + // Spot-check the data arrays actually exist on their components and + // are excluded (chart.data, rich_text.spans). + assert!(component_props("chart").is_some()); + assert!(component_props("rich_text").is_some()); + } + + #[test] + fn unknown_tag_has_no_props() { + assert!(component_props("definitely_not_a_component").is_none()); + } + + // ── CSS bucketing: completeness by construction ───────────────────── + + #[test] + fn every_css_schema_property_is_in_exactly_one_section() { + let all = css_props(); + assert!( + all.len() > 50, + "CssStyle schema should be large: {}", + all.len() + ); + let sections = [ + CssSection::Typography, + CssSection::Layout, + CssSection::Sizing, + CssSection::Spacing, + CssSection::Position, + CssSection::Visual, + CssSection::Effects, + CssSection::Overflow, + CssSection::Advanced, + ]; + let mut seen = std::collections::BTreeMap::new(); + for s in sections { + for p in css_section_props(s) { + *seen.entry(p.name.clone()).or_insert(0usize) += 1; + } + } + for p in all { + assert_eq!( + seen.get(&p.name), + Some(&1), + "property '{}' must be in exactly one section", + p.name + ); + } + let total: usize = seen.values().sum(); + assert_eq!(total, all.len(), "no extra properties invented"); + } + + #[test] + fn css_spot_checks() { + assert_eq!(section_for("font-size"), CssSection::Typography); + assert_eq!(section_for("backdrop-filter"), CssSection::Effects); + assert_eq!(section_for("display"), CssSection::Layout); + assert_eq!(section_for("padding"), CssSection::Spacing); + assert_eq!(section_for("z-index"), CssSection::Position); + assert_eq!(section_for("box-shadow"), CssSection::Visual); + assert_eq!(section_for("overflow-wrap"), CssSection::Overflow); + assert_eq!(section_for("width"), CssSection::Sizing); + } + + #[test] + fn future_unknown_property_lands_in_advanced() { + assert_eq!(section_for("grid-magic-2030"), CssSection::Advanced); + assert_eq!(section_for("scroll-timeline"), CssSection::Advanced); + } + + #[test] + fn css_kinds_are_usable() { + let all = css_props(); + // color is a Color control, opacity a Number, display an Enum, + // width a Unit (untagged number|string), box-shadow Complex. + assert_eq!(kind_of(all, "color"), Some(&PropKind::Color)); + assert_eq!(kind_of(all, "opacity"), Some(&PropKind::Number)); + assert!(matches!(kind_of(all, "display"), Some(PropKind::Enum(_)))); + assert!(matches!( + kind_of(all, "width"), + Some(PropKind::Unit) | Some(PropKind::Complex) + )); + assert!(matches!( + kind_of(all, "box-shadow"), + Some(PropKind::Complex) + )); + } + + // ── Family visibility ─────────────────────────────────────────────── + + #[test] + fn counter_is_text_like_and_gets_typography() { + assert_eq!(css_family("counter"), CssFamily::TextLike); + assert!(visible_sections(CssFamily::TextLike).contains(&CssSection::Typography)); + assert!(!visible_sections(CssFamily::TextLike).contains(&CssSection::Layout)); + } + + #[test] + fn card_is_container_and_gets_layout() { + assert_eq!(css_family("card"), CssFamily::Container); + assert!(visible_sections(CssFamily::Container).contains(&CssSection::Layout)); + assert!(!visible_sections(CssFamily::Container).contains(&CssSection::Typography)); + } + + #[test] + fn plain_family_gets_common_trunk_only() { + assert_eq!(css_family("shape"), CssFamily::Plain); + let v = visible_sections(CssFamily::Plain); + assert!(!v.contains(&CssSection::Typography)); + assert!(!v.contains(&CssSection::Layout)); + for s in [ + CssSection::Sizing, + CssSection::Spacing, + CssSection::Position, + CssSection::Visual, + CssSection::Effects, + CssSection::Overflow, + CssSection::Advanced, + ] { + assert!(v.contains(&s), "{s:?} missing from common trunk"); + } + } + + #[test] + fn slider_ranges_known_and_unknown() { + assert_eq!(slider_range("opacity"), Some((0.0, 1.0, 0.01))); + assert_eq!(slider_range("font-size"), Some((8.0, 300.0, 1.0))); + assert_eq!(slider_range("background"), None); + } +} diff --git a/crates/rustmotion-studio/src/editor/view.rs b/crates/rustmotion-studio/src/editor/view.rs index 7a9d7bf..e426967 100644 --- a/crates/rustmotion-studio/src/editor/view.rs +++ b/crates/rustmotion-studio/src/editor/view.rs @@ -170,7 +170,20 @@ pub fn StudioApp(view: Signal) -> Element { let m = inspector_shared.lock().unwrap_or_else(|e| e.into_inner()); let style = read_style_object(&m.raw, &pointer); let content = read_field(&m.raw, &pointer, "content"); - (pointer, style, kind, content) + // Element root object (children stripped) for the schema-driven + // Properties section. + let element = m + .raw + .pointer(&pointer) + .cloned() + .map(|mut v| { + if let Some(o) = v.as_object_mut() { + o.remove("children"); + } + v + }) + .unwrap_or(serde_json::Value::Null); + (pointer, style, kind, content, element) }) }); @@ -287,8 +300,8 @@ pub fn StudioApp(view: Signal) -> Element { current, diff_active, } - } else if let Some((pointer, style, kind, content)) = panel { - InspectorPanel { selected, pointer, kind, current, content, style } + } else if let Some((pointer, style, kind, content, element)) = panel { + InspectorPanel { selected, pointer, kind, current, content, style, element } } } } diff --git a/crates/rustmotion-studio/src/scenario/edit.rs b/crates/rustmotion-studio/src/scenario/edit.rs index c32cf3c..e54307a 100644 --- a/crates/rustmotion-studio/src/scenario/edit.rs +++ b/crates/rustmotion-studio/src/scenario/edit.rs @@ -55,6 +55,41 @@ pub fn set_field(mut raw: Value, pointer: &str, field: &str, value: &str) -> Opt Some(raw) } +/// Typed variant of [`set_field`]: writes the JSON value as-is (a number stays +/// a number, a bool a bool). `Value::Null` REMOVES the field — an emptied +/// control unsets rather than writing an empty string. Returns the mutated +/// clone. +pub fn set_field_value(mut raw: Value, pointer: &str, field: &str, value: Value) -> Option { + let el = raw.pointer_mut(pointer)?; + let obj = el.as_object_mut()?; + if value.is_null() { + obj.remove(field); + } else { + obj.insert(field.to_string(), value); + } + Some(raw) +} + +/// Typed variant of [`set_style`]: writes the JSON value as-is into the +/// element's `style` object. `Value::Null` REMOVES the property. Returns the +/// mutated clone. +pub fn set_style_value(mut raw: Value, pointer: &str, prop: &str, value: Value) -> Option { + let el = raw.pointer_mut(pointer)?; + let obj = el.as_object_mut()?; + if value.is_null() { + if let Some(style) = obj.get_mut("style").and_then(|s| s.as_object_mut()) { + style.remove(prop); + } + return Some(raw); + } + let style = obj + .entry("style") + .or_insert_with(|| Value::Object(Default::default())); + let style_obj = style.as_object_mut()?; + style_obj.insert(prop.to_string(), value); + Some(raw) +} + /// Append an annotation object to `raw["annotations"]` (creating the array if /// absent). Returns the mutated clone. pub fn append_annotation(mut raw: Value, annotation: Value) -> Value { @@ -151,6 +186,46 @@ mod tests { ); } + #[test] + fn set_field_value_keeps_json_types_round_trip() { + let updated = set_field_value(raw(), "/scenes/0/children/0", "from", json!(250)).unwrap(); + let text = serde_json::to_string(&updated).unwrap(); + let back: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(back["scenes"][0]["children"][0]["from"], json!(250)); + assert!( + back["scenes"][0]["children"][0]["from"].is_number(), + "a number must stay a number, not a string" + ); + let updated = + set_field_value(updated, "/scenes/0/children/0", "wrap", json!(false)).unwrap(); + assert_eq!(updated["scenes"][0]["children"][0]["wrap"], json!(false)); + } + + #[test] + fn set_field_value_null_removes_the_field() { + let updated = + set_field_value(raw(), "/scenes/0/children/0", "content", Value::Null).unwrap(); + assert!( + updated["scenes"][0]["children"][0].get("content").is_none(), + "null must remove the key" + ); + } + + #[test] + fn set_style_value_null_removes_the_property() { + let updated = set_style_value(raw(), "/scenes/0/children/0", "color", Value::Null).unwrap(); + assert!(updated["scenes"][0]["children"][0]["style"] + .get("color") + .is_none()); + // Typed write keeps the number. + let updated = + set_style_value(updated, "/scenes/0/children/0", "font-size", json!(64)).unwrap(); + assert_eq!( + updated["scenes"][0]["children"][0]["style"]["font-size"], + json!(64) + ); + } + #[test] fn reads_and_sets_a_top_level_field() { assert_eq!( diff --git a/crates/rustmotion-studio/src/scenario/mod.rs b/crates/rustmotion-studio/src/scenario/mod.rs index 7bd56fb..a03b966 100644 --- a/crates/rustmotion-studio/src/scenario/mod.rs +++ b/crates/rustmotion-studio/src/scenario/mod.rs @@ -12,7 +12,7 @@ pub use baseline::{baseline_slot, get_baseline, set_baseline}; pub use diff::{diff_scenarios, ChangeKind, ElementChange}; pub use edit::{ append_annotation, list_annotations, read_field, read_style_object, remove_annotation, - set_field, set_style, + set_field, set_field_value, set_style, set_style_value, }; pub use history::{history_slot, record_edit, redo, set_saving, undo, SharedHistory}; pub use model::{empty_scenario, Shared, StudioModel}; diff --git a/crates/rustmotion/src/loader.rs b/crates/rustmotion/src/loader.rs index 9d5d781..cfd9fdf 100644 --- a/crates/rustmotion/src/loader.rs +++ b/crates/rustmotion/src/loader.rs @@ -175,6 +175,19 @@ pub fn set_html_text_content(html: &str, pointer: &str, text: &str) -> Option Option { + rustmotion_html::set_attribute(html, pointer, name, value) +} + +/// Remove one inline `style` declaration on an HTML-dialect element by JSON +/// pointer (studio inspector, emptied style control). +pub fn remove_html_inline_style(html: &str, pointer: &str, prop: &str) -> Option { + rustmotion_html::remove_inline_style(html, pointer, prop) +} + #[cfg(test)] mod html_tests { use super::*;