Skip to content

Commit 38fbe27

Browse files
authored
feat(studio): schema-driven inspector with full CSS surface and root properties (#71)
Closes #70. Schema-derived property registry with typed kinds, construction-proven section completeness, generic control factory over the existing UI kit, typed JSON+HTML write paths riding the debounced pipeline.
1 parent fb74a5e commit 38fbe27

10 files changed

Lines changed: 1327 additions & 14 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/rustmotion-html/src/lib.rs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,82 @@ pub fn set_text_content(html: &str, pointer: &str, text: &str) -> Option<String>
258258
Some(serialize_element(&root))
259259
}
260260

261+
/// Set or replace a plain attribute on the element addressed by the JSON
262+
/// pointer (into the transpiled scenario), returning the rewritten HTML. Used
263+
/// by the studio inspector for component root fields (`<rm-counter from=…>`).
264+
/// Attributes are strings; the transpiler's coercion re-types them on load.
265+
/// An EMPTY `value` REMOVES the attribute (unset, not empty-string — the
266+
/// transpiler skips empty attributes anyway). `anim`, `style` and every other
267+
/// attribute are preserved (mirrors [`set_inline_style`]). Returns `None` if
268+
/// the pointer doesn't resolve to an element.
269+
pub fn set_attribute(html: &str, pointer: &str, name: &str, value: &str) -> Option<String> {
270+
let dom = parse_fragment_dom(html);
271+
let root = find_element(&dom.document, "rustmotion")?;
272+
let target = resolve_pointer(&root, pointer)?;
273+
set_attr(&target, name, value)?;
274+
Some(serialize_element(&root))
275+
}
276+
277+
/// Remove one inline `style` property from the element addressed by the JSON
278+
/// pointer, returning the rewritten HTML (an emptied inspector control unsets
279+
/// the declaration). Other declarations and attributes are preserved. Returns
280+
/// `None` if the pointer doesn't resolve to an element.
281+
pub fn remove_inline_style(html: &str, pointer: &str, prop: &str) -> Option<String> {
282+
let dom = parse_fragment_dom(html);
283+
let root = find_element(&dom.document, "rustmotion")?;
284+
let target = resolve_pointer(&root, pointer)?;
285+
remove_style_decl(&target, prop)?;
286+
Some(serialize_element(&root))
287+
}
288+
289+
/// Upsert (or, for an empty value, remove) a plain attribute on an element.
290+
fn set_attr(handle: &Handle, name: &str, value: &str) -> Option<()> {
291+
let NodeData::Element { attrs, .. } = &handle.data else {
292+
return None;
293+
};
294+
let mut attrs = attrs.borrow_mut();
295+
if value.is_empty() {
296+
attrs.retain(|a| a.name.local.as_ref() != name);
297+
return Some(());
298+
}
299+
if let Some(a) = attrs.iter_mut().find(|a| a.name.local.as_ref() == name) {
300+
a.value = value.into();
301+
} else {
302+
attrs.push(Attribute {
303+
name: QualName::new(None, ns!(), name.into()),
304+
value: value.into(),
305+
});
306+
}
307+
Some(())
308+
}
309+
310+
/// Drop one `prop: value` declaration from an element's `style` attribute.
311+
fn remove_style_decl(handle: &Handle, prop: &str) -> Option<()> {
312+
let NodeData::Element { attrs, .. } = &handle.data else {
313+
return None;
314+
};
315+
let mut attrs = attrs.borrow_mut();
316+
let Some(a) = attrs.iter_mut().find(|a| a.name.local.as_ref() == "style") else {
317+
return Some(()); // no style attribute → nothing to remove
318+
};
319+
let kept: Vec<String> = a
320+
.value
321+
.split(';')
322+
.filter_map(|decl| {
323+
let decl = decl.trim();
324+
let (k, v) = decl.split_once(':')?;
325+
let k = k.trim();
326+
if k == prop || k.is_empty() {
327+
None
328+
} else {
329+
Some(format!("{k}:{}", v.trim()))
330+
}
331+
})
332+
.collect();
333+
a.value = kept.join("; ").as_str().into();
334+
Some(())
335+
}
336+
261337
/// Replace an element's children with a single text node.
262338
fn set_text(handle: &Handle, text: &str) -> Option<()> {
263339
if !matches!(handle.data, NodeData::Element { .. }) {
@@ -448,6 +524,51 @@ mod lib_tests {
448524
assert!(crate::html_to_scenario_value("<div>no root</div>").is_err());
449525
}
450526

527+
// --- set_attribute (studio schema inspector) ---
528+
529+
#[test]
530+
fn set_attribute_updates_counter_from_and_retypes_on_transpile() {
531+
let html = r##"<rustmotion width="100" height="100"><scene duration="2"><rm-counter from="0" to="100" anim="fade-in" style="font-size:64; color:#fff"></rm-counter></scene></rustmotion>"##;
532+
let out = crate::set_attribute(html, "/scenes/0/children/0", "from", "250").unwrap();
533+
let v = crate::html_to_scenario_value(&out).unwrap();
534+
let child = &v["scenes"][0]["children"][0];
535+
// The attribute string is re-typed to a number by the transpiler.
536+
assert_eq!(child["from"], json!(250));
537+
assert!(child["from"].is_number());
538+
// Other attributes are preserved: to, anim (→ style.animation), style.
539+
assert_eq!(child["to"], json!(100));
540+
assert_eq!(child["style"]["font-size"], json!(64));
541+
assert_eq!(child["style"]["color"], json!("#fff"));
542+
assert_eq!(child["style"]["animation"][0]["name"], json!("fade_in"));
543+
}
544+
545+
#[test]
546+
fn set_attribute_inserts_when_absent() {
547+
let html = r##"<rustmotion width="100" height="100"><scene duration="2"><rm-counter from="0" to="10"></rm-counter></scene></rustmotion>"##;
548+
let out = crate::set_attribute(html, "/scenes/0/children/0", "suffix", "%").unwrap();
549+
let v = crate::html_to_scenario_value(&out).unwrap();
550+
assert_eq!(v["scenes"][0]["children"][0]["suffix"], json!("%"));
551+
}
552+
553+
#[test]
554+
fn set_attribute_empty_value_removes_the_attribute() {
555+
let html = r##"<rustmotion width="100" height="100"><scene duration="2"><rm-counter from="0" to="10" suffix="%"></rm-counter></scene></rustmotion>"##;
556+
let out = crate::set_attribute(html, "/scenes/0/children/0", "suffix", "").unwrap();
557+
assert!(!out.contains("suffix"), "attribute removed: {out}");
558+
let v = crate::html_to_scenario_value(&out).unwrap();
559+
assert!(v["scenes"][0]["children"][0].get("suffix").is_none());
560+
}
561+
562+
#[test]
563+
fn remove_inline_style_drops_only_that_declaration() {
564+
let html = r##"<rustmotion width="100" height="100"><scene duration="2"><h1 style="font-size:96; color:#fff">Hi</h1></scene></rustmotion>"##;
565+
let out = crate::remove_inline_style(html, "/scenes/0/children/0", "color").unwrap();
566+
let v = crate::html_to_scenario_value(&out).unwrap();
567+
let style = &v["scenes"][0]["children"][0]["style"];
568+
assert!(style.get("color").is_none(), "color removed");
569+
assert_eq!(style["font-size"], json!(96), "other declarations kept");
570+
}
571+
451572
// --- anim round-trip (studio) ---
452573

453574
#[test]

crates/rustmotion-studio/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ dioxus-icons = { version = "0.1.0", default-features = false }
2424
rfd = "0.17"
2525
dirs = "6.0.0"
2626
palette = { version = "0.7.6", default-features = false }
27+
schemars = "0.8"

0 commit comments

Comments
 (0)