From 2efcbb4807a24aedce0165a5636f6613d218432c Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 19 Jul 2026 13:01:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(studio):=20inspector=20round=202=20?= =?UTF-8?q?=E2=80=94=20color=20editors,=20effective=20defaults,=20timing?= =?UTF-8?q?=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-feedback iteration on the schema-driven inspector: - ColorList kind: color arrays render one picker+hex row per entry with add/remove (typed whole-array writes); Fill kind for string|gradient unions with a Single/Linear/Radial mode selector, per-stop pickers and angle; existing values detect their mode - effective-view: displayed values come from a typed serde round-trip that fills every #[serde(default)]; absent-from-raw fields carry a dimmed DEFAULT marker; writes still target the raw document. Fixes the reported sync bug (gauge show_value now shows checked like the render) and gives font-weight/font-family their real defaults - start_at/end_at leave the generic list for a dedicated Timing section: Visible from/until (s), bounded by the containing scene's duration (scenes and composition pointers), placeholders and a visibility-window help line; clearing restores the default window - multiline strings (code/content/message or values with newlines) render as monospace textareas --- .../rustmotion-studio/src/editor/inspector.rs | 388 +++++++++++++++++- .../src/editor/properties.rs | 205 ++++++++- crates/rustmotion-studio/src/scenario/edit.rs | 29 ++ crates/rustmotion-studio/src/scenario/mod.rs | 2 +- 4 files changed, 606 insertions(+), 18 deletions(-) diff --git a/crates/rustmotion-studio/src/editor/inspector.rs b/crates/rustmotion-studio/src/editor/inspector.rs index f292d1e..c49cf9f 100644 --- a/crates/rustmotion-studio/src/editor/inspector.rs +++ b/crates/rustmotion-studio/src/editor/inspector.rs @@ -12,11 +12,14 @@ 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_field_value, set_style, set_style_value, Shared}; +use crate::scenario::{ + scene_duration_for_pointer, 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, + component_props, css_family, css_section_props, effective_element, engine_placeholder, + fill_to_value, is_multiline, parse_fill, slider_range, visible_sections, FillMode, PropKind, }; // ── Debounce context ───────────────────────────────────────────────────────── @@ -536,8 +539,9 @@ fn seg_icon(active: bool) -> &'static str { fn prop_str(style: &serde_json::Value, name: &str) -> String { match style.get(name) { Some(serde_json::Value::String(s)) => s.clone(), + // Null = unset (effective documents serialize unset Options as null). + Some(serde_json::Value::Null) | None => String::new(), Some(other) => other.to_string(), - None => String::new(), } } @@ -648,33 +652,126 @@ fn curated_names(fam: Family) -> std::collections::BTreeSet<&'static str> { /// 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). +/// is skipped for the text family (the Content editor owns it); start_at / +/// end_at move to the dedicated Timing section below. +/// +/// Controls read the EFFECTIVE element (typed round-trip fills serde +/// defaults), so a gauge without `show_value` shows the switch ON like the +/// engine renders it; fields absent from the raw get a dimmed "default" +/// marker. Writes keep targeting the raw document. #[component] fn RootPropsSection(pointer: String, kind: String, element: serde_json::Value) -> Element { let Some(props) = component_props(&kind) else { return rsx! {}; }; + let effective = effective_element(&element); let skip_content = family(&kind) == Family::Text; + let has_timing = props + .iter() + .any(|p| p.name == "start_at" || p.name == "end_at"); let rows: Vec<_> = props .iter() .filter(|p| !(skip_content && p.name == "content")) + .filter(|p| p.name != "start_at" && p.name != "end_at") .collect(); - if rows.is_empty() { + if rows.is_empty() && !has_timing { 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, + if !rows.is_empty() { + 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(&effective, &spec.name), + is_style: false, + is_default: element.get(&spec.name).is_none() + && effective.get(&spec.name).map(|v| !v.is_null()).unwrap_or(false), + } } } } + if has_timing { + TimingSection { pointer: pointer.clone(), effective: effective.clone() } + } + } +} + +/// Dedicated visibility-window editor (start_at / end_at), bounded by the +/// containing scene's duration. +#[component] +fn TimingSection(pointer: String, effective: serde_json::Value) -> Element { + let shared = use_context::(); + let max = { + let m = shared.lock().unwrap_or_else(|e| e.into_inner()); + scene_duration_for_pointer(&m.raw, &pointer) + }; + rsx! { + CollapsibleSection { title: "Timing".to_string(), start_open: true, + TimingRow { + pointer: pointer.clone(), + field: "start_at".to_string(), + label: "Visible from (s)".to_string(), + placeholder: "scene start".to_string(), + value: prop_str(&effective, "start_at"), + max, + } + TimingRow { + pointer: pointer.clone(), + field: "end_at".to_string(), + label: "Visible until (s)".to_string(), + placeholder: "end of scene".to_string(), + value: prop_str(&effective, "end_at"), + max, + } + div { style: "color:var(--rm-text-muted); font-size:10px;", + "Visibility window — the element keeps its layout slot outside it." + } + } + } +} + +/// One Timing row: numeric input (step 0.1) writing the typed field; empty +/// unsets it. +#[component] +fn TimingRow( + pointer: String, + field: String, + label: String, + placeholder: String, + value: String, + max: Option, +) -> Element { + let shared = use_context::(); + // An empty max attribute is ignored by the browser (no constraint). + let max_attr = max.map(|m| m.to_string()).unwrap_or_default(); + rsx! { + div { style: "display:flex; align-items:center; gap:8px; min-height:26px;", + span { style: "width:96px; flex:none; color:var(--rm-text-muted); font-size:11px;", "{label}" } + input { + r#type: "number", + style: "{INPUT_STYLE}", + step: "0.1", + min: "0", + max: "{max_attr}", + value: "{value}", + placeholder: "{placeholder}", + title: "Visibility window — the element keeps its layout slot outside it.", + oninput: { + let shared = shared.clone(); + let p = pointer.clone(); + let f = field.clone(); + move |e: FormEvent| { + if let Ok(v) = parse_root_value(&PropKind::Number, &e.value()) { + write_root_field(&shared, &p, &f, v); + } + } + }, + } + } } } @@ -769,6 +866,7 @@ fn GenericRow( prop_kind: PropKind, value: String, is_style: bool, + #[props(default = false)] is_default: bool, ) -> Element { let shared = use_context::(); @@ -903,17 +1001,45 @@ fn GenericRow( } } } + PropKind::String if is_multiline(&name, &value) => { + // Long-form text (codeblock `code`, notification `message`, …). + let commit = commit.clone(); + let font = if name == "code" { + "font-family:monospace; font-size:11px;" + } else { + "font:inherit;" + }; + rsx! { + textarea { + style: "width:100%; box-sizing:border-box; min-height:88px; resize:vertical; padding:7px 9px; background:var(--rm-bg); color:var(--rm-text); border:1px solid var(--rm-border-2); border-radius:7px; {font}", + value: "{value}", + oninput: move |e: FormEvent| commit(&e.value()), + } + } + } PropKind::Unit | PropKind::String => { let commit = commit.clone(); + let placeholder = engine_placeholder(&name).unwrap_or_default(); rsx! { input { r#type: "text", style: "{INPUT_STYLE}", value: "{value}", + placeholder: "{placeholder}", oninput: move |e: FormEvent| commit(&e.value()), } } } + PropKind::ColorList => { + rsx! { + ColorListControl { pointer: pointer.clone(), name: name.clone(), value: value.clone() } + } + } + PropKind::Fill => { + rsx! { + FillControl { pointer: pointer.clone(), name: name.clone(), value: value.clone() } + } + } PropKind::Complex => { let commit = commit.clone(); rsx! { @@ -929,8 +1055,13 @@ fn GenericRow( 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}", + title: if is_default { "{name} — engine default (not set in the source)" } else { "{name}" }, "{name}" + if is_default { + span { style: "margin-left:4px; color:var(--rm-text-muted); opacity:0.55; font-size:9px; text-transform:uppercase; letter-spacing:0.04em;", + "default" + } + } } div { style: "flex:1; min-width:0; display:flex; justify-content:flex-end;", {control} } } @@ -1149,7 +1280,9 @@ fn FieldRow(field: Field, pointer: String, style: serde_json::Value) -> Element rsx! { Select:: { width: "100%", - default_value: if value.is_empty() { None } else { Some(value.clone()) }, + // Unset → show the engine's cascade default (Regular · 400) + // instead of an empty "Select an option" placeholder. + default_value: Some(if value.is_empty() { "400".to_string() } else { value.clone() }), on_value_change: { let shared = shared.clone(); let p = pointer.clone(); @@ -1289,6 +1422,229 @@ fn FieldRow(field: Field, pointer: String, style: serde_json::Value) -> Element } } +// ── Color-list & fill editors ──────────────────────────────────────────────── + +/// Editable list of color rows (swatch + hex + delete, plus "+ Add color"). +/// Operates on a shared signal so add/remove render immediately (the panel +/// itself is memoized per selection). Reordering: v1 = none. +#[component] +fn ColorRows(colors: Signal>, on_change: EventHandler<()>) -> Element { + rsx! { + div { style: "display:flex; flex-direction:column; gap:6px; width:100%;", + for (i, c) in colors().iter().cloned().enumerate() { + div { key: "{i}", style: "display:flex; align-items:center; gap:6px;", + ColorPicker { + color: parse_hsv(&c), + on_color_change: { + let mut colors = colors; + move |hsv: Hsv| { + if let Some(slot) = colors.write().get_mut(i) { + *slot = hsv_to_hex(hsv); + } + on_change.call(()); + } + }, + } + input { + r#type: "text", + style: "{HEX_STYLE}", + value: "{c}", + oninput: { + let mut colors = colors; + move |e: FormEvent| { + if let Some(slot) = colors.write().get_mut(i) { + *slot = e.value(); + } + on_change.call(()); + } + }, + } + Button { + variant: ButtonVariant::Ghost, + size: ButtonSize::IconSm, + title: "Remove color", + onclick: { + let mut colors = colors; + move |_| { + if colors.read().len() > i { + colors.write().remove(i); + } + on_change.call(()); + } + }, + X { size: 13, stroke: "var(--rm-text-muted)" } + } + } + } + Button { + variant: ButtonVariant::Outline, + size: ButtonSize::Xs, + onclick: { + let mut colors = colors; + move |_| { + colors.write().push("#ffffff".to_string()); + on_change.call(()); + } + }, + "+ Add color" + } + } + } +} + +/// Control for [`PropKind::ColorList`] fields (gradient_text `colors`): +/// per-entry pickers writing the whole array (typed). +#[component] +fn ColorListControl(pointer: String, name: String, value: String) -> Element { + let shared = use_context::(); + let colors = use_signal(|| serde_json::from_str::>(&value).unwrap_or_default()); + let write = { + let shared = shared.clone(); + let p = pointer.clone(); + let n = name.clone(); + move |_| { + let list: Vec = colors + .read() + .iter() + .cloned() + .map(serde_json::Value::String) + .collect(); + write_root_field(&shared, &p, &n, serde_json::Value::Array(list)); + } + }; + rsx! { + ColorRows { colors, on_change: write } + } +} + +/// Control for [`PropKind::Fill`] fields (shape `fill`): segmented +/// Single | Linear | Radial, a single picker or a stop list + angle, writing +/// the hex string or the gradient object. +#[component] +fn FillControl(pointer: String, name: String, value: String) -> Element { + let shared = use_context::(); + let parsed: serde_json::Value = + serde_json::from_str(&value).unwrap_or(serde_json::Value::String(value.clone())); + let (m0, c0, a0) = parse_fill(&parsed); + let mode = use_signal(|| m0); + let colors = use_signal(|| { + if c0.is_empty() { + vec!["#ffffff".to_string()] + } else { + c0 + } + }); + let angle = use_signal(|| a0); + + let write = { + let shared = shared.clone(); + let p = pointer.clone(); + let n = name.clone(); + Rc::new(move || { + write_root_field( + &shared, + &p, + &n, + fill_to_value(mode(), &colors.read(), angle()), + ); + }) + }; + + let seg = |m: FillMode, label: &'static str| { + let mut mode = mode; + let write = write.clone(); + rsx! { + Button { + variant: if mode() == m { ButtonVariant::Primary } else { ButtonVariant::Ghost }, + size: ButtonSize::Xs, + style: "flex:1;", + onclick: move |_| { + mode.set(m); + write(); + }, + "{label}" + } + } + }; + + rsx! { + div { style: "display:flex; flex-direction:column; gap:8px; width:100%;", + div { class: "rm-seg", + {seg(FillMode::Single, "Single")} + {seg(FillMode::Linear, "Linear")} + {seg(FillMode::Radial, "Radial")} + } + if mode() == FillMode::Single { + div { style: "display:flex; align-items:center; gap:6px;", + ColorPicker { + color: parse_hsv(colors.read().first().map(String::as_str).unwrap_or("#ffffff")), + on_color_change: { + let mut colors = colors; + let write = write.clone(); + move |hsv: Hsv| { + let hex = hsv_to_hex(hsv); + if colors.read().is_empty() { + colors.write().push(hex); + } else { + colors.write()[0] = hex; + } + write(); + } + }, + } + input { + r#type: "text", + style: "{HEX_STYLE}", + value: "{colors.read().first().cloned().unwrap_or_default()}", + oninput: { + let mut colors = colors; + let write = write.clone(); + move |e: FormEvent| { + let v = e.value(); + if colors.read().is_empty() { + colors.write().push(v); + } else { + colors.write()[0] = v; + } + write(); + } + }, + } + } + } else { + ColorRows { + colors, + on_change: { + let write = write.clone(); + move |_| write() + }, + } + if mode() == FillMode::Linear { + div { style: "display:flex; align-items:center; gap:8px;", + span { style: "color:var(--rm-text-muted); font-size:11px;", "Angle" } + input { + r#type: "number", + style: "{NUM_STYLE}", + value: "{angle}", + oninput: { + let mut angle = angle; + let write = write.clone(); + move |e: FormEvent| { + if let Ok(v) = e.value().parse::() { + angle.set(v); + write(); + } + } + }, + } + span { style: "color:var(--rm-text-muted); font-size:10px;", "°" } + } + } + } + } + } +} + // ── Color helpers ──────────────────────────────────────────────────────────── fn parse_hsv(s: &str) -> Hsv { diff --git a/crates/rustmotion-studio/src/editor/properties.rs b/crates/rustmotion-studio/src/editor/properties.rs index 21fd760..85c239a 100644 --- a/crates/rustmotion-studio/src/editor/properties.rs +++ b/crates/rustmotion-studio/src/editor/properties.rs @@ -70,9 +70,13 @@ pub enum PropKind { Enum(Vec), /// String whose name contains "color" → color picker. Color, + /// Array of color strings (gradient_text `colors`) → per-entry pickers. + ColorList, + /// Untagged string|gradient-object (shape `fill`) → mode-switched editor. + Fill, /// Untagged number|string (Length, LengthPercentage, Size, …) → unit input. Unit, - /// Objects/arrays (border, box-shadow, fill, …) → JSON textarea. + /// Objects/arrays (border, box-shadow, …) → JSON textarea. Complex, } @@ -82,6 +86,87 @@ pub struct PropSpec { pub kind: PropKind, } +/// Display mode of a [`PropKind::Fill`] value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FillMode { + Single, + Linear, + Radial, +} + +/// Decompose an existing fill value into `(mode, colors, angle)`. A bare +/// string is a single color; objects pick Linear/Radial from their `type`. +pub fn parse_fill(v: &Value) -> (FillMode, Vec, f64) { + match v { + Value::String(s) => (FillMode::Single, vec![s.clone()], 0.0), + Value::Object(o) => { + let colors: Vec = o + .get("colors") + .and_then(|c| c.as_array()) + .map(|a| { + a.iter() + .filter_map(|c| c.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + let angle = o.get("angle").and_then(|a| a.as_f64()).unwrap_or(0.0); + let mode = match o.get("type").and_then(|t| t.as_str()) { + Some("radial") => FillMode::Radial, + _ => FillMode::Linear, + }; + (mode, colors, angle) + } + _ => (FillMode::Single, Vec::new(), 0.0), + } +} + +/// Serialize a fill editor state back to the scenario value: Single → the hex +/// string, Linear/Radial → the gradient object (angle only on Linear). +pub fn fill_to_value(mode: FillMode, colors: &[String], angle: f64) -> Value { + match mode { + FillMode::Single => Value::String( + colors + .first() + .cloned() + .unwrap_or_else(|| "#ffffff".to_string()), + ), + FillMode::Linear => serde_json::json!({ + "type": "linear", + "colors": colors, + "angle": angle, + }), + FillMode::Radial => serde_json::json!({ + "type": "radial", + "colors": colors, + }), + } +} + +/// The element as the ENGINE sees it: typed round-trip through `Component` +/// fills every `#[serde(default)]`. Falls back to the raw element when the +/// round-trip fails (invalid element). +pub fn effective_element(raw: &Value) -> Value { + serde_json::from_value::(raw.clone()) + .ok() + .and_then(|c| serde_json::to_value(&c).ok()) + .unwrap_or_else(|| raw.clone()) +} + +/// Multiline heuristic for String controls: known long-form field names, or a +/// current value that already contains a newline. +pub fn is_multiline(name: &str, value: &str) -> bool { + matches!(name, "code" | "content" | "message") || value.contains('\n') +} + +/// Engine-default placeholder for string inputs (cascade defaults the schema +/// can't know): `font-family` → "Inter". +pub fn engine_placeholder(name: &str) -> Option<&'static str> { + match name { + "font-family" => Some("Inter"), + _ => None, + } +} + /// Root fields (name + kind) for a component tag, schema order. `None` for /// unknown tags. pub fn component_props(tag: &str) -> Option<&'static Vec> { @@ -161,6 +246,9 @@ fn kind_of_schema(name: &str, schema: &Value, defs: &Value, depth: u8) -> PropKi collect_union(arm, defs, depth + 1, &mut info); } // Decision order (see the doc comment on `UnionInfo`). + if info.has_string && info.has_gradient_object { + return PropKind::Fill; + } if info.has_string && (name.contains("color") || name.contains("colour")) { return PropKind::Color; } @@ -181,10 +269,29 @@ fn kind_of_schema(name: &str, schema: &Value, defs: &Value, depth: u8) -> PropKi Some("boolean") => PropKind::Bool, Some("string") if name.contains("color") || name.contains("colour") => PropKind::Color, Some("string") => PropKind::String, + Some("array") if is_color_string_array(name, schema, defs, depth) => PropKind::ColorList, _ => PropKind::Complex, } } +/// Array-of-color-strings detection: the items resolve to strings (or a +/// Color-typed union with a string arm) AND either the field name contains +/// "color" or the items are the `Color` schema type. +fn is_color_string_array(name: &str, schema: &Value, defs: &Value, depth: u8) -> bool { + let Some(items) = schema.get("items") else { + return false; + }; + let items_ref_is_color = items + .get("$ref") + .and_then(|r| r.as_str()) + .is_some_and(|r| r.rsplit('/').next().unwrap_or_default().contains("Color")); + let resolved = resolve_arm(items, defs, depth + 1); + let mut info = UnionInfo::default(); + collect_union(items, defs, depth + 1, &mut info); + let string_items = primary_type(&resolved) == Some("string") || info.has_string; + string_items && (name.contains("color") || name.contains("colour") || items_ref_is_color) +} + /// What a (recursively flattened) union offers. Decision order in /// `kind_of_schema`: /// 1. string arm + color-ish name → `Color` (untagged string|rgba). @@ -199,6 +306,8 @@ struct UnionInfo { variants: Vec, has_number: bool, has_string: bool, + /// An object arm with a `colors` property (the gradient side of `Fill`). + has_gradient_object: bool, } /// Recursively flatten a union arm into `UnionInfo`. @@ -222,6 +331,13 @@ fn collect_union(arm: &Value, defs: &Value, depth: u8, info: &mut UnionInfo) { match primary_type(&s) { Some("number") | Some("integer") => info.has_number = true, Some("string") => info.has_string = true, + Some("object") + if s.get("properties") + .and_then(|p| p.as_object()) + .is_some_and(|p| p.contains_key("colors")) => + { + info.has_gradient_object = true; + } _ => {} } } @@ -602,6 +718,93 @@ mod tests { } } + // ── Round 2: color editors / effective view / multiline ───────────── + + #[test] + fn gradient_text_colors_is_a_color_list() { + let props = component_props("gradient_text").expect("gradient_text in schema"); + assert_eq!(kind_of(props, "colors"), Some(&PropKind::ColorList)); + } + + #[test] + fn plain_string_arrays_are_not_color_lists() { + let arr = serde_json::json!({"type": "array", "items": {"type": "string"}}); + assert!(!is_color_string_array("headers", &arr, &Value::Null, 0)); + assert!(is_color_string_array("colors", &arr, &Value::Null, 0)); + } + + #[test] + fn shape_fill_is_fill_kind() { + let props = component_props("shape").expect("shape in schema"); + assert_eq!(kind_of(props, "fill"), Some(&PropKind::Fill)); + } + + #[test] + fn parse_fill_detects_modes() { + let (m, c, _) = parse_fill(&serde_json::json!("#ff0000")); + assert_eq!(m, FillMode::Single); + assert_eq!(c, vec!["#ff0000".to_string()]); + + let (m, c, a) = + parse_fill(&serde_json::json!({"type":"linear","colors":["#a","#b"],"angle":45})); + assert_eq!(m, FillMode::Linear); + assert_eq!(c, vec!["#a".to_string(), "#b".to_string()]); + assert_eq!(a, 45.0); + + let (m, _, _) = parse_fill(&serde_json::json!({"type":"radial","colors":["#a"]})); + assert_eq!(m, FillMode::Radial); + } + + #[test] + fn fill_to_value_serializes_by_mode() { + assert_eq!( + fill_to_value(FillMode::Single, &["#fff".to_string()], 0.0), + serde_json::json!("#fff") + ); + assert_eq!( + fill_to_value( + FillMode::Linear, + &["#a".to_string(), "#b".to_string()], + 90.0 + ), + serde_json::json!({"type":"linear","colors":["#a","#b"],"angle":90.0}) + ); + assert_eq!( + fill_to_value(FillMode::Radial, &["#a".to_string()], 45.0), + serde_json::json!({"type":"radial","colors":["#a"]}) + ); + } + + #[test] + fn effective_element_fills_serde_defaults() { + // User bug: gauge without show_value renders the value (default true), + // but the inspector showed the switch off. + let raw = serde_json::json!({"type": "gauge", "value": 50.0}); + let eff = effective_element(&raw); + assert_eq!(eff["show_value"], serde_json::json!(true)); + + // Counter without easing → the exact default variant, serialized. + let raw = serde_json::json!({"type": "counter", "from": 0, "to": 10}); + let eff = effective_element(&raw); + assert_eq!(eff["easing"], serde_json::json!("linear")); + assert_eq!(eff["decimals"], serde_json::json!(0)); + } + + #[test] + fn effective_element_falls_back_to_raw_when_invalid() { + let raw = serde_json::json!({"type": "counter", "note": "missing from/to"}); + assert_eq!(effective_element(&raw), raw); + } + + #[test] + fn multiline_heuristic() { + assert!(is_multiline("code", "let x = 1;")); + assert!(is_multiline("content", "")); + assert!(is_multiline("message", "")); + assert!(!is_multiline("title", "Hello")); + assert!(is_multiline("title", "line\nbreak")); + } + #[test] fn slider_ranges_known_and_unknown() { assert_eq!(slider_range("opacity"), Some((0.0, 1.0, 0.01))); diff --git a/crates/rustmotion-studio/src/scenario/edit.rs b/crates/rustmotion-studio/src/scenario/edit.rs index e54307a..5142c22 100644 --- a/crates/rustmotion-studio/src/scenario/edit.rs +++ b/crates/rustmotion-studio/src/scenario/edit.rs @@ -90,6 +90,19 @@ pub fn set_style_value(mut raw: Value, pointer: &str, prop: &str, value: Value) Some(raw) } +/// Duration (seconds) of the scene containing the element at `pointer` +/// (`/scenes/N/…` or `/composition/V/scenes/N/…`). Used by the inspector's +/// Timing section as the upper bound for start_at/end_at. +pub fn scene_duration_for_pointer(raw: &Value, pointer: &str) -> Option { + let segs: Vec<&str> = pointer.split('/').collect(); + let scene_ptr = match segs.as_slice() { + ["", "scenes", n, ..] => format!("/scenes/{n}"), + ["", "composition", v, "scenes", n, ..] => format!("/composition/{v}/scenes/{n}"), + _ => return None, + }; + raw.pointer(&scene_ptr)?.get("duration")?.as_f64() +} + /// 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 { @@ -186,6 +199,22 @@ mod tests { ); } + #[test] + fn scene_duration_for_pointer_reads_the_containing_scene() { + let r = json!({"scenes": [ {"duration": 3.0}, {"duration": 5.5, "children": [ {"type": "text"} ]} ]}); + assert_eq!( + scene_duration_for_pointer(&r, "/scenes/1/children/0"), + Some(5.5) + ); + assert_eq!(scene_duration_for_pointer(&r, "/scenes/0"), Some(3.0)); + let c = json!({"composition": [ {"scenes": [ {"duration": 2.0} ]} ]}); + assert_eq!( + scene_duration_for_pointer(&c, "/composition/0/scenes/0/children/2"), + Some(2.0) + ); + assert_eq!(scene_duration_for_pointer(&r, "/video"), None); + } + #[test] fn set_field_value_keeps_json_types_round_trip() { let updated = set_field_value(raw(), "/scenes/0/children/0", "from", json!(250)).unwrap(); diff --git a/crates/rustmotion-studio/src/scenario/mod.rs b/crates/rustmotion-studio/src/scenario/mod.rs index a03b966..ac3751e 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_field_value, set_style, set_style_value, + scene_duration_for_pointer, 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};