Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions crates/rustmotion-studio/src/app/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
54 changes: 43 additions & 11 deletions crates/rustmotion-studio/src/editor/inspector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -841,8 +843,26 @@ fn parse_root_value(kind: &PropKind, text: &str) -> Result<serde_json::Value, ()
return Ok(serde_json::Value::Null);
}
Ok(match kind {
PropKind::Number => {
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::<i64>() {
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 {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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} }
Expand Down
104 changes: 101 additions & 3 deletions crates/rustmotion-studio/src/editor/playback.rs
Original file line number Diff line number Diff line change
@@ -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<PlaybackAction> {
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<u32>, playing: Signal<bool>) {
Expand Down Expand Up @@ -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;",
Expand Down Expand Up @@ -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);
}
}
72 changes: 66 additions & 6 deletions crates/rustmotion-studio/src/editor/properties.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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::<f64>()
.map(|v| v.to_string())
.unwrap_or_else(|_| raw.to_string())
}

#[derive(Debug, Clone, PartialEq)]
pub struct PropSpec {
pub name: String,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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<u32> → "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::<Component> 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::<Component>(raw).is_ok(),
"integer count parses"
);
let bad = serde_json::json!({"type": "badge", "text": "New", "count": 12.0});
assert!(
serde_json::from_value::<Component>(bad).is_err(),
"float in u32 field must fail — this is why Integer never writes floats"
);
}

// ── Round 2: color editors / effective view / multiline ─────────────

#[test]
Expand Down
6 changes: 5 additions & 1 deletion crates/rustmotion-studio/src/editor/topbar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading