diff --git a/crates/rustmotion-studio/src/components/color_picker/component.rs b/crates/rustmotion-studio/src/components/color_picker/component.rs index 10b4350..2596346 100644 --- a/crates/rustmotion-studio/src/components/color_picker/component.rs +++ b/crates/rustmotion-studio/src/components/color_picker/component.rs @@ -12,11 +12,44 @@ fn format_color_hex(color: Color) -> String { format!("#{color:X}") } +/// Compute the popover's fixed position from the anchor (trigger swatch) +/// rect, the popover size and the window viewport, all in logical pixels. +/// Preference: below the anchor, right edges aligned (opens leftward — the +/// inspector hugs the right window edge); flips above when the bottom would +/// clip; then a HARD clamp of both axes into `[margin, viewport - size - +/// margin]` — the clamp always wins, even after the flip. +pub fn popover_position( + anchor: (f64, f64, f64, f64), // x, y, w, h + popover: (f64, f64), // w, h + viewport: (f64, f64), // w, h + margin: f64, +) -> (f64, f64) { + const GAP: f64 = 4.0; + let (ax, ay, aw, ah) = anchor; + let (pw, ph) = popover; + let (vw, vh) = viewport; + + // Right-aligned under the anchor. + let x = ax + aw - pw; + let mut y = ay + ah + GAP; + // Flip above when the bottom clips. + if y + ph > vh - margin { + y = ay - ph - GAP; + } + // Hard clamp (min first so the margin wins on tiny viewports). + let cx = x.min(vw - pw - margin).max(margin); + let cy = y.min(vh - ph - margin).max(margin); + (cx, cy) +} + #[derive(Clone, Copy)] struct ColorPickerRootContext { open: Memo, disabled: ReadSignal, color: ReadSignal>, + /// The trigger's mounted node — the popover anchors its fixed position on + /// this rect at open time. + trigger: Signal>>, } /// The props for the [`ColorPickerRoot`] component. @@ -75,10 +108,12 @@ pub fn ColorPickerRoot(props: ColorPickerRootProps) -> Element { forward.call(c); }; + let trigger = use_signal(|| None); use_context_provider(|| ColorPickerRootContext { open, disabled: props.disabled, color: local.into(), + trigger, }); rsx! { @@ -181,16 +216,21 @@ pub fn ColorPickerTrigger(props: ColorPickerTriggerProps) -> Element { format_color_hex(rgb) }); + let mut trigger = ctx.trigger; rsx! { - popover::PopoverTrigger { - class: "dx-color-picker-button", - disabled: if (ctx.disabled)() { true }, - aria_label: format!("Color picker {aria_hex}"), - aria_expanded: (ctx.open)(), - attributes: props.attributes, - ColorSwatch { color: ctx.color } - if let Some(label) = props.label { span { {label} } } - {props.children} + div { + style: "display:inline-flex;", + onmounted: move |e| trigger.set(Some(e.data())), + popover::PopoverTrigger { + class: "dx-color-picker-button", + disabled: if (ctx.disabled)() { true }, + aria_label: format!("Color picker {aria_hex}"), + aria_expanded: (ctx.open)(), + attributes: props.attributes, + ColorSwatch { color: ctx.color } + if let Some(label) = props.label { span { {label} } } + {props.children} + } } } } @@ -209,37 +249,53 @@ pub struct ColorPickerPopoverProps { #[component] pub fn ColorPickerPopover(props: ColorPickerPopoverProps) -> Element { let ctx = use_context::(); - // The inspector docks the picker against the right window edge, so the - // popover opens LEFT of the swatch (CSS `right:0`). Vertically it flips - // above the swatch when its bottom would clip the window: measured when - // the popover opens. + // Fixed positioning computed at open time by [`popover_position`]: below + // the swatch opening leftward, flipped above near the window bottom, and + // hard-clamped into the viewport on both axes. `position:fixed` escapes + // every scrollable/clipping container of the panel. let mut node = use_signal(|| None::>); - let mut flip_up = use_signal(|| false); + let mut pos = use_signal(|| None::<(f64, f64)>); use_effect(move || { if (ctx.open)() { - if let Some(n) = node() { + if let (Some(t), Some(n)) = ((ctx.trigger)(), node()) { spawn(async move { - if let Ok(rect) = n.get_client_rect().await { - let win = dioxus::desktop::window(); - let vh = win - .inner_size() - .to_logical::(win.scale_factor()) - .height; - flip_up.set(rect.origin.y + rect.size.height > vh - 8.0); - } + let (Ok(anchor), Ok(content)) = + (t.get_client_rect().await, n.get_client_rect().await) + else { + return; + }; + let win = dioxus::desktop::window(); + let vs = win.inner_size().to_logical::(win.scale_factor()); + // The measured node is the inner content: compensate for + // the popover's own padding (12px each side). + const PAD: f64 = 24.0; + pos.set(Some(popover_position( + ( + anchor.origin.x, + anchor.origin.y, + anchor.size.width, + anchor.size.height, + ), + (content.size.width + PAD, content.size.height + PAD), + (vs.width, vs.height), + 8.0, + ))); }); } } }); - let class = if flip_up() { - "dx-color-picker-popover flip-up" - } else { - "dx-color-picker-popover" + let style = match pos() { + Some((x, y)) => { + format!("position:fixed; left:{x}px; top:{y}px; right:auto; bottom:auto; margin:0;") + } + // Not measured yet (first open): keep it invisible for one frame. + None => "visibility:hidden;".to_string(), }; rsx! { popover::PopoverContent { - class: class.to_string(), + class: "dx-color-picker-popover".to_string(), + style: "{style}", attributes: props.attributes, div { onmounted: move |e| node.set(Some(e.data())), @@ -521,3 +577,63 @@ pub fn ColorPickerSelect(props: ColorPickerSelectProps) -> Element { } } } + +#[cfg(test)] +mod tests { + use super::popover_position; + + #[test] + fn anchor_top_right_opens_below_clamped_into_viewport() { + // Swatch near the top-right corner of a 1200x800 window. + let (x, y) = popover_position( + (1150.0, 60.0, 24.0, 24.0), + (260.0, 320.0), + (1200.0, 800.0), + 8.0, + ); + // Below the anchor… + assert_eq!(y, 60.0 + 24.0 + 4.0); + // …right-aligned would be 1174-260=914, fits; but never past the right margin. + assert_eq!(x, 914.0); + assert!(x + 260.0 <= 1200.0 - 8.0); + } + + #[test] + fn anchor_near_bottom_flips_above() { + let (x, y) = popover_position( + (900.0, 700.0, 24.0, 24.0), + (260.0, 320.0), + (1200.0, 800.0), + 8.0, + ); + // 700+24+4+320 > 792 → flip above: 700-320-4 = 376. + assert_eq!(y, 376.0); + assert_eq!(x, 900.0 + 24.0 - 260.0); + } + + #[test] + fn anchor_top_left_never_clips_left_or_top() { + // Right-aligned x would be negative → clamped to the margin. + let (x, y) = popover_position( + (10.0, 10.0, 24.0, 24.0), + (260.0, 320.0), + (1200.0, 800.0), + 8.0, + ); + assert_eq!(x, 8.0); + assert_eq!(y, 10.0 + 24.0 + 4.0); + } + + #[test] + fn tiny_viewport_clamps_to_margins() { + // Popover larger than the window: both axes pinned at the margin + // (the clamp always wins, even after the flip). + let (x, y) = popover_position( + (50.0, 90.0, 24.0, 24.0), + (260.0, 320.0), + (200.0, 150.0), + 8.0, + ); + assert_eq!((x, y), (8.0, 8.0)); + } +} diff --git a/crates/rustmotion-studio/src/components/color_picker/style.css b/crates/rustmotion-studio/src/components/color_picker/style.css index e30a9fc..d7a4f07 100644 --- a/crates/rustmotion-studio/src/components/color_picker/style.css +++ b/crates/rustmotion-studio/src/components/color_picker/style.css @@ -243,12 +243,3 @@ cursor: not-allowed; opacity: 0.5; } - -/* Near the bottom of the window the popover flips above the swatch - (measured at open time by ColorPickerPopover). */ -.dx-color-picker-popover.flip-up { - top: auto; - bottom: 100%; - margin-top: 0; - margin-bottom: 4px; -} diff --git a/crates/rustmotion-studio/src/editor/inspector.rs b/crates/rustmotion-studio/src/editor/inspector.rs index 19499c9..aaee6be 100644 --- a/crates/rustmotion-studio/src/editor/inspector.rs +++ b/crates/rustmotion-studio/src/editor/inspector.rs @@ -897,6 +897,11 @@ fn GenericRow( value: String, is_style: bool, #[props(default = false)] is_default: bool, + /// When set (object sub-rows), the parsed TYPED value is handed to this + /// callback instead of being written as a root field — the parent object + /// control folds it into the whole-object write. + #[props(default)] + custom_commit: Option>, ) -> Element { let shared = use_context::(); @@ -907,7 +912,11 @@ fn GenericRow( let n = name.clone(); let k = prop_kind.clone(); Rc::new(move |text: &str| { - if is_style { + if let Some(cb) = &custom_commit { + if let Ok(v) = parse_root_value(&k, text) { + cb.call(v); + } + } else if is_style { if text.trim().is_empty() { write_style_removal(&shared, &p, &n); } else { @@ -1083,7 +1092,25 @@ fn GenericRow( FillControl { pointer: pointer.clone(), name: name.clone(), value: value.clone() } } } - PropKind::Complex => { + // Object/number-list editors are root-field only: style writes are + // string-based, so object-shaped style props keep the JSON area. + PropKind::Object(specs) if !is_style => { + rsx! { + ObjectControl { + pointer: pointer.clone(), + name: name.clone(), + specs, + value: value.clone(), + custom_commit, + } + } + } + PropKind::NumberList if !is_style => { + rsx! { + NumberListControl { pointer: pointer.clone(), name: name.clone(), value: value.clone() } + } + } + PropKind::Complex | PropKind::Object(_) | PropKind::NumberList => { let commit = commit.clone(); rsx! { JsonArea { @@ -1464,6 +1491,169 @@ fn FieldRow(field: Field, pointer: String, style: serde_json::Value) -> Element } } +// ── Object & number-list editors ───────────────────────────────────────────── + +/// Structured editor for object fields with known schema properties (stat +/// `trend`, `stroke`, …): one indented sub-row per property through the same +/// control factory. Sub-edits fold into the WHOLE object (typed) via the +/// existing root-field write — optimistic + debounce for free. A pruned last +/// key collapses to `Null` (field removed). Nested objects thread their +/// writes through `custom_commit` (registry depth cap: 2). +#[component] +fn ObjectControl( + pointer: String, + name: String, + specs: Vec, + value: String, + #[props(default)] custom_commit: Option>, +) -> Element { + let shared = use_context::(); + // Local object state so successive sub-edits accumulate (the panel is + // memoized per selection and won't re-render between keystrokes). + let obj = use_signal(|| { + serde_json::from_str::(&value).unwrap_or(serde_json::Value::Null) + }); + + let commit_whole = { + let shared = shared.clone(); + let p = pointer.clone(); + let n = name.clone(); + move |whole: serde_json::Value| { + if let Some(cb) = &custom_commit { + cb.call(whole); + } else { + write_root_field(&shared, &p, &n, whole); + } + } + }; + + rsx! { + div { style: "display:flex; flex-direction:column; gap:6px; width:100%; padding-left:10px; border-left:2px solid var(--rm-border-2);", + for spec in specs { + GenericRow { + key: "{pointer}-{name}-{spec.name}", + pointer: pointer.clone(), + name: spec.name.clone(), + prop_kind: spec.kind.clone(), + value: prop_str(&obj(), &spec.name), + is_style: false, + custom_commit: { + let commit_whole = commit_whole.clone(); + let key = spec.name.clone(); + let mut obj = obj; + Callback::new(move |sub: serde_json::Value| { + let next = crate::editor::properties::mutate_object_field( + &obj(), + &key, + sub, + ); + obj.set(next.clone()); + commit_whole(next); + }) + }, + } + } + } + } +} + +/// Per-entry editor for arrays of numbers (`sparkline_data`, dash patterns…): +/// number input + remove per entry, "+ Add". Writes the whole array (typed) +/// once every entry parses; an emptied list removes the field. Kept separate +/// from `ColorRows` — the item controls share almost nothing. +#[component] +fn NumberListControl(pointer: String, name: String, value: String) -> Element { + let shared = use_context::(); + let entries = use_signal(|| { + serde_json::from_str::>(&value) + .map(|v| { + v.into_iter() + .map(|n| display_number(&n.to_string())) + .collect::>() + }) + .unwrap_or_default() + }); + + let write = { + let shared = shared.clone(); + let p = pointer.clone(); + let n = name.clone(); + move |_| { + let parsed: Option> = entries + .read() + .iter() + .map(|e| { + parse_root_value(&PropKind::Float, e) + .ok() + .filter(|v| !v.is_null()) + }) + .collect(); + if let Some(nums) = parsed { + let value = if nums.is_empty() { + serde_json::Value::Null + } else { + serde_json::Value::Array(nums) + }; + write_root_field(&shared, &p, &n, value); + } + } + }; + + rsx! { + div { style: "display:flex; flex-direction:column; gap:6px; width:100%;", + for (i, e) in entries().iter().cloned().enumerate() { + div { key: "{i}", style: "display:flex; align-items:center; gap:6px;", + input { + r#type: "number", + step: "0.1", + style: "{INPUT_STYLE}", + value: "{e}", + oninput: { + let mut entries = entries; + let write = write.clone(); + move |ev: FormEvent| { + if let Some(slot) = entries.write().get_mut(i) { + *slot = ev.value(); + } + write(()); + } + }, + } + Button { + variant: ButtonVariant::Ghost, + size: ButtonSize::IconSm, + title: "Remove entry", + onclick: { + let mut entries = entries; + let write = write.clone(); + move |_| { + if entries.read().len() > i { + entries.write().remove(i); + } + write(()); + } + }, + X { size: 13, stroke: "var(--rm-text-muted)" } + } + } + } + Button { + variant: ButtonVariant::Outline, + size: ButtonSize::Xs, + onclick: { + let mut entries = entries; + let write = write.clone(); + move |_| { + entries.write().push("0".to_string()); + write(()); + } + }, + "+ Add" + } + } + } +} + // ── Color-list & fill editors ──────────────────────────────────────────────── /// Editable list of color rows (swatch + hex + delete, plus "+ Add color"). diff --git a/crates/rustmotion-studio/src/editor/properties.rs b/crates/rustmotion-studio/src/editor/properties.rs index 8957ad6..7ddb353 100644 --- a/crates/rustmotion-studio/src/editor/properties.rs +++ b/crates/rustmotion-studio/src/editor/properties.rs @@ -82,7 +82,12 @@ pub enum PropKind { Fill, /// Untagged number|string (Length, LengthPercentage, Size, …) → unit input. Unit, - /// Objects/arrays (border, box-shadow, …) → JSON textarea. + /// Object with known schema properties (stat `trend`, `stroke`, …) → + /// indented sub-rows through the same control factory (depth ≤ 2). + Object(Vec), + /// Array of numbers (stat `sparkline_data`, …) → per-entry number rows. + NumberList, + /// Objects/arrays without a known shape → JSON textarea. Complex, } @@ -102,6 +107,22 @@ pub struct PropSpec { pub kind: PropKind, } +/// Mutate one sub-key of an object value: `Null` prunes the key; an object +/// left empty collapses to `Null` (the whole field gets removed). +pub fn mutate_object_field(current: &Value, key: &str, new: Value) -> Value { + let mut map = current.as_object().cloned().unwrap_or_default(); + if new.is_null() { + map.remove(key); + } else { + map.insert(key.to_string(), new); + } + if map.is_empty() { + Value::Null + } else { + Value::Object(map) + } +} + /// Display mode of a [`PropKind::Fill`] value. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FillMode { @@ -227,6 +248,18 @@ fn component_registry() -> &'static BTreeMap> { /// 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 { + kind_of_schema_inner(name, schema, defs, depth, 0) +} + +/// `obj_level` counts object nesting for [`PropKind::Object`] (capped at 2 — +/// deeper structures fall back to the JSON textarea). +fn kind_of_schema_inner( + name: &str, + schema: &Value, + defs: &Value, + depth: u8, + obj_level: u8, +) -> PropKind { if depth > 8 { return PropKind::Complex; } @@ -234,14 +267,14 @@ fn kind_of_schema(name: &str, schema: &Value, defs: &Value, depth: u8) -> PropKi 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), + Some(target) => kind_of_schema_inner(name, target, defs, depth + 1, obj_level), 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); + return kind_of_schema_inner(name, &all[0], defs, depth + 1, obj_level); } } // Direct string enum. @@ -255,7 +288,7 @@ fn kind_of_schema(name: &str, schema: &Value, defs: &Value, depth: u8) -> PropKi 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); + return kind_of_schema_inner(name, non_null[0], defs, depth + 1, obj_level); } let mut info = UnionInfo::default(); for arm in &non_null { @@ -283,14 +316,51 @@ fn kind_of_schema(name: &str, schema: &Value, defs: &Value, depth: u8) -> PropKi match primary_type(schema) { Some("integer") => PropKind::Integer, Some("number") => PropKind::Float, + Some("object") if obj_level < 2 => match object_specs(schema, defs, depth, obj_level) { + Some(specs) => PropKind::Object(specs), + None => PropKind::Complex, + }, 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, + Some("array") if is_number_array(schema, defs, depth) => PropKind::NumberList, _ => PropKind::Complex, } } +/// Sub-specs of an object schema with KNOWN properties (None for map-like / +/// empty objects → JSON textarea fallback). +fn object_specs(schema: &Value, defs: &Value, depth: u8, obj_level: u8) -> Option> { + let props = schema.get("properties")?.as_object()?; + if props.is_empty() { + return None; + } + Some( + props + .iter() + .map(|(n, ps)| PropSpec { + name: n.clone(), + kind: kind_of_schema_inner(n, ps, defs, depth + 1, obj_level + 1), + }) + .collect(), + ) +} + +/// Array whose items resolve to numbers (`Vec`, dash patterns, …). +fn is_number_array(schema: &Value, defs: &Value, depth: u8) -> bool { + let Some(items) = schema.get("items") else { + return false; + }; + let resolved = resolve_arm(items, defs, depth + 1); + if matches!(primary_type(&resolved), Some("number") | Some("integer")) { + return true; + } + let mut info = UnionInfo::default(); + collect_union(items, defs, depth + 1, &mut info); + info.has_number && !info.has_string && info.variants.is_empty() +} + /// 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. @@ -735,6 +805,97 @@ mod tests { } } + // ── Round 5: object / number-list editors ─────────────────────────── + + #[test] + fn stat_trend_is_an_object_with_known_sub_kinds() { + let props = component_props("stat").expect("stat in schema"); + match kind_of(props, "trend") { + Some(PropKind::Object(specs)) => { + let sub = |n: &str| specs.iter().find(|p| p.name == n).map(|p| &p.kind); + assert_eq!(sub("value"), Some(&PropKind::String)); + match sub("direction") { + Some(PropKind::Enum(v)) => { + assert!(v.contains(&"up".to_string()), "{v:?}"); + assert!(v.contains(&"down".to_string()), "{v:?}"); + assert!(v.contains(&"neutral".to_string()), "{v:?}"); + } + other => panic!("direction should be Enum, got {other:?}"), + } + assert_eq!(sub("color"), Some(&PropKind::Color)); + } + other => panic!("trend should be Object, got {other:?}"), + } + } + + #[test] + fn shapeless_objects_stay_json_areas() { + // An object without known properties (map-like) must NOT become an + // Object control. + let map_schema = serde_json::json!({ + "type": "object", + "additionalProperties": { "type": "string" } + }); + assert_eq!( + kind_of_schema("anything", &map_schema, &Value::Null, 0), + PropKind::Complex + ); + } + + #[test] + fn number_arrays_become_number_lists() { + let props = component_props("stat").expect("stat in schema"); + assert_eq!( + kind_of(props, "sparkline_data"), + Some(&PropKind::NumberList) + ); + // Color arrays keep their dedicated editor. + let gt = component_props("gradient_text").unwrap(); + assert_eq!(kind_of(gt, "colors"), Some(&PropKind::ColorList)); + // String arrays are neither. + let strings = serde_json::json!({"type": "array", "items": {"type": "string"}}); + assert_eq!( + kind_of_schema("labels", &strings, &Value::Null, 0), + PropKind::Complex + ); + } + + #[test] + fn mutate_object_field_sets_prunes_and_collapses() { + let trend = serde_json::json!({"value": "+340%", "direction": "up"}); + // Set a sub-key → whole object with the new value. + let out = mutate_object_field(&trend, "direction", serde_json::json!("down")); + assert_eq!( + out, + serde_json::json!({"value": "+340%", "direction": "down"}) + ); + // Null prunes the key. + let out = mutate_object_field(&out, "direction", Value::Null); + assert_eq!(out, serde_json::json!({"value": "+340%"})); + // Last key removed → the whole field collapses to Null. + let out = mutate_object_field(&out, "value", Value::Null); + assert!(out.is_null()); + // Mutating a null/absent object starts a fresh one. + let out = mutate_object_field(&Value::Null, "value", serde_json::json!("+1%")); + assert_eq!(out, serde_json::json!({"value": "+1%"})); + } + + #[test] + fn mutated_trend_round_trips_through_the_typed_parse() { + let raw = serde_json::json!({ + "type": "stat", "value": "8.4M", + "trend": {"value": "+340%", "direction": "up"} + }); + let mutated_trend = + mutate_object_field(&raw["trend"], "direction", serde_json::json!("down")); + let mut updated = raw.clone(); + updated["trend"] = mutated_trend; + assert!( + serde_json::from_value::(updated).is_ok(), + "mutated stat parses" + ); + } + // ── Round 3: Integer/Float split + display ────────────────────────── #[test]