From e5612b9712b8ae255e7521659a4878d0380e9486 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Wed, 9 Sep 2026 20:57:12 -0700 Subject: [PATCH] fix(ui): preserve Studio layer controls across scene updates Keep effect widgets mounted while authoritative scene values refresh so edits retain scroll position and focus. Reconcile snapshots beneath pending writes, restore rejected values, and cache schemas across layer replacements without retaining retired mutation targets. Coalesce scene invalidations and avoid unrelated face and primary-effect reloads. Cover control edits, remote updates, replacement revisions, failed writes, and advanced-panel scrolling with browser regressions. Co-Authored-By: Nova (Codex) --- crates/hypercolor-ui/src/app.rs | 14 +- .../src/components/layer_panel/controls.rs | 148 ++++++---- .../src/components/layer_panel/mod.rs | 189 +++++++------ .../src/components/layer_panel/row.rs | 160 +++++------ crates/hypercolor-ui/src/control_session.rs | 10 + .../hypercolor-ui/src/optimistic_controls.rs | 74 ++++- crates/hypercolor-ui/src/pages/studio/mod.rs | 30 +- crates/hypercolor-ui/src/ws/messages.rs | 59 ++++ crates/hypercolor-ui/src/zones.rs | 25 +- .../hypercolor-ui/tests/layer_panel_tests.rs | 32 +++ .../hypercolor-ui/tests/ws_messages_tests.rs | 142 +++++++++- e2e/tests/studio-controls.spec.mjs | 262 ++++++++++++++++++ 12 files changed, 869 insertions(+), 276 deletions(-) create mode 100644 e2e/tests/studio-controls.spec.mjs diff --git a/crates/hypercolor-ui/src/app.rs b/crates/hypercolor-ui/src/app.rs index 982a984a6..e9acea577 100644 --- a/crates/hypercolor-ui/src/app.rs +++ b/crates/hypercolor-ui/src/app.rs @@ -38,7 +38,7 @@ use crate::preferences::PreferencesStore; use crate::preview_telemetry::{PreviewPresenterTelemetry, PreviewTelemetryContext}; use crate::thumbnails::{self, ThumbnailStore}; use crate::toasts; -use crate::ws::messages::scene_event_affects_active_effect; +use crate::ws::messages::scene_event_requires_effect_refresh; use crate::ws::{ AudioLevel, BackpressureNotice, CanvasFrame, ControlSurfaceEventHint, DeviceEventHint, EffectErrorHint, ExtensionEventHint, InputInjectEdge, InputSourceStatusEventHint, @@ -934,10 +934,14 @@ pub fn app_view(ext: UiExtensions) -> impl IntoView { .set_active_scene_mutation_mode .set(Some(scene_mutation_mode)); } - if current_scene_event - .as_ref() - .is_none_or(scene_event_affects_active_effect) - { + let target = effects_ctx.active_effect_target.get_untracked(); + if current_scene_event.as_ref().is_none_or(|current| { + scene_event_requires_effect_refresh( + previous_scene_event.as_ref().and_then(Option::as_ref), + current, + target.as_ref().map(|target| target.zone_id.as_str()), + ) + }) { effects_ctx.refresh_active_effect(); } diff --git a/crates/hypercolor-ui/src/components/layer_panel/controls.rs b/crates/hypercolor-ui/src/components/layer_panel/controls.rs index 2948a03d0..1ba61d1c0 100644 --- a/crates/hypercolor-ui/src/components/layer_panel/controls.rs +++ b/crates/hypercolor-ui/src/components/layer_panel/controls.rs @@ -37,35 +37,57 @@ const LAYER_CONTROLS_DEBOUNCE_MS: f64 = 120.0; #[component] pub fn EffectControlsSection( zone_id: String, - layer: SceneLayer, + layer: Signal, + effect_cache: super::LayerEffectCache, on_layers_mutated: Callback<()>, ) -> impl IntoView { let LayerSource::Effect { effect_id, controls, .. - } = layer.source.clone() + } = layer.get_untracked().source else { return ().into_any(); }; let effect_id_str = effect_id.to_string(); + let ws = expect_context::(); let detail = api::daemon_resource({ let effect_id_str = effect_id_str.clone(); move || { let effect_id_str = effect_id_str.clone(); - async move { api::fetch_effect_detail(&effect_id_str).await } + let generation = ws.connection_generation.get(); + async move { + if let Some((epoch, detail)) = + effect_cache.with_value(|cache| cache.get(&effect_id_str).cloned()) + && epoch == generation + { + return Ok(detail); + } + let detail = api::fetch_effect_detail(&effect_id_str).await?; + effect_cache.try_update_value(|cache| { + cache.insert(effect_id_str, (generation, detail.clone())); + }); + Ok::<_, api::ApiError>(detail) + } } }); + // A same-effect layer replacement retires its write session, but its + // controls need not collapse to a loading placeholder while it mounts. + let detail_value = Signal::derive(move || { + detail.get().and_then(Result::ok).or_else(|| { + effect_cache + .with_value(|cache| cache.get(&effect_id_str).map(|(_, detail)| detail.clone())) + }) + }); let defs = Signal::derive(move || { - detail + detail_value .get() - .and_then(Result::ok) .map(|detail| detail.controls) .unwrap_or_default() }); let screen_reactive = Signal::derive(move || { - detail.get().and_then(Result::ok).is_some_and(|detail| { + detail_value.get().is_some_and(|detail| { detail .tags .iter() @@ -76,7 +98,7 @@ pub fn EffectControlsSection( // Optimistic local control values. Layer identity fences stale patches, // so the canonical control route carries no revision token. let (values, set_values) = signal(controls); - let layer_id = layer.id.to_string(); + let layer_id = layer.get_untracked().id.to_string(); let session_target = Signal::stored(Some(format!("{zone_id}:{layer_id}"))); let patch: ControlPatchFn = Arc::new({ @@ -103,39 +125,44 @@ pub fn EffectControlsSection( on_error: Callback::new(|error: String| { toasts::toast_error(&format!("Effect controls failed: {error}")); }), - recover: on_layers_mutated, + recover: Callback::new(move |()| { + // A rejected edit can leave the server snapshot unchanged, so + // memo equality will not trigger the normal reconciliation effect. + if let LayerSource::Effect { controls, .. } = layer.get_untracked().source { + set_values.set(controls); + } + on_layers_mutated.run(()); + }), on_committed: None, flush_guard: None, }); + Effect::new(move |_| { + if let LayerSource::Effect { controls, .. } = layer.get().source { + session.reconcile_values.run(controls); + } + }); let on_change = session.on_change; view! {
"Effect controls" - {move || { - if detail.get().is_none() { - view! { -
- "Loading controls…" -
- } - .into_any() - } else { - view! { - - - } - .into_any() + "Loading controls…"
} - }} + > + + + } .into_any() @@ -147,26 +174,31 @@ pub fn EffectControlsSection( #[component] pub fn MediaPlaybackSection( zone_id: String, - layer: SceneLayer, - revision: u64, + layer: Signal, + revision: Signal, on_layers_mutated: Callback<()>, ) -> impl IntoView { - let LayerSource::Media { playback, .. } = layer.source.clone() else { + let LayerSource::Media { playback, .. } = layer.get_untracked().source else { return ().into_any(); }; - let speed = playback.speed; - let loop_mode = playback.loop_mode; - let auto_play = playback.auto_play; + let playback = Memo::new(move |_| match layer.get().source { + LayerSource::Media { playback, .. } => playback, + _ => playback.clone(), + }); // Rebuild the layer with a mutated `MediaPlayback` and push it. let push = { - let layer = layer.clone(); move |mutate: &dyn Fn(&mut hypercolor_types::layer::MediaPlayback)| { - let mut next = layer.clone(); + let mut next = layer.get_untracked(); if let LayerSource::Media { playback, .. } = &mut next.source { mutate(playback); } - update_layer(zone_id.clone(), next, revision, on_layers_mutated); + update_layer( + zone_id.clone(), + next, + revision.get_untracked(), + on_layers_mutated, + ); } }; let push_speed = push.clone(); @@ -177,12 +209,14 @@ pub fn MediaPlaybackSection( ("ping_pong".to_owned(), "Ping-pong".to_owned()), ("none".to_owned(), "Play once".to_owned()), ]; - let loop_value = match loop_mode { - LoopMode::Loop => "loop", - LoopMode::PingPong => "ping_pong", - LoopMode::None => "none", - } - .to_owned(); + let loop_value = move || { + match playback.get().loop_mode { + LoopMode::Loop => "loop", + LoopMode::PingPong => "ping_pong", + LoopMode::None => "none", + } + .to_owned() + }; view! {
@@ -195,17 +229,17 @@ pub fn MediaPlaybackSection( max="4" step="0.05" class="w-full accent-accent" - prop:value=format!("{speed:.2}") + prop:value=move || format!("{:.2}", playback.get().speed) on:change=move |event| { if let Some(value) = Change::from_event(event).value::() { push_speed(&|playback| playback.speed = value.clamp(0.1, 4.0)); } } /> - {format!("{speed:.2}×")} + {move || format!("{:.2}×", playback.get().speed)} "Auto-play" - +
} .into_any() } -/// A compact Luminary toggle track — the switch visual without its own -/// label row, for embedding in a layer card. The card rebuilds on every -/// layer change, so a plain `bool` tracks state without a signal. +/// A compact toggle track that follows the current layer playback state. #[component] -pub fn LayerToggleTrack(on: bool) -> impl IntoView { +pub fn LayerToggleTrack(#[prop(into)] on: Signal) -> impl IntoView { view! { } diff --git a/crates/hypercolor-ui/src/components/layer_panel/mod.rs b/crates/hypercolor-ui/src/components/layer_panel/mod.rs index fcd374125..8fda324d2 100644 --- a/crates/hypercolor-ui/src/components/layer_panel/mod.rs +++ b/crates/hypercolor-ui/src/components/layer_panel/mod.rs @@ -51,6 +51,9 @@ use source::{ resolve_add_layer_targets, }; +/// Effect schemas survive layer replacements; a reconnect invalidates their epoch. +pub type LayerEffectCache = StoredValue>; + /// Layer-stack editor for one zone. See the module docs for the /// mount contract. #[component] @@ -66,6 +69,10 @@ pub fn LayerPanel( layers_resource: LocalResource>, on_layers_mutated: Callback<()>, ) -> impl IntoView { + let effect_cache = LayerEffectCache::new(HashMap::new()); + // Disclosure preference belongs to a visible stack slot, independently of + // the fresh layer authority minted by a whole-layer replacement. + let disclosures = StoredValue::new(HashMap::<(String, String, usize), bool>::new()); // Content selection is owned here, not driven by the host page — the // asset list backs both media-name resolution and the picker's Media tab. let assets_resource = api::daemon_resource(|| async { api::list_assets().await }); @@ -105,6 +112,31 @@ pub fn LayerPanel( .map(|stack| stack.revision) }); + let layers = Memo::new(move |_| { + layers_resource + .get() + .and_then(Result::ok) + .map(|stack| stack.items) + .unwrap_or_default() + }); + let scene_id = Memo::new(move |_| { + active_scene + .get() + .map(|scene| scene.id.to_string()) + .unwrap_or_default() + }); + let revision = Signal::derive(move || scene_revision.get().unwrap_or_default()); + let row_keys = Memo::new(move |_| { + let scene = scene_id.get(); + let zone = selected_zone_id.get().unwrap_or_default(); + layers + .get() + .into_iter() + .rev() + .map(|layer| (scene.clone(), zone.clone(), layer_mount_key(&layer))) + .collect::>() + }); + // Per-layer runtime health streams in over the WebSocket, independent // of the layer stack itself; an absent context means no health yet. let ws = use_context::(); @@ -251,98 +283,62 @@ pub fn LayerPanel( "Add layer" - }> - {move || match layers_resource.get() { - None => view! { }.into_any(), - Some(Err(error)) => view! { -
- {error.to_string()} -
- }.into_any(), - Some(Ok(stack)) if stack.items.is_empty() => { - // A screen may still be painting its stored - // default face; only the scene's own stack is - // empty, and the card above the panel says so. - let copy = if selected_zone_role.get() == Some(ZoneRole::Display) { - "No scene layers on this screen" - } else { - "No layers in this zone" - }; - view! { -
- {copy} -
- }.into_any() - } - Some(Ok(stack)) => { - // `try_get`: Suspense can re-poll this closure after - // the panel instance owning these memos is disposed - // (host swapped the mounted view mid-fetch). A plain - // `get` panics the whole reactive runtime there. - let (Some(names), Some(effect_name_map)) = - (media_names.try_get(), effect_names.try_get()) - else { - return view! { }.into_any(); - }; - let scene_id = active_scene - .get() - .map(|scene| scene.id.to_string()) - .unwrap_or_default(); - let zone_id = selected_zone_id.get().unwrap_or_default(); - let revision = stack.revision; - let total = stack.items.len(); - let mut rows = stack - .items - .iter() - .cloned() - .enumerate() - .collect::>(); - rows.reverse(); - // The Top/Bottom stack markers orient a real - // stack; with a single layer there is no - // ordering to convey, so they stay hidden. - let show_stack_markers = total > 1; + + + + {move || layers_resource.get().and_then(Result::err).map(|error| view! { +
{error.to_string()}
+ })} + +
+ {move || if selected_zone_role.get() == Some(ZoneRole::Display) { "No scene layers on this screen" } else { "No layers in this zone" }} +
+
+
+ 1)> +
"Top"
+
+ - {show_stack_markers.then(|| view! { -
- "Top" -
- })} - {rows.into_iter().map(|(stack_index, layer)| { - let row_health_key = layer_health_key( - &scene_id, - &zone_id, - &layer.id.to_string(), - ); - let row_health = Signal::derive(move || { - layer_health.with(|map| map.get(&row_health_key).cloned()) - }); - view! { - - } - }).collect_view()} - {show_stack_markers.then(|| view! { -
- "Bottom" -
- })} -
- }.into_any() + + } } - }} -
+ /> + 1)> +
"Bottom"
+
+ @@ -436,3 +432,16 @@ fn reorder_layer( } }); } + +/// Keep editors mounted across value/revision changes, but retire a session +/// when its layer or source identity changes. +#[must_use] +pub fn layer_mount_key(layer: &SceneLayer) -> (String, String) { + use hypercolor_types::layer::LayerSource; + let source = match &layer.source { + LayerSource::Effect { effect_id, .. } => format!("effect:{effect_id}"), + LayerSource::Media { asset_id, .. } => format!("media:{asset_id}"), + other => row::source_meta(other).2.to_owned(), + }; + (layer.id.to_string(), source) +} diff --git a/crates/hypercolor-ui/src/components/layer_panel/row.rs b/crates/hypercolor-ui/src/components/layer_panel/row.rs index 41085e276..f2619bc84 100644 --- a/crates/hypercolor-ui/src/components/layer_panel/row.rs +++ b/crates/hypercolor-ui/src/components/layer_panel/row.rs @@ -64,46 +64,34 @@ pub fn layer_title( #[component] pub fn LayerRow( zone_id: String, - layer: SceneLayer, - stack_index: usize, - total_layers: usize, - stack: Vec, - revision: u64, - media_names: HashMap, - effect_names: HashMap, + layer: Signal, + stack_index: Signal, + stack: Signal>, + revision: Signal, + media_names: Memo>, + effect_names: Memo>, + effect_cache: super::LayerEffectCache, + expanded: bool, + on_disclosure: Callback, #[prop(into)] health: Signal>, on_layers_mutated: Callback<()>, ) -> impl IntoView { - let (icon, accent_rgb, kind_word) = source_meta(&layer.source); - let title = layer_title(&layer, &media_names, &effect_names, kind_word); - let layer_id = layer.id.to_string(); - let is_effect = matches!(layer.source, LayerSource::Effect { .. }); - let is_media = matches!(layer.source, LayerSource::Media { .. }); - // A lone layer has nowhere to move; reorder appears only in a stack. - let show_reorder = total_layers > 1; - let can_move_up = stack_index + 1 < total_layers; - let can_move_down = stack_index > 0; - let opacity = layer.opacity; - let blend = layer.blend; - let fit = layer.transform.fit; - let brightness = layer.adjust.brightness; - let saturation = layer.adjust.saturation; - let tint_strength = layer.adjust.tint_strength; - let scale_x = layer.transform.scale[0]; - let scale_y = layer.transform.scale[1]; - - let blend_layer = layer.clone(); - let opacity_layer = layer.clone(); - let fit_layer = layer.clone(); - let brightness_layer = layer.clone(); - let saturation_layer = layer.clone(); - let tint_layer = layer.clone(); - let scale_x_layer = layer.clone(); - let scale_y_layer = layer.clone(); - let effect_layer = layer.clone(); - let media_layer = layer.clone(); - let move_up_stack = stack.clone(); - let move_down_stack = stack; + let initial = layer.get_untracked(); + let (icon, accent_rgb, kind_word) = source_meta(&initial.source); + let title = move || { + layer_title( + &layer.get(), + &media_names.get(), + &effect_names.get(), + kind_word, + ) + }; + let layer_id = initial.id.to_string(); + let is_effect = matches!(initial.source, LayerSource::Effect { .. }); + let is_media = matches!(initial.source, LayerSource::Media { .. }); + let show_reorder = move || stack.with(|layers| layers.len() > 1); + let can_move_up = move || stack_index.get() + 1 < stack.with(Vec::len); + let can_move_down = move || stack_index.get() > 0; let chip_style = format!("background: rgba({accent_rgb}, 0.14)"); let icon_style = format!("color: rgb({accent_rgb})"); @@ -130,24 +118,24 @@ pub fn LayerRow(
- {show_reorder + {let zone_id = zone_id.clone(); move || show_reorder() .then(|| { let zone_up = zone_id.clone(); let zone_down = zone_id.clone(); - let up_stack = move_up_stack.clone(); - let down_stack = move_down_stack.clone(); + let up_stack = stack; + let down_stack = stack; view! {