diff --git a/crates/rustmotion-studio/src/app/root.rs b/crates/rustmotion-studio/src/app/root.rs index 68c1d66..918704a 100644 --- a/crates/rustmotion-studio/src/app/root.rs +++ b/crates/rustmotion-studio/src/app/root.rs @@ -104,6 +104,27 @@ pub fn StudioRoot() -> Element { let theme = use_signal(|| Theme::System); use_context_provider(|| theme); + // Open at 75% of the current monitor, centered. Runtime one-shot: tao + // monitors are only queryable once the window exists, so a pre-display + // Config sizing isn't possible — the first-frame resize flash is accepted. + // No detectable monitor → silent no-op. + use_effect(|| { + let window = dioxus::desktop::window(); + if let Some(monitor) = window.current_monitor() { + let size = monitor.size(); + if size.width == 0 || size.height == 0 { + return; + } + let w = (size.width as f64 * 0.75) as u32; + let h = (size.height as f64 * 0.75) as u32; + let pos = monitor.position(); + let x = pos.x + ((size.width - w) / 2) as i32; + let y = pos.y + ((size.height - h) / 2) as i32; + window.set_inner_size(dioxus::desktop::tao::dpi::PhysicalSize::new(w, h)); + window.set_outer_position(dioxus::desktop::tao::dpi::PhysicalPosition::new(x, y)); + } + }); + // /thumb/{i} -> scenario at flat index i, frame-0 JPEG (cached; rendered // off-lock to avoid blocking other asset requests). let thumb_lib = library.clone(); diff --git a/crates/rustmotion-studio/src/editor/inspector.rs b/crates/rustmotion-studio/src/editor/inspector.rs index c49cf9f..8272147 100644 --- a/crates/rustmotion-studio/src/editor/inspector.rs +++ b/crates/rustmotion-studio/src/editor/inspector.rs @@ -18,8 +18,9 @@ use crate::scenario::{ use super::annotations::AnnotationBox; use super::properties::{ - component_props, css_family, css_section_props, effective_element, engine_placeholder, - fill_to_value, is_multiline, parse_fill, slider_range, visible_sections, FillMode, PropKind, + component_props, css_family, css_section_props, display_number, effective_element, + engine_placeholder, fill_to_value, is_multiline, parse_fill, slider_range, visible_sections, + FillMode, PropKind, }; // ── Debounce context ───────────────────────────────────────────────────────── @@ -559,7 +560,8 @@ fn fmt_num(v: f64, step: f64) -> String { if step >= 1.0 { format!("{}", v.round() as i64) } else { - format!("{v:.2}") + // Round to 2 decimals, then shortest display: 1.40 → "1.4", 405.0 → "405". + ((v * 100.0).round() / 100.0).to_string() } } @@ -765,7 +767,7 @@ fn TimingRow( let p = pointer.clone(); let f = field.clone(); move |e: FormEvent| { - if let Ok(v) = parse_root_value(&PropKind::Number, &e.value()) { + if let Ok(v) = parse_root_value(&PropKind::Float, &e.value()) { write_root_field(&shared, &p, &f, v); } } @@ -841,8 +843,26 @@ fn parse_root_value(kind: &PropKind, text: &str) -> Result { + PropKind::Integer => { + // NEVER write a float into an integer field: `12.0` fails the + // typed parse ("invalid type: floating point") and would drop the + // element at render. + match t.parse::() { + Ok(i) => serde_json::Value::from(i), + Err(_) => { + 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 { + return Err(()); + } + } + } + } + PropKind::Float => { let f: f64 = t.parse().map_err(|_| ())?; + // Integral floats are written as JSON integers (clean source; f64 + // fields deserialize integers fine). if f.fract() == 0.0 && f.abs() < 9_007_199_254_740_992.0 { serde_json::Value::from(f as i64) } else { @@ -953,7 +973,7 @@ fn GenericRow( } } } - PropKind::Number => { + PropKind::Float => { 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)); @@ -993,14 +1013,27 @@ fn GenericRow( let commit = commit.clone(); rsx! { input { - r#type: "text", + r#type: "number", + step: "0.1", style: "{INPUT_STYLE}", - value: "{value}", + value: "{display_number(&value)}", oninput: move |e: FormEvent| commit(&e.value()), } } } } + PropKind::Integer => { + let commit = commit.clone(); + rsx! { + input { + r#type: "number", + step: "1", + style: "{INPUT_STYLE}", + value: "{display_number(&value)}", + oninput: move |e: FormEvent| commit(&e.value()), + } + } + } PropKind::String if is_multiline(&name, &value) => { // Long-form text (codeblock `code`, notification `message`, …). let commit = commit.clone(); @@ -1058,9 +1091,8 @@ fn GenericRow( 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" - } + // Discreet default marker; the row title explains it. + span { style: "margin-left:4px; color:var(--rm-text-muted); opacity:0.6;", "•" } } } div { style: "flex:1; min-width:0; display:flex; justify-content:flex-end;", {control} } diff --git a/crates/rustmotion-studio/src/editor/playback.rs b/crates/rustmotion-studio/src/editor/playback.rs index 27a6dcb..4cff975 100644 --- a/crates/rustmotion-studio/src/editor/playback.rs +++ b/crates/rustmotion-studio/src/editor/playback.rs @@ -1,10 +1,39 @@ use std::time::Duration; use dioxus::prelude::*; +use dioxus_icons::lucide::{Pause, Play}; use crate::components::button::{Button, ButtonSize, ButtonVariant}; use crate::scenario::Shared; +/// What a playback keyboard shortcut does (see [`playback_action`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlaybackAction { + TogglePlay, + /// Step the playhead by N frames (pausing first). + Step(i64), + SeekStart, + SeekEnd, +} + +/// Map a key press to a playback action: Space → toggle, arrows → ±1 frame +/// (±10 with Shift), Home/End → first/last frame. `None` for anything else or +/// when command modifiers are held (those belong to other shortcuts). +pub fn playback_action(key: &Key, mods: Modifiers) -> Option { + if mods.meta() || mods.ctrl() || mods.alt() { + return None; + } + let step = if mods.shift() { 10 } else { 1 }; + match key { + Key::Character(c) if c == " " && !mods.shift() => Some(PlaybackAction::TogglePlay), + Key::ArrowLeft => Some(PlaybackAction::Step(-step)), + Key::ArrowRight => Some(PlaybackAction::Step(step)), + Key::Home => Some(PlaybackAction::SeekStart), + Key::End => Some(PlaybackAction::SeekEnd), + _ => None, + } +} + /// Advance the playhead at the scenario fps while `playing` is true. A custom /// hook so the editor view stays focused on layout. pub fn use_playback_clock(shared: Shared, mut current: Signal, playing: Signal) { @@ -73,12 +102,23 @@ pub fn PlaybackBar( let side = diff_side(); rsx! { - div { style: "display:flex; align-items:center; gap:12px; padding:12px 20px; border-top:1px solid var(--rm-border); background:var(--rm-surface-2);", + div { + style: "display:flex; align-items:center; gap:12px; padding:12px 20px; border-top:1px solid var(--rm-border); background:var(--rm-surface-2);", + // Focused transport controls behave natively (arrows on the range + // slider step the frame, Space re-activates the focused button — + // both ARE playback actions); stopping propagation prevents the + // root shortcut handler from double-applying them. + onkeydown: move |evt: KeyboardEvent| evt.stop_propagation(), Button { variant: ButtonVariant::Secondary, - size: ButtonSize::Sm, + size: ButtonSize::IconSm, + title: if is_playing { "Pause (Space)" } else { "Play (Space)" }, onclick: move |_| playing.set(!playing()), - if is_playing { "Pause" } else { "Play" } + if is_playing { + Pause { size: 15 } + } else { + Play { size: 15 } + } } if diff_active() { div { class: "rm-seg", style: "width:auto; flex:none;", @@ -128,3 +168,61 @@ pub fn PlaybackBar( } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn ch(s: &str) -> Key { + Key::Character(s.to_string()) + } + + #[test] + fn space_toggles_play() { + assert_eq!( + playback_action(&ch(" "), Modifiers::empty()), + Some(PlaybackAction::TogglePlay) + ); + } + + #[test] + fn arrows_step_one_or_ten() { + assert_eq!( + playback_action(&Key::ArrowLeft, Modifiers::empty()), + Some(PlaybackAction::Step(-1)) + ); + assert_eq!( + playback_action(&Key::ArrowRight, Modifiers::empty()), + Some(PlaybackAction::Step(1)) + ); + assert_eq!( + playback_action(&Key::ArrowRight, Modifiers::SHIFT), + Some(PlaybackAction::Step(10)) + ); + assert_eq!( + playback_action(&Key::ArrowLeft, Modifiers::SHIFT), + Some(PlaybackAction::Step(-10)) + ); + } + + #[test] + fn home_end_seek() { + assert_eq!( + playback_action(&Key::Home, Modifiers::empty()), + Some(PlaybackAction::SeekStart) + ); + assert_eq!( + playback_action(&Key::End, Modifiers::empty()), + Some(PlaybackAction::SeekEnd) + ); + } + + #[test] + fn unhandled_or_modified_keys_are_none() { + assert_eq!(playback_action(&ch("z"), Modifiers::empty()), None); + assert_eq!(playback_action(&ch(" "), Modifiers::META), None); + assert_eq!(playback_action(&ch(" "), Modifiers::SHIFT), None); + assert_eq!(playback_action(&Key::ArrowRight, Modifiers::CONTROL), None); + assert_eq!(playback_action(&Key::Enter, Modifiers::empty()), None); + } +} diff --git a/crates/rustmotion-studio/src/editor/properties.rs b/crates/rustmotion-studio/src/editor/properties.rs index 85c239a..8957ad6 100644 --- a/crates/rustmotion-studio/src/editor/properties.rs +++ b/crates/rustmotion-studio/src/editor/properties.rs @@ -63,7 +63,13 @@ pub const EXCLUDED_FIELDS: &[&str] = &[ /// How a schema field is edited by the generic control factory. #[derive(Debug, Clone, PartialEq)] pub enum PropKind { - Number, + /// Schema `"type": "integer"` (u8/u32/i64… — schemars adds a + /// `format: uint8/uint32/…` hint). Writes REAL JSON integers: a `12.0` + /// written into a `u32` field fails the typed parse ("invalid type: + /// floating point") and would drop the element at render. + Integer, + /// Schema `"type": "number"` (f32/f64 — `format: float/double`). + Float, Bool, String, /// String enum with the exact variants from the schema. @@ -80,6 +86,16 @@ pub enum PropKind { Complex, } +/// Shortest display form of a numeric raw string: `"405.0"` → `"405"`, +/// `"1.40"` → `"1.4"`, `"100"` → `"100"` (Rust's f64 Display is +/// shortest-round-trip). Non-numeric input passes through unchanged. +pub fn display_number(raw: &str) -> String { + raw.trim() + .parse::() + .map(|v| v.to_string()) + .unwrap_or_else(|_| raw.to_string()) +} + #[derive(Debug, Clone, PartialEq)] pub struct PropSpec { pub name: String, @@ -265,7 +281,8 @@ fn kind_of_schema(name: &str, schema: &Value, defs: &Value, depth: u8) -> PropKi } } match primary_type(schema) { - Some("number") | Some("integer") => PropKind::Number, + Some("integer") => PropKind::Integer, + Some("number") => PropKind::Float, Some("boolean") => PropKind::Bool, Some("string") if name.contains("color") || name.contains("colour") => PropKind::Color, Some("string") => PropKind::String, @@ -565,9 +582,9 @@ mod tests { #[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, "from"), Some(&PropKind::Float)); + assert_eq!(kind_of(props, "to"), Some(&PropKind::Float)); + assert_eq!(kind_of(props, "decimals"), Some(&PropKind::Integer)); 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)); @@ -671,7 +688,7 @@ mod tests { // 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_eq!(kind_of(all, "opacity"), Some(&PropKind::Float)); assert!(matches!(kind_of(all, "display"), Some(PropKind::Enum(_)))); assert!(matches!( kind_of(all, "width"), @@ -718,6 +735,49 @@ mod tests { } } + // ── Round 3: Integer/Float split + display ────────────────────────── + + #[test] + fn integer_and_float_split_follows_the_schema() { + // f64 → "type": "number" → Float. + let gauge = component_props("gauge").expect("gauge in schema"); + assert_eq!(kind_of(gauge, "value"), Some(&PropKind::Float)); + // Option → "type": "integer" (format uint32) → Integer. + let badge = component_props("badge").expect("badge in schema"); + assert_eq!(kind_of(badge, "count"), Some(&PropKind::Integer)); + // u8 → "type": "integer" (format uint8) → Integer. + let counter = component_props("counter").expect("counter in schema"); + assert_eq!(kind_of(counter, "decimals"), Some(&PropKind::Integer)); + } + + #[test] + fn display_number_trims_trailing_zeros() { + assert_eq!(display_number("100"), "100"); + assert_eq!(display_number("405.0"), "405"); + assert_eq!(display_number("1.40"), "1.4"); + assert_eq!(display_number("0.5"), "0.5"); + assert_eq!(display_number("72.0"), "72"); + assert_eq!(display_number("not-a-number"), "not-a-number"); + assert_eq!(display_number(""), ""); + } + + #[test] + fn integer_write_round_trips_through_the_typed_parse() { + // Writing 12 into badge.count must produce a REAL JSON integer: a + // 12.0 float makes from_value:: fail ("invalid type: + // floating point") and the element would be dropped at render. + let raw = serde_json::json!({"type": "badge", "text": "New", "count": 12}); + assert!( + serde_json::from_value::(raw).is_ok(), + "integer count parses" + ); + let bad = serde_json::json!({"type": "badge", "text": "New", "count": 12.0}); + assert!( + serde_json::from_value::(bad).is_err(), + "float in u32 field must fail — this is why Integer never writes floats" + ); + } + // ── Round 2: color editors / effective view / multiline ───────────── #[test] diff --git a/crates/rustmotion-studio/src/editor/topbar.rs b/crates/rustmotion-studio/src/editor/topbar.rs index f4c222c..41b06a1 100644 --- a/crates/rustmotion-studio/src/editor/topbar.rs +++ b/crates/rustmotion-studio/src/editor/topbar.rs @@ -125,7 +125,11 @@ pub fn TopBar( let can_diff = diff_available(); rsx! { - div { style: "position:relative; display:flex; align-items:center; justify-content:space-between; height:40px; padding:0 12px; border-bottom:1px solid var(--rm-border); background:var(--rm-surface-2); flex:none;", + div { + style: "position:relative; display:flex; align-items:center; justify-content:space-between; height:40px; padding:0 12px; border-bottom:1px solid var(--rm-border); background:var(--rm-surface-2); flex:none;", + // A focused topbar button owns its keys (Space activates IT, not + // play/pause) — same isolation pattern as the inspector panel. + onkeydown: move |evt: KeyboardEvent| evt.stop_propagation(), div { style: "display:flex; align-items:center; gap:8px; z-index:1;", Button { variant: ButtonVariant::Outline, diff --git a/crates/rustmotion-studio/src/editor/view.rs b/crates/rustmotion-studio/src/editor/view.rs index e426967..dc33009 100644 --- a/crates/rustmotion-studio/src/editor/view.rs +++ b/crates/rustmotion-studio/src/editor/view.rs @@ -12,7 +12,9 @@ use super::annotations::AnnotationsPanel; use super::diff_panel::{DiffPanel, DiffSide}; use super::frames::{baseline_arcs, frame_hits, render_frame, scene_prefix, HitPct}; use super::inspector::InspectorPanel; -use super::playback::{use_hot_reload, use_playback_clock, PlaybackBar}; +use super::playback::{ + playback_action, use_hot_reload, use_playback_clock, PlaybackAction, PlaybackBar, +}; use super::prefetch::{frame_cache, use_prefetch_publisher, FrameKey}; use super::topbar::TopBar; @@ -243,20 +245,42 @@ pub fn StudioApp(view: Signal) -> Element { let on_shortcut = { let shared = shared.clone(); let history = history.clone(); + let mut playing = playing; + let mut current = current; move |evt: KeyboardEvent| { let mods = evt.modifiers(); - if !(mods.meta() || mods.ctrl()) { - return; - } - let is_z = matches!(evt.key(), Key::Character(ref c) if c.eq_ignore_ascii_case("z")); - if !is_z { + let key = evt.key(); + // Undo / redo (Cmd/Ctrl+Z, Shift for redo). + if (mods.meta() || mods.ctrl()) + && matches!(key, Key::Character(ref c) if c.eq_ignore_ascii_case("z")) + { + evt.prevent_default(); + if mods.shift() { + redo(&shared, &history); + } else { + undo(&shared, &history); + } return; } - evt.prevent_default(); - if mods.shift() { - redo(&shared, &history); - } else { - undo(&shared, &history); + // Transport: Space toggles, arrows step (Shift = ×10), Home/End + // seek. prevent_default keeps Space from scrolling/activating and + // arrows from scrolling the page. + if let Some(action) = playback_action(&key, mods) { + evt.prevent_default(); + let max = { + let m = shared.lock().unwrap_or_else(|e| e.into_inner()); + m.total_frames.saturating_sub(1) + }; + match action { + PlaybackAction::TogglePlay => playing.set(!playing()), + PlaybackAction::Step(d) => { + playing.set(false); + let next = (current() as i64 + d).clamp(0, max as i64) as u32; + current.set(next); + } + PlaybackAction::SeekStart => current.set(0), + PlaybackAction::SeekEnd => current.set(max), + } } } };