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
15 changes: 15 additions & 0 deletions crates/rustmotion-studio/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,21 @@ fn spawn_watcher(shared: Shared) -> Sender<WatchMsg> {
}
WatchMsg::Changed => {
if let Some(p) = current.clone() {
// Self-write skip: if the disk content is exactly what
// this process last wrote (debounced write, undo), the
// in-memory model is already up to date — and possibly
// NEWER under continuous typing. Reloading would
// clobber it, so skip. External edits hash differently
// and reload normally.
if let Ok(content) = std::fs::read_to_string(&p) {
if crate::scenario::is_self_write(
&crate::scenario::self_write_slot(),
&p,
&content,
) {
continue;
}
}
let (scenario, error) = match rustmotion::loader::load_input(&p) {
Ok(s) => (s, None),
Err(e) => (crate::scenario::empty_scenario(), Some(e.to_string())),
Expand Down
60 changes: 55 additions & 5 deletions crates/rustmotion-studio/src/components/color_picker/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,35 @@ pub struct ColorPickerRootProps {
pub fn ColorPickerRoot(props: ColorPickerRootProps) -> Element {
let (open, set_open) = use_controlled(props.open, props.default_open, props.on_open_change);

// Local mirror of the color: the picker internals (area drag, hue slider,
// hex field) read/write THIS state, so every selection — including
// mid-drag moves — previews live in the popover AND propagates through
// `on_color_change` immediately, independent of whether the parent echoes
// the new color back through the `color` prop.
let mut local = use_signal(|| (props.color)());
use_effect(move || {
let external = (props.color)();
if external != *local.peek() {
local.set(external);
}
});
let forward = props.on_color_change;
let on_change = move |c: Hsv<encoding::Srgb, f64>| {
local.set(c);
forward.call(c);
};

use_context_provider(|| ColorPickerRootContext {
open,
disabled: props.disabled,
color: props.color,
color: local.into(),
});

rsx! {
color_picker::ColorPicker {
class: "dx-color-picker",
color: props.color,
on_color_change: props.on_color_change,
color: local(),
on_color_change: on_change,
disabled: props.disabled,
attributes: props.attributes,
popover::PopoverRoot {
Expand Down Expand Up @@ -190,11 +208,43 @@ pub struct ColorPickerPopoverProps {

#[component]
pub fn ColorPickerPopover(props: ColorPickerPopoverProps) -> Element {
let ctx = use_context::<ColorPickerRootContext>();
// 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.
let mut node = use_signal(|| None::<std::rc::Rc<MountedData>>);
let mut flip_up = use_signal(|| false);
use_effect(move || {
if (ctx.open)() {
if let Some(n) = 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::<f64>(win.scale_factor())
.height;
flip_up.set(rect.origin.y + rect.size.height > vh - 8.0);
}
});
}
}
});
let class = if flip_up() {
"dx-color-picker-popover flip-up"
} else {
"dx-color-picker-popover"
};

rsx! {
popover::PopoverContent {
class: "dx-color-picker-popover".to_string(),
class: class.to_string(),
attributes: props.attributes,
{props.children}
div {
onmounted: move |e| node.set(Some(e.data())),
{props.children}
}
}
}
}
Expand Down
14 changes: 13 additions & 1 deletion crates/rustmotion-studio/src/components/color_picker/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,10 @@
position: absolute;
z-index: 1000;
top: 100%;
left: 0;
/* The inspector docks against the right window edge: anchor the popover's
right edge to the swatch so it opens LEFTWARD instead of clipping. */
right: 0;
left: auto;
display: block;
border-radius: 0.5rem;
margin-top: 4px;
Expand Down Expand Up @@ -240,3 +243,12 @@
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;
}
99 changes: 88 additions & 11 deletions crates/rustmotion-studio/src/editor/inspector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ use crate::components::select::{Select, SelectOption};
use crate::components::slider::Slider;
use crate::components::switch::Switch;
use crate::scenario::{
scene_duration_for_pointer, set_field, set_field_value, set_style, set_style_value, Shared,
apply_optimistic, scene_duration_for_pointer, set_field, set_field_value, set_style,
set_style_value, Mutation, Shared,
};

use super::view::RevSignal;

use super::annotations::AnnotationBox;
use super::properties::{
component_props, css_family, css_section_props, display_number, effective_element,
Expand Down Expand Up @@ -422,6 +425,13 @@ fn family(kind: &str) -> Family {
}
}

/// Text-family elements edit their content first: the CONTENT section renders
/// ABOVE Properties. Other families keep Properties on top (and have no
/// content editor).
fn content_before_properties(kind: &str) -> bool {
family(kind) == Family::Text
}

const TEXT_SECTIONS: &[Section] = &[
Section {
title: "Typography",
Expand Down Expand Up @@ -618,10 +628,10 @@ pub fn InspectorPanel(
"✕"
}
}
RootPropsSection { pointer: pointer.clone(), kind: kind.clone(), element }
if fam == Family::Text {
if content_before_properties(&kind) {
ContentEditor { pointer: pointer.clone(), content: content.clone().unwrap_or_default() }
}
RootPropsSection { pointer: pointer.clone(), kind: kind.clone(), element }
for section in sections(fam) {
SectionView {
key: "{section.title}",
Expand Down Expand Up @@ -1812,6 +1822,14 @@ fn schedule_write(debounce: &WriteDebounce, shared: Shared, payload: WritePayloa
*debounce.0.borrow_mut() = Some(task);
}

/// Write scenario-file content and record it in the self-write ledger so the
/// watcher skips the resulting event (the in-memory model is already ahead).
fn write_and_note(path: &std::path::Path, content: &str) -> Result<(), String> {
std::fs::write(path, content).map_err(|e| format!("write: {e}"))?;
crate::scenario::note_self_write(&crate::scenario::self_write_slot(), path, content);
Ok(())
}

/// Execute the actual file write. Returns `Ok(true)` when the file was
/// written, `Ok(false)` when the edit was a no-op (nothing to record in the
/// undo history), or an error message.
Expand All @@ -1829,13 +1847,13 @@ fn perform_write(payload: &WritePayload) -> Result<bool, String> {
if let Some(updated) =
rustmotion::loader::set_html_inline_style(&html, pointer, prop, value)
{
std::fs::write(path, updated).map_err(|e| format!("write: {e}"))?;
write_and_note(path, &updated)?;
return Ok(true);
}
} else if let Some(updated) = set_style(raw.clone(), pointer, prop, value) {
let text =
serde_json::to_string_pretty(&updated).map_err(|e| format!("json: {e}"))?;
std::fs::write(path, text).map_err(|e| format!("write: {e}"))?;
write_and_note(path, &text)?;
return Ok(true);
}
Ok(false)
Expand All @@ -1851,12 +1869,12 @@ fn perform_write(payload: &WritePayload) -> Result<bool, String> {
if let Some(updated) =
rustmotion::loader::set_html_text_content(&html, pointer, text)
{
std::fs::write(path, updated).map_err(|e| format!("write: {e}"))?;
write_and_note(path, &updated)?;
return Ok(true);
}
} else if let Some(updated) = set_field(raw.clone(), pointer, "content", text) {
let s = serde_json::to_string_pretty(&updated).map_err(|e| format!("json: {e}"))?;
std::fs::write(path, s).map_err(|e| format!("write: {e}"))?;
write_and_note(path, &s)?;
return Ok(true);
}
Ok(false)
Expand All @@ -1874,14 +1892,14 @@ fn perform_write(payload: &WritePayload) -> Result<bool, String> {
if let Some(updated) =
rustmotion::loader::set_html_attribute(&html, pointer, field, &attr)
{
std::fs::write(path, updated).map_err(|e| format!("write: {e}"))?;
write_and_note(path, &updated)?;
return Ok(true);
}
} else if let Some(updated) =
set_field_value(raw.clone(), pointer, field, value.clone())
{
let s = serde_json::to_string_pretty(&updated).map_err(|e| format!("json: {e}"))?;
std::fs::write(path, s).map_err(|e| format!("write: {e}"))?;
write_and_note(path, &s)?;
return Ok(true);
}
Ok(false)
Expand All @@ -1897,21 +1915,34 @@ fn perform_write(payload: &WritePayload) -> Result<bool, String> {
if let Some(updated) =
rustmotion::loader::remove_html_inline_style(&html, pointer, prop)
{
std::fs::write(path, updated).map_err(|e| format!("write: {e}"))?;
write_and_note(path, &updated)?;
return Ok(true);
}
} else if let Some(updated) =
set_style_value(raw.clone(), pointer, prop, serde_json::Value::Null)
{
let s = serde_json::to_string_pretty(&updated).map_err(|e| format!("json: {e}"))?;
std::fs::write(path, s).map_err(|e| format!("write: {e}"))?;
write_and_note(path, &s)?;
return Ok(true);
}
Ok(false)
}
}
}

/// Apply an edit to the in-memory model immediately (canvas refreshes in ~one
/// render) and nudge the hot-reload signal. Rebuild failures are transient
/// (mid-typing) and silently keep the previous model — the disk write path
/// has its own guards.
fn optimistic(shared: &Shared, mutation: Mutation) {
if apply_optimistic(shared, &mutation).is_ok() {
if let Some(rev) = try_consume_context::<RevSignal>() {
let mut r = rev.0;
r.set(r() + 1);
}
}
}

/// Write a single style property back to the scenario file. Schedules a debounced
/// write (~250 ms) so rapid slider drags and keystrokes coalesce into one flush.
/// Empty values are ignored so clearing a field mid-edit doesn't collapse the
Expand All @@ -1921,6 +1952,14 @@ fn write_prop(shared: &Shared, pointer: &str, prop: &str, value: &str) {
if value.trim().is_empty() {
return;
}
optimistic(
shared,
Mutation::Style {
pointer: pointer.to_string(),
prop: prop.to_string(),
value: serde_json::Value::String(value.to_string()),
},
);
let (path, raw) = {
let m = shared.lock().unwrap_or_else(|e| e.into_inner());
(m.path.clone(), m.raw.clone())
Expand All @@ -1946,6 +1985,14 @@ fn write_prop(shared: &Shared, pointer: &str, prop: &str, value: &str) {
/// `Value::Null` removes the field (JSON) / the attribute (HTML). Debounced
/// with the same guarantees as [`write_prop`].
fn write_root_field(shared: &Shared, pointer: &str, field: &str, value: serde_json::Value) {
optimistic(
shared,
Mutation::Field {
pointer: pointer.to_string(),
field: field.to_string(),
value: value.clone(),
},
);
let (path, raw) = {
let m = shared.lock().unwrap_or_else(|e| e.into_inner());
(m.path.clone(), m.raw.clone())
Expand All @@ -1970,6 +2017,14 @@ fn write_root_field(shared: &Shared, pointer: &str, field: &str, value: serde_js
/// Remove one style property (an emptied generic control unsets the key /
/// declaration rather than writing an empty string). Debounced.
fn write_style_removal(shared: &Shared, pointer: &str, prop: &str) {
optimistic(
shared,
Mutation::Style {
pointer: pointer.to_string(),
prop: prop.to_string(),
value: serde_json::Value::Null,
},
);
let (path, raw) = {
let m = shared.lock().unwrap_or_else(|e| e.into_inner());
(m.path.clone(), m.raw.clone())
Expand All @@ -1994,6 +2049,14 @@ fn write_style_removal(shared: &Shared, pointer: &str, prop: &str) {
/// [`write_prop`], an empty value is allowed (clearing the text is valid).
/// Schedules a debounced write (~250 ms); errors are surfaced in the topbar.
fn write_content(shared: &Shared, pointer: &str, text: &str) {
optimistic(
shared,
Mutation::Field {
pointer: pointer.to_string(),
field: "content".to_string(),
value: serde_json::Value::String(text.to_string()),
},
);
let (path, raw) = {
let m = shared.lock().unwrap_or_else(|e| e.into_inner());
(m.path.clone(), m.raw.clone())
Expand All @@ -2013,3 +2076,17 @@ fn write_content(shared: &Shared, pointer: &str, text: &str) {
},
);
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn text_family_shows_content_before_properties() {
assert!(content_before_properties("text"));
assert!(content_before_properties("caption"));
// Non-text components keep the current order (no content editor).
assert!(!content_before_properties("gauge"));
assert!(!content_before_properties("card"));
}
}
7 changes: 7 additions & 0 deletions crates/rustmotion-studio/src/editor/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ use super::playback::{
use super::prefetch::{frame_cache, use_prefetch_publisher, FrameKey};
use super::topbar::TopBar;

/// The hot-reload revision signal, exposed via context so the optimistic edit
/// path can nudge the canvas immediately instead of waiting for the 250 ms
/// generation poll.
#[derive(Clone, Copy)]
pub struct RevSignal(pub Signal<u64>);

/// Overlay element boxes: invisible by default. Only the single hovered element
/// (`.hov`) gets a dashed outline, and the selected element (`.sel`) a solid
/// blue box. "Hovered" is tracked explicitly (one node at a time) rather than
Expand Down Expand Up @@ -131,6 +137,7 @@ pub fn StudioApp(view: Signal<View>) -> Element {

use_playback_clock(shared.clone(), current, playing);
use_hot_reload(shared.clone(), rev);
use_context_provider(|| RevSignal(rev));
// Publish the playhead/side/model snapshot for the background prefetcher.
use_prefetch_publisher(
shared.clone(),
Expand Down
Loading
Loading