diff --git a/crates/hypercolor-daemon/src/api/openapi.rs b/crates/hypercolor-daemon/src/api/openapi.rs index 157285146..cd1e62e32 100644 --- a/crates/hypercolor-daemon/src/api/openapi.rs +++ b/crates/hypercolor-daemon/src/api/openapi.rs @@ -1,7 +1,7 @@ #![allow(clippy::needless_for_each)] use axum::routing::MethodRouter; -use utoipa::openapi::path::{OperationBuilder, ParameterBuilder, ParameterIn, Paths}; +use utoipa::openapi::path::{OperationBuilder, Parameter, ParameterBuilder, ParameterIn, Paths}; use utoipa::openapi::request_body::RequestBodyBuilder; use utoipa::openapi::schema::{ObjectBuilder, Schema, Type}; use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; @@ -123,6 +123,7 @@ pub(crate) struct OperationDoc { request_schema: Option, additional_schemas: Vec, query: Option, + headers: Vec, } impl OperationDoc { @@ -207,6 +208,7 @@ impl OperationDoc { request_schema: None, additional_schemas: Vec::new(), query: None, + headers: Vec::new(), } } @@ -255,6 +257,19 @@ impl OperationDoc { self } + pub(crate) fn required_header(mut self, name: &'static str, description: &'static str) -> Self { + self.headers.push( + ParameterBuilder::new() + .name(name) + .parameter_in(ParameterIn::Header) + .required(Required::True) + .description(Some(description)) + .schema(Some(ObjectBuilder::new().schema_type(Type::String))) + .build(), + ); + self + } + pub(crate) fn component(mut self) -> Self { self.additional_schemas.push(register_schema::); self @@ -320,6 +335,9 @@ fn operation(path: &str, document: OperationDoc) -> utoipa::openapi::path::Opera builder = builder.parameter(parameter); } } + for header in document.headers { + builder = builder.parameter(header); + } if let Some((schema, required)) = document.request { builder = builder.request_body(Some( diff --git a/crates/hypercolor-daemon/src/api/routes/scene.rs b/crates/hypercolor-daemon/src/api/routes/scene.rs index a724a04f6..9bd41bbeb 100644 --- a/crates/hypercolor-daemon/src/api/routes/scene.rs +++ b/crates/hypercolor-daemon/src/api/routes/scene.rs @@ -96,6 +96,22 @@ pub(super) fn router() -> OpenApiRouter> { .body::(), ], )) + .routes(openapi::documented_route( + "/scene/members/edit", + axum::routing::post(scene::edit_members), + [ + OperationDoc::post::( + "edit_live_scene_members", + "scenes", + "Atomically edit live scene memberships", + ) + .body::() + .required_header( + "If-Match", + "Last observed scene revision, as a quoted or bare integer. The wildcard * is not accepted.", + ), + ], + )) .routes(openapi::documented_route( "/scene/zones/{zone}/members", axum::routing::post(scene::assign_members), diff --git a/crates/hypercolor-daemon/src/api/scene.rs b/crates/hypercolor-daemon/src/api/scene.rs index 86cd73870..47d5ae1ce 100644 --- a/crates/hypercolor-daemon/src/api/scene.rs +++ b/crates/hypercolor-daemon/src/api/scene.rs @@ -318,6 +318,29 @@ pub async fn unassign_member( // ── Layers ─────────────────────────────────────────────────────────────── +/// `POST /api/v1/scene/members/edit` applies a reversible membership transaction. +pub async fn edit_members( + State(state): State>, + headers: HeaderMap, + Json(body): Json, +) -> Response { + let expected = match parse_if_match(&headers) { + Ok(Some(expected)) => expected, + Ok(None) => { + return DomainError::validation("If-Match is required for membership edits") + .into_response(); + } + Err(error) => return error.into_response(), + }; + match scene_tree::edit_members(&state.domains.scene_tree, body, expected).await { + Ok(outcome) => { + let revision = outcome.document.revision; + with_revision(envelope::ok(outcome), revision) + } + Err(error) => error.into_response(), + } +} + /// `GET /api/v1/scene/zones/{zone}/layers` — the zone's stack. pub async fn list_layers(State(state): State>, Path(zone): Path) -> Response { let zone_id = match parse_zone_id(&zone) { diff --git a/crates/hypercolor-daemon/src/domain/layout.rs b/crates/hypercolor-daemon/src/domain/layout.rs index f10d5aeef..3a73d20f0 100644 --- a/crates/hypercolor-daemon/src/domain/layout.rs +++ b/crates/hypercolor-daemon/src/domain/layout.rs @@ -996,7 +996,7 @@ pub(crate) fn validate_layout_sampling_radii(layout: &SpatialLayout) -> Result<( Ok(()) } -fn validate_output_sampling_radii(output: &Output) -> Result<(), DomainError> { +pub(crate) fn validate_output_sampling_radii(output: &Output) -> Result<(), DomainError> { validate_output_sampling_radii_text(output).map_err(DomainError::validation) } diff --git a/crates/hypercolor-daemon/src/domain/scene_tree.rs b/crates/hypercolor-daemon/src/domain/scene_tree.rs index 50b975629..a8374786d 100644 --- a/crates/hypercolor-daemon/src/domain/scene_tree.rs +++ b/crates/hypercolor-daemon/src/domain/scene_tree.rs @@ -37,6 +37,9 @@ use crate::domain::output::OutputContext; use crate::domain::scene::SceneMutation; use crate::domain::{DomainError, DomainErrorDetails, MutationContext, ResourceKind}; +mod member_edits; +pub use member_edits::edit_members; + /// Live scene-tree authority shared by REST and MCP adapters. #[derive(Clone)] pub struct SceneTreeContext { diff --git a/crates/hypercolor-daemon/src/domain/scene_tree/member_edits.rs b/crates/hypercolor-daemon/src/domain/scene_tree/member_edits.rs new file mode 100644 index 000000000..4f45a08bb --- /dev/null +++ b/crates/hypercolor-daemon/src/domain/scene_tree/member_edits.rs @@ -0,0 +1,406 @@ +//! Atomic, reversible membership edits without replacing scene content. + +use std::collections::HashSet; + +use hypercolor_types::api::scene::{ + EditMembersRequest, EditMembersResponse, MemberAssignmentTarget, MemberEdit, MemberState, +}; +use hypercolor_types::spatial::{LedTopology, NormalizedPosition}; + +use super::{ + AssignMembersRequest, DomainError, Output, OutputPlacement, ResourceKind, Scene, + SceneTreeContext, ZoneRole, active_scene, check_scene_revision, ensure_live_zone_mutable, + member_placement, mint_missing_outputs, reconcile_member_exclusions, scene_document, + zone_layout_error, +}; + +/// Commit only the named memberships, preserving all unrelated scene data. +/// +/// # Errors +/// Rejects stale revisions, another active scene, mismatched preconditions, +/// duplicate output identities, display zones, and invalid geometry before committing. +pub async fn edit_members( + ctx: &SceneTreeContext, + mut request: EditMembersRequest, + expected_revision: u64, +) -> Result { + if request.changes.is_empty() == request.assignment.is_none() { + return Err(DomainError::validation( + "provide either an assignment target or explicit membership changes", + )); + } + let minted = if let Some(assignment) = &request.assignment { + mint_missing_outputs( + ctx, + &AssignMembersRequest { + device_id: assignment.device_id.clone(), + segments: assignment.segments.clone(), + }, + ) + .await? + } else { + Vec::new() + }; + let mut mutation = ctx.scene.begin_mutation().await; + check_scene_revision(&mutation, Some(expected_revision))?; + let scene_id = mutation.active_scene_for_runtime_mutation("editing zone memberships")?; + if scene_id != request.scene_id { + return Err(DomainError::conflict("the active scene changed")); + } + let previous = active_scene(&mutation)?; + if let Some(assignment) = request.assignment { + ensure_live_zone_mutable(&mutation, assignment.zone_id)?; + request.changes = assignment_changes(&previous, &assignment, minted)?; + if request.changes.is_empty() { + return Ok(EditMembersResponse { + document: scene_document(&previous, expected_revision), + changes: Vec::new(), + }); + } + } + let mut changes = Vec::with_capacity(request.changes.len()); + let mut ids = HashSet::new(); + for change in request.changes { + let state = change + .before + .as_ref() + .or(change.after.as_ref()) + .ok_or_else(|| { + DomainError::validation("membership edit must have a before or after state") + })?; + let id = state.output.id.clone(); + if !ids.insert(id.clone()) { + return Err(DomainError::validation( + "an output may only appear once per edit", + )); + } + let actual = find_state(&previous, &id); + match (&change.before, &actual) { + (None, None) => {} + (Some(expected), Some(actual)) if same_public_state(expected, actual) => {} + _ => { + return Err(DomainError::conflict( + "output membership or placement changed", + )); + } + } + if let Some(before) = &actual { + ensure_live_zone_mutable(&mutation, before.zone_id)?; + } + let after = change + .after + .map(|mut after| { + if after.output.id != id { + return Err(DomainError::validation( + "membership edits cannot replace output identity", + )); + } + ensure_live_zone_mutable(&mutation, after.zone_id)?; + if let Some(before) = &actual { + if before.output.device_id != after.output.device_id + || before.output.zone_name != after.output.zone_name + || before.output.topology != after.output.topology + { + return Err(DomainError::validation( + "membership edits cannot change hardware bindings", + )); + } + let requested = after.output; + after.output = before.output.clone(); + after.output.position = requested.position; + after.output.size = requested.size; + after.output.rotation = requested.rotation; + after.output.scale = requested.scale; + after.output.orientation = requested.orientation; + } + validate_output(&after.output)?; + after.output.led_positions = + hypercolor_core::spatial::generate_positions(&after.output.topology); + Ok(after) + }) + .transpose()?; + changes.push(MemberEdit { + before: actual, + after, + }); + } + + // Remove the complete touched set first, allowing a move between zones + // without a temporary duplicate device/segment binding. + for change in &changes { + if let Some(before) = &change.before { + mutation + .unassign_output(scene_id, &before.output.id) + .map_err(|_| { + DomainError::conflict("output disappeared while editing memberships") + })?; + } + } + for change in &changes { + if let Some(after) = &change.after { + mutation + .assign_output( + scene_id, + after.zone_id, + after.output.clone(), + OutputPlacement::Preserve, + ) + .map_err(|_| DomainError::not_found(ResourceKind::Zone, after.zone_id))?; + } + } + let candidate = active_scene(&mutation)?; + // Restore authored ordering as well as placement. Each final insertion + // index is interpreted against the complete final output list. + for zone in &candidate.zones { + let mut inserted: Vec<_> = changes + .iter() + .filter_map(|change| change.after.as_ref()) + .filter(|after| after.zone_id == zone.id) + .collect(); + if inserted.is_empty() { + continue; + } + inserted.sort_by_key(|after| after.index); + let mut layout = zone.layout.clone(); + layout.zones.retain(|output| !ids.contains(&output.id)); + for after in inserted { + let index = after.index.min(layout.zones.len()); + layout.zones.insert(index, after.output.clone()); + } + mutation + .set_zone_layout(scene_id, zone.id, layout) + .map_err(|error| zone_layout_error(error, zone.id))?; + } + for zone_id in changes + .iter() + .flat_map(|change| { + change + .before + .iter() + .chain(change.after.iter()) + .map(|state| state.zone_id) + }) + .collect::>() + { + mutation.retire_zone_preview(scene_id, zone_id); + } + let committed = active_scene(&mutation)?; + for change in &mut changes { + if let Some(after) = &change.after { + change.after = find_state(&committed, &after.output.id); + } + } + let commit = ctx.scene.commit(mutation).await?; + ctx.scene.save_runtime_session().await; + ctx.layout + .sync_runtime_connectivity(ctx.scene.layout_runtime()) + .await; + reconcile_member_exclusions(ctx, scene_id, &previous.zones).await; + Ok(EditMembersResponse { + document: scene_document(&committed, commit.revision()), + changes, + }) +} + +fn find_state(scene: &Scene, id: &str) -> Option { + scene.zones.iter().find_map(|zone| { + zone.layout + .zones + .iter() + .enumerate() + .find(|(_, output)| output.id == id) + .map(|(index, output)| MemberState { + zone_id: zone.id, + output: output.clone(), + index, + }) + }) +} + +fn same_public_state(expected: &MemberState, actual: &MemberState) -> bool { + expected.zone_id == actual.zone_id + && expected.index == actual.index + && expected.output.id == actual.output.id + && expected.output.device_id == actual.output.device_id + && expected.output.zone_name == actual.output.zone_name + && member_placement(&expected.output) == member_placement(&actual.output) +} + +fn validate_output(output: &Output) -> Result<(), DomainError> { + if output.id.trim().is_empty() || output.device_id.trim().is_empty() { + return Err(DomainError::validation( + "output and device identities must not be empty", + )); + } + let values = [ + output.position.x, + output.position.y, + output.size.x, + output.size.y, + output.rotation, + output.scale, + ]; + if values.iter().any(|value| !value.is_finite()) + || output.size.x <= 0.0 + || output.size.y <= 0.0 + || output.scale <= 0.0 + { + return Err(DomainError::validation( + "output placement must be finite with positive size and scale", + )); + } + validate_topology(&output.topology)?; + super::super::layout::validate_output_sampling_radii(output) +} + +fn validate_topology(topology: &LedTopology) -> Result<(), DomainError> { + let overflow = || DomainError::validation("topology LED count exceeds its addressable range"); + let finite = |values: &[f32]| { + if values.iter().all(|value| value.is_finite()) { + Ok(()) + } else { + Err(DomainError::validation( + "topology coordinates must be finite", + )) + } + }; + let count = match topology { + LedTopology::Strip { count, .. } => *count, + LedTopology::Ring { + count, start_angle, .. + } => { + finite(&[*start_angle])?; + *count + } + LedTopology::Matrix { width, height, .. } => { + if *width == 0 || *height == 0 { + return Err(DomainError::validation( + "matrix dimensions must be positive", + )); + } + width.checked_mul(*height).ok_or_else(overflow)? + } + LedTopology::ConcentricRings { rings } => rings.iter().try_fold(0_u32, |total, ring| { + finite(&[ring.radius, ring.start_angle])?; + total.checked_add(ring.count).ok_or_else(overflow) + })?, + LedTopology::PerimeterLoop { + top, + right, + bottom, + left, + .. + } => [*top, *right, *bottom, *left] + .into_iter() + .try_fold(0_u32, |total, count| { + total.checked_add(count).ok_or_else(overflow) + })?, + LedTopology::Point => 1, + LedTopology::Custom { positions } => { + for position in positions { + finite(&[position.x, position.y])?; + } + u32::try_from(positions.len()).map_err(|_| overflow())? + } + }; + let bytes = usize::try_from(count) + .ok() + .and_then(|count| count.checked_mul(size_of::())) + .ok_or_else(overflow)?; + isize::try_from(bytes).map_err(|_| overflow())?; + Ok(()) +} + +fn assignment_changes( + scene: &Scene, + target: &MemberAssignmentTarget, + mut minted: Vec, +) -> Result, DomainError> { + let mut hinted = HashSet::new(); + for hint in &target.placements { + if !hinted.insert(&hint.segment) { + return Err(DomainError::validation( + "placement hints must name each segment once", + )); + } + for output in minted + .iter_mut() + .filter(|output| output.zone_name == hint.segment) + { + output.position = hint.position; + output.size = hint.size; + output.rotation = hint.rotation; + output.scale = hint.scale; + output.orientation = hint.orientation; + } + } + let selected = |output: &Output| { + output.device_id == target.device_id + && (target.segments.is_empty() + || output + .zone_name + .as_ref() + .is_some_and(|segment| target.segments.contains(segment))) + }; + let mut outputs: Vec<_> = scene + .zones + .iter() + .filter(|zone| zone.role != ZoneRole::Display) + .flat_map(|zone| zone.layout.zones.iter()) + .filter(|output| selected(output)) + .cloned() + .collect(); + // Existing authored outputs take precedence over a generated segment. + // One segment can contain several independently placed attachments. + let held_segments: HashSet<_> = outputs + .iter() + .map(|output| output.zone_name.clone()) + .collect(); + outputs.extend( + minted + .into_iter() + .filter(|output| selected(output) && !held_segments.contains(&output.zone_name)), + ); + if outputs.is_empty() { + return Err(DomainError::not_found( + ResourceKind::Device, + &target.device_id, + )); + } + if target.segments.iter().any(|segment| { + !outputs + .iter() + .any(|output| output.zone_name.as_ref() == Some(segment)) + }) { + return Err(DomainError::validation( + "assignment contains an unknown light segment", + )); + } + let mut index = scene + .zones + .iter() + .find(|zone| zone.id == target.zone_id) + .map_or(0, |zone| zone.layout.zones.len()); + Ok(outputs + .into_iter() + .filter_map(|output| { + let before = find_state(scene, &output.id); + if before + .as_ref() + .is_some_and(|before| before.zone_id == target.zone_id) + { + return None; + } + let after = MemberState { + zone_id: target.zone_id, + output, + index, + }; + index += 1; + Some(MemberEdit { + before, + after: Some(after), + }) + }) + .collect()) +} diff --git a/crates/hypercolor-daemon/tests/fixtures/rest_v1/spec78-target-manifest.json b/crates/hypercolor-daemon/tests/fixtures/rest_v1/spec78-target-manifest.json index 943c30c7c..9e9b5b70a 100644 --- a/crates/hypercolor-daemon/tests/fixtures/rest_v1/spec78-target-manifest.json +++ b/crates/hypercolor-daemon/tests/fixtures/rest_v1/spec78-target-manifest.json @@ -560,6 +560,12 @@ "methods": [ "GET" ] + }, + { + "path": "/api/v1/scene/members/edit", + "methods": [ + "POST" + ] } ] } diff --git a/crates/hypercolor-daemon/tests/openapi_tests.rs b/crates/hypercolor-daemon/tests/openapi_tests.rs index 61eead726..77a2e4175 100644 --- a/crates/hypercolor-daemon/tests/openapi_tests.rs +++ b/crates/hypercolor-daemon/tests/openapi_tests.rs @@ -18,6 +18,31 @@ fn openapi_document() -> serde_json::Value { .expect("OpenAPI document should parse") } +#[test] +fn membership_edit_declares_its_required_revision_header() { + let document = openapi_document(); + let operation = &document["paths"]["/api/v1/scene/members/edit"]["post"]; + let parameters = operation["parameters"] + .as_array() + .expect("membership edits declare their revision header"); + let header = parameters + .iter() + .find(|parameter| parameter["in"] == "header" && parameter["name"] == "If-Match") + .expect("required If-Match header is discoverable by generated clients"); + assert_eq!(header["required"], true); + assert_eq!(header["schema"]["type"], "string"); + assert!( + header["description"] + .as_str() + .expect("header format is described") + .contains("quoted or bare integer") + ); + assert_eq!( + operation["requestBody"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/EditMembersRequest" + ); +} + fn isolated_state() -> AppState { let _lock = DATA_DIR_LOCK .lock() @@ -310,15 +335,15 @@ fn runtime_document_exactly_matches_the_spec_78_target_manifest() { let live = documented_operations(&document); let target = target_operations(); - assert_eq!(target.len(), 123, "target operation count drifted"); - assert_eq!(live.len(), 123, "live operation count has not converged"); + assert_eq!(target.len(), 124, "target operation count drifted"); + assert_eq!(live.len(), 124, "live operation count has not converged"); assert_eq!( target .iter() .map(|(_, path)| path) .collect::>() .len(), - 87, + 88, "target path count drifted" ); assert_eq!( @@ -326,7 +351,7 @@ fn runtime_document_exactly_matches_the_spec_78_target_manifest() { .map(|(_, path)| path) .collect::>() .len(), - 87, + 88, "live path count has not converged" ); assert_eq!( @@ -422,7 +447,7 @@ fn runtime_document_has_complete_operation_contracts() { } } - assert_eq!(operation_ids.len(), 123); + assert_eq!(operation_ids.len(), 124); let schemas = &document["components"]["schemas"]; assert!(schemas["Vec"].is_null()); assert!(schemas["ListResponse"].is_null()); diff --git a/crates/hypercolor-daemon/tests/scene_tree_api_tests.rs b/crates/hypercolor-daemon/tests/scene_tree_api_tests.rs index 6aca99dff..195eaf962 100644 --- a/crates/hypercolor-daemon/tests/scene_tree_api_tests.rs +++ b/crates/hypercolor-daemon/tests/scene_tree_api_tests.rs @@ -1841,3 +1841,842 @@ async fn scene_settings_event_carries_the_candidate_revision() { } if revision == expected )); } + +#[tokio::test] +async fn membership_edit_restores_offline_outputs_and_preserves_hidden_fields() { + use hypercolor_types::api::scene::{ + EditMembersRequest, EditMembersResponse, MemberEdit, MemberState, + }; + let (state, _tmp) = isolated_state(); + let app = api::build_router(Arc::clone(&state), None); + seed_tree(&state).await; + let document = read_document(&app).await; + let scene_id = serde_json::from_value(document["data"]["id"].clone()).expect("scene id"); + let zone_id = serde_json::from_value(primary_zone(&document)["id"].clone()).expect("zone id"); + let mut projected_output = sample_output("out-a", Some("ch1")); + // The compact scene document intentionally does not expose these fields. + projected_output.sampling_mode = None; + projected_output.edge_behavior = None; + let request = EditMembersRequest { + assignment: None, + scene_id, + changes: vec![MemberEdit { + before: Some(MemberState { + zone_id, + output: projected_output, + index: 0, + }), + after: None, + }], + }; + let revision = document["data"]["revision"].as_u64().expect("revision"); + let removed = send( + &app, + if_match( + json_request("POST", "/api/v1/scene/members/edit".into(), json!(request)), + revision, + ), + ) + .await; + assert_eq!(removed.status(), StatusCode::OK); + let removed: EditMembersResponse = + serde_json::from_value(body_json(removed).await["data"].clone()).expect("receipt"); + assert_eq!(removed.document.revision, revision + 1); + let canonical = removed.changes[0] + .before + .as_ref() + .expect("canonical output"); + assert_eq!(canonical.output.sampling_mode, Some(SamplingMode::Bilinear)); + assert_eq!(canonical.output.edge_behavior, Some(EdgeBehavior::Clamp)); + assert_eq!(removed.document.zones[0].members.len(), 1); + let restore = EditMembersRequest { + assignment: None, + scene_id, + changes: removed + .changes + .iter() + .map(|change| MemberEdit { + before: change.after.clone(), + after: change.before.clone(), + }) + .collect(), + }; + let restored = send( + &app, + if_match( + json_request("POST", "/api/v1/scene/members/edit".into(), json!(restore)), + removed.document.revision, + ), + ) + .await; + assert_eq!(restored.status(), StatusCode::OK); + let restored: EditMembersResponse = + serde_json::from_value(body_json(restored).await["data"].clone()).expect("receipt"); + assert_eq!(restored.document.zones[0].members[0].id.0, "out-a"); + assert_eq!(restored.document.zones[0].members[1].id.0, "out-b"); + assert_eq!( + restored.document.zones[0].layers, + removed.document.zones[0].layers + ); + assert_eq!(restored.changes[0].after, removed.changes[0].before); +} + +#[tokio::test] +async fn membership_edit_rejects_stale_invalid_and_partial_batches() { + use hypercolor_types::api::scene::{EditMembersRequest, MemberEdit, MemberState}; + let (state, _tmp) = isolated_state(); + let app = api::build_router(Arc::clone(&state), None); + seed_tree(&state).await; + let document = read_document(&app).await; + let scene_id = serde_json::from_value(document["data"]["id"].clone()).expect("scene id"); + let zone_id = serde_json::from_value(primary_zone(&document)["id"].clone()).expect("zone id"); + let revision = document["data"]["revision"].as_u64().expect("revision"); + let valid = MemberEdit { + before: Some(MemberState { + zone_id, + output: sample_output("out-a", Some("ch1")), + index: 0, + }), + after: None, + }; + let mut invalid_output = sample_output("out-b", Some("ch2")); + invalid_output.position.x = 0.99; + let request = EditMembersRequest { + assignment: None, + scene_id, + changes: vec![ + valid.clone(), + MemberEdit { + before: Some(MemberState { + zone_id, + output: invalid_output, + index: 1, + }), + after: None, + }, + ], + }; + let rejected = send( + &app, + if_match( + json_request("POST", "/api/v1/scene/members/edit".into(), json!(request)), + revision, + ), + ) + .await; + assert_eq!(rejected.status(), StatusCode::CONFLICT); + let after = read_document(&app).await; + assert_eq!( + after["data"], document["data"], + "invalid second entry must leave first member intact" + ); + let request = EditMembersRequest { + assignment: None, + scene_id, + changes: vec![valid], + }; + let stale = send( + &app, + if_match( + json_request("POST", "/api/v1/scene/members/edit".into(), json!(request)), + revision - 1, + ), + ) + .await; + assert_eq!(stale.status(), StatusCode::PRECONDITION_FAILED); + let missing = send( + &app, + json_request("POST", "/api/v1/scene/members/edit".into(), json!(request)), + ) + .await; + assert_eq!(missing.status(), StatusCode::UNPROCESSABLE_ENTITY); + let mut wrong_scene = request; + wrong_scene.scene_id = hypercolor_types::scene::SceneId::new(); + let rejected = send( + &app, + if_match( + json_request( + "POST", + "/api/v1/scene/members/edit".into(), + json!(wrong_scene), + ), + revision, + ), + ) + .await; + assert_eq!(rejected.status(), StatusCode::CONFLICT); + assert_eq!(read_document(&app).await["data"], document["data"]); +} + +#[tokio::test] +async fn membership_edit_moves_outputs_without_losing_unrelated_controls() { + use hypercolor_types::api::scene::{ + EditMembersRequest, EditMembersResponse, MemberEdit, MemberState, + }; + let (state, _tmp) = isolated_state(); + let app = api::build_router(Arc::clone(&state), None); + seed_tree(&state).await; + let created = send( + &app, + json_request( + "POST", + "/api/v1/scene/zones".into(), + json!({"name": "Desk"}), + ), + ) + .await; + assert_eq!(created.status(), StatusCode::CREATED); + let desk_id = + serde_json::from_value(body_json(created).await["data"]["id"].clone()).expect("desk id"); + let document = read_document(&app).await; + let scene_id = serde_json::from_value(document["data"]["id"].clone()).expect("scene id"); + let zone_id = serde_json::from_value(primary_zone(&document)["id"].clone()).expect("zone id"); + let before = MemberState { + zone_id, + output: sample_output("out-a", Some("ch1")), + index: 0, + }; + let mut after = before.clone(); + after.zone_id = desk_id; + after.output.position.x = 0.75; + after.output.sampling_mode = None; + after.output.edge_behavior = None; + let moved = send( + &app, + if_match( + json_request( + "POST", + "/api/v1/scene/members/edit".into(), + json!(EditMembersRequest { + assignment: None, + scene_id, + changes: vec![MemberEdit { + before: Some(before), + after: Some(after) + }], + }), + ), + document["data"]["revision"].as_u64().expect("revision"), + ), + ) + .await; + assert_eq!(moved.status(), StatusCode::OK); + let moved: EditMembersResponse = + serde_json::from_value(body_json(moved).await["data"].clone()).expect("receipt"); + let after = moved.changes[0].after.as_ref().expect("moved output"); + assert_eq!(after.output.position.x, 0.75); + assert_eq!(after.output.sampling_mode, Some(SamplingMode::Bilinear)); + let layer_id = moved.document.zones[0].layers[0].id; + let patched = send( + &app, + json_request( + "PATCH", + format!("/api/v1/scene/zones/{zone_id}/layers/{layer_id}/controls"), + json!({"values": {"speed": {"kind": "float", "value": 1.5}}}), + ), + ) + .await; + assert_eq!(patched.status(), StatusCode::OK); + let patched = read_document(&app).await; + let undo = EditMembersRequest { + assignment: None, + scene_id, + changes: moved + .changes + .iter() + .map(|change| MemberEdit { + before: change.after.clone(), + after: change.before.clone(), + }) + .collect(), + }; + let restored = send( + &app, + if_match( + json_request("POST", "/api/v1/scene/members/edit".into(), json!(undo)), + patched["data"]["revision"].as_u64().expect("revision"), + ), + ) + .await; + assert_eq!(restored.status(), StatusCode::OK); + let restored = body_json(restored).await; + assert_eq!( + restored["data"]["document"]["zones"][0]["layers"], + patched["data"]["zones"][0]["layers"] + ); + assert_eq!( + restored["data"]["document"]["zones"][0]["members"][0]["id"], + "out-a" + ); + assert_eq!( + restored["data"]["document"]["zones"][0]["layout"]["placements"][0]["position"], + document["data"]["zones"][0]["layout"]["placements"][0]["position"] + ); +} + +#[tokio::test] +async fn membership_edit_rejects_duplicate_identities_geometry_and_display_targets() { + use hypercolor_types::api::scene::{EditMembersRequest, MemberEdit, MemberState}; + let (state, _tmp) = isolated_state(); + let app = api::build_router(Arc::clone(&state), None); + let effect = seed_tree(&state).await; + let display_id = seed_display_zone(&state, effect).await; + let document = read_document(&app).await; + let scene_id = serde_json::from_value(document["data"]["id"].clone()).expect("scene id"); + let zone_id = serde_json::from_value(primary_zone(&document)["id"].clone()).expect("zone id"); + let revision = document["data"]["revision"].as_u64().expect("revision"); + let mut output = sample_output("new-output", Some("ch1")); + let duplicate = MemberState { + zone_id, + output: output.clone(), + index: 2, + }; + output.zone_name = Some("ch3".into()); + output.size.x = 0.0; + let invalid_geometry = MemberState { + zone_id, + output: output.clone(), + index: 2, + }; + output.size.x = 0.2; + let display_target = MemberState { + zone_id: display_id, + output, + index: 0, + }; + for targets in [ + vec![duplicate.clone(), duplicate], + vec![invalid_geometry], + vec![display_target], + ] { + let request = EditMembersRequest { + assignment: None, + scene_id, + changes: targets + .into_iter() + .map(|after| MemberEdit { + before: None, + after: Some(after), + }) + .collect(), + }; + let rejected = send( + &app, + if_match( + json_request("POST", "/api/v1/scene/members/edit".into(), json!(request)), + revision, + ), + ) + .await; + assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(read_document(&app).await["data"], document["data"]); + } +} + +#[tokio::test] +async fn membership_edit_preserves_multiple_attachment_instances_on_one_segment() { + use hypercolor_types::api::scene::{ + EditMembersRequest, EditMembersResponse, MemberEdit, MemberState, + }; + use hypercolor_types::spatial::OutputComponent; + let (state, _tmp) = isolated_state(); + let app = api::build_router(Arc::clone(&state), None); + seed_tree(&state).await; + let created = send( + &app, + json_request( + "POST", + "/api/v1/scene/zones".into(), + json!({"name": "Fans"}), + ), + ) + .await; + assert_eq!(created.status(), StatusCode::CREATED); + let target_id = + serde_json::from_value(body_json(created).await["data"]["id"].clone()).expect("zone id"); + let document = read_document(&app).await; + let scene_id = serde_json::from_value(document["data"]["id"].clone()).expect("scene id"); + let source_id = serde_json::from_value(primary_zone(&document)["id"].clone()).expect("zone id"); + // Three physical fans share one controller segment. Each attachment's + // own output identity and LED span must survive assignment and replay. + let changes = (0_u32..3) + .map(|instance| { + let mut output = sample_output(&format!("fan-{instance}"), Some("channel-2")); + output.device_id = "nollie:controller".into(); + output.topology = LedTopology::Strip { + count: 20, + direction: StripDirection::LeftToRight, + }; + output.attachment = Some(OutputComponent { + template_id: "lian-li-sl-infinity-fan".into(), + slot_id: "channel-2".into(), + instance, + led_start: Some(256 + instance * 20), + led_count: Some(20), + led_mapping: None, + }); + MemberEdit { + before: None, + after: Some(MemberState { + zone_id: source_id, + output, + index: 2 + instance as usize, + }), + } + }) + .collect(); + let added = send( + &app, + if_match( + json_request( + "POST", + "/api/v1/scene/members/edit".into(), + json!(EditMembersRequest { + scene_id, + changes, + assignment: None + }), + ), + document["data"]["revision"].as_u64().expect("revision"), + ), + ) + .await; + assert_eq!(added.status(), StatusCode::OK); + let added: EditMembersResponse = + serde_json::from_value(body_json(added).await["data"].clone()).expect("receipt"); + // Even an unrelated removal must not revalidate the scene using a + // coarser device/segment key than the canonical output identity. + let removed = send( + &app, + if_match( + json_request( + "POST", + "/api/v1/scene/members/edit".into(), + json!(EditMembersRequest { + assignment: None, + scene_id, + changes: vec![MemberEdit { + before: Some(MemberState { + zone_id: source_id, + output: sample_output("out-a", Some("ch1")), + index: 0 + }), + after: None, + }], + }), + ), + added.document.revision, + ), + ) + .await; + assert_eq!(removed.status(), StatusCode::OK); + let removed: EditMembersResponse = + serde_json::from_value(body_json(removed).await["data"].clone()).expect("receipt"); + let moved = send( + &app, + if_match( + json_request( + "POST", + "/api/v1/scene/members/edit".into(), + json!(EditMembersRequest { + assignment: Some(hypercolor_types::api::scene::MemberAssignmentTarget { + zone_id: target_id, + device_id: "nollie:controller".into(), + segments: Vec::new(), + placements: Vec::new(), + }), + scene_id, + changes: Vec::new(), + }), + ), + removed.document.revision, + ), + ) + .await; + assert_eq!(moved.status(), StatusCode::OK); + let moved: EditMembersResponse = + serde_json::from_value(body_json(moved).await["data"].clone()).expect("receipt"); + for (index, change) in moved.changes.iter().enumerate() { + let after = change.after.as_ref().expect("moved fan"); + let attachment = after + .output + .attachment + .as_ref() + .expect("canonical attachment retained"); + assert_eq!(attachment.instance as usize, index); + assert_eq!(attachment.led_start, Some(256 + attachment.instance * 20)); + assert_eq!(after.zone_id, target_id); + } + let undo = EditMembersRequest { + assignment: None, + scene_id, + changes: moved + .changes + .iter() + .map(|change| MemberEdit { + before: change.after.clone(), + after: change.before.clone(), + }) + .collect(), + }; + let restored = send( + &app, + if_match( + json_request("POST", "/api/v1/scene/members/edit".into(), json!(undo)), + moved.document.revision, + ), + ) + .await; + assert_eq!(restored.status(), StatusCode::OK); + let restored: EditMembersResponse = + serde_json::from_value(body_json(restored).await["data"].clone()).expect("receipt"); + assert_eq!(restored.changes.len(), moved.changes.len()); + for (change, previous) in restored.changes.iter().zip(&moved.changes) { + assert_eq!(change.after, previous.before); + } +} + +#[tokio::test] +async fn membership_edit_assignment_uses_canonical_device_layout_hints() { + use hypercolor_types::api::scene::{ + EditMembersRequest, EditMembersResponse, MemberAssignmentTarget, + }; + use hypercolor_types::device::{ + ConnectionType, DeviceCapabilities, DeviceColorFormat, DeviceFamily, DeviceFeatures, + DeviceId, DeviceInfo, DeviceOrigin, DeviceTopologyHint, SegmentInfo, SegmentLayoutHint, + }; + use hypercolor_types::spatial::ZoneShape; + let (state, _tmp) = isolated_state(); + let app = api::build_router(Arc::clone(&state), None); + seed_tree(&state).await; + let hint = SegmentLayoutHint::custom_grid(3, 2, &[(0, 0), (2, 1)]) + .with_size(NormalizedPosition::new(0.3, 0.2)) + .with_shape(ZoneShape::Rectangle); + let info = DeviceInfo { + id: DeviceId::new(), + name: "Custom-grid microphone".into(), + vendor: "TestVendor".into(), + family: DeviceFamily::named("test"), + model: None, + connection_type: ConnectionType::Network, + origin: DeviceOrigin::native("test", "test", ConnectionType::Network), + segments: vec![ + SegmentInfo { + name: "Lights".into(), + led_count: 2, + topology: DeviceTopologyHint::Strip, + color_format: DeviceColorFormat::Rgb, + layout_hint: Some(hint.clone()), + }, + SegmentInfo { + name: "Display".into(), + led_count: 1, + topology: DeviceTopologyHint::Display { + width: 1, + height: 1, + circular: false, + format: hypercolor_types::device::DisplayFrameFormat::default(), + }, + color_format: DeviceColorFormat::Rgb, + layout_hint: None, + }, + ], + firmware_version: None, + capabilities: DeviceCapabilities { + led_count: 2, + supports_direct: true, + supports_brightness: false, + has_display: true, + display_resolution: Some((1, 1)), + max_fps: 60, + color_space: hypercolor_types::device::DeviceColorSpace::default(), + features: DeviceFeatures::default(), + }, + }; + let device_id = + hypercolor_core::device::DeviceLifecycleManager::canonical_layout_device_id(&info, None); + let _ = state.device_registry.add(info).await; + let document = read_document(&app).await; + let scene_id = serde_json::from_value(document["data"]["id"].clone()).expect("scene id"); + let zone_id = serde_json::from_value(primary_zone(&document)["id"].clone()).expect("zone id"); + let request = EditMembersRequest { + scene_id, + changes: Vec::new(), + assignment: Some(MemberAssignmentTarget { + zone_id, + device_id, + segments: Vec::new(), + placements: Vec::new(), + }), + }; + let assigned = send( + &app, + if_match( + json_request("POST", "/api/v1/scene/members/edit".into(), json!(request)), + document["data"]["revision"].as_u64().expect("revision"), + ), + ) + .await; + assert_eq!(assigned.status(), StatusCode::OK); + let assigned: EditMembersResponse = + serde_json::from_value(body_json(assigned).await["data"].clone()).expect("receipt"); + assert_eq!( + assigned.changes.len(), + 1, + "display segments never enter LED membership" + ); + let output = &assigned.changes[0] + .after + .as_ref() + .expect("minted output") + .output; + assert_eq!(Some(output.topology.clone()), hint.topology); + assert_eq!(Some(output.size), hint.size); + assert_eq!(output.shape, hint.shape); + assert_eq!(output.zone_name.as_deref(), Some("Lights")); + let unchanged = send( + &app, + if_match( + json_request("POST", "/api/v1/scene/members/edit".into(), json!(request)), + assigned.document.revision, + ), + ) + .await; + assert_eq!(unchanged.status(), StatusCode::OK); + let unchanged: EditMembersResponse = + serde_json::from_value(body_json(unchanged).await["data"].clone()).expect("receipt"); + assert!(unchanged.changes.is_empty()); + assert_eq!(unchanged.document.revision, assigned.document.revision); + + let removed = send( + &app, + if_match( + json_request( + "POST", + "/api/v1/scene/members/edit".into(), + json!(EditMembersRequest { + scene_id, + assignment: None, + changes: vec![hypercolor_types::api::scene::MemberEdit { + before: assigned.changes[0].after.clone(), + after: None, + }], + }), + ), + assigned.document.revision, + ), + ) + .await; + assert_eq!(removed.status(), StatusCode::OK); + let removed: EditMembersResponse = + serde_json::from_value(body_json(removed).await["data"].clone()).expect("receipt"); + let mut seeded = request; + seeded.assignment.as_mut().expect("assignment").placements = + vec![hypercolor_types::api::scene::MemberPlacementHint { + segment: Some("Lights".into()), + position: NormalizedPosition::new(0.7, 0.6), + size: NormalizedPosition::new(0.15, 0.1), + rotation: 0.3, + scale: 1.2, + orientation: None, + }]; + let seeded = send( + &app, + if_match( + json_request("POST", "/api/v1/scene/members/edit".into(), json!(seeded)), + removed.document.revision, + ), + ) + .await; + assert_eq!(seeded.status(), StatusCode::OK); + let seeded: EditMembersResponse = + serde_json::from_value(body_json(seeded).await["data"].clone()).expect("receipt"); + let output = &seeded.changes[0] + .after + .as_ref() + .expect("seeded output") + .output; + assert_eq!(Some(output.topology.clone()), hint.topology); + assert_eq!(output.shape, hint.shape); + assert_eq!(output.position, NormalizedPosition::new(0.7, 0.6)); + assert_eq!(output.size, NormalizedPosition::new(0.15, 0.1)); +} + +#[tokio::test] +async fn membership_edit_rejects_ambiguous_forms_and_overflowing_topologies_atomically() { + use hypercolor_types::api::scene::{ + EditMembersRequest, MemberAssignmentTarget, MemberEdit, MemberState, + }; + use hypercolor_types::spatial::{Corner, RingDef, Winding}; + let (state, _tmp) = isolated_state(); + let app = api::build_router(Arc::clone(&state), None); + seed_tree(&state).await; + let document = read_document(&app).await; + let scene_id = serde_json::from_value(document["data"]["id"].clone()).expect("scene id"); + let zone_id = serde_json::from_value(primary_zone(&document)["id"].clone()).expect("zone id"); + let revision = document["data"]["revision"].as_u64().expect("revision"); + let remove = MemberEdit { + before: Some(MemberState { + zone_id, + output: sample_output("out-a", Some("ch1")), + index: 0, + }), + after: None, + }; + let assignment = MemberAssignmentTarget { + zone_id, + device_id: "mock:controller".into(), + segments: Vec::new(), + placements: Vec::new(), + }; + for request in [ + EditMembersRequest { + scene_id, + changes: vec![remove.clone()], + assignment: Some(assignment), + }, + EditMembersRequest { + scene_id, + changes: Vec::new(), + assignment: None, + }, + ] { + let response = send( + &app, + if_match( + json_request("POST", "/api/v1/scene/members/edit".into(), json!(request)), + revision, + ), + ) + .await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(read_document(&app).await["data"], document["data"]); + } + let topologies = [ + LedTopology::Matrix { + width: u32::MAX, + height: 2, + start_corner: Corner::TopLeft, + serpentine: false, + }, + LedTopology::Matrix { + width: 0, + height: u32::MAX, + start_corner: Corner::TopLeft, + serpentine: false, + }, + LedTopology::ConcentricRings { + rings: vec![ + RingDef { + count: u32::MAX, + radius: 1.0, + start_angle: 0.0, + direction: Winding::Clockwise, + }, + RingDef { + count: 1, + radius: 0.5, + start_angle: 0.0, + direction: Winding::Clockwise, + }, + ], + }, + LedTopology::PerimeterLoop { + top: u32::MAX, + right: 1, + bottom: 0, + left: 0, + start_corner: Corner::TopLeft, + direction: Winding::Clockwise, + }, + ]; + for topology in topologies { + let mut output = sample_output("restored-output", Some("ch3")); + output.topology = topology; + let request = EditMembersRequest { + scene_id, + assignment: None, + changes: vec![ + remove.clone(), + MemberEdit { + before: None, + after: Some(MemberState { + zone_id, + output, + index: 1, + }), + }, + ], + }; + let response = send( + &app, + if_match( + json_request("POST", "/api/v1/scene/members/edit".into(), json!(request)), + revision, + ), + ) + .await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + read_document(&app).await["data"], + document["data"], + "invalid second change leaves the first member intact" + ); + } +} + +#[tokio::test] +async fn membership_edit_domain_rejects_nonfinite_topology_coordinates() { + use hypercolor_types::api::scene::{EditMembersRequest, MemberEdit, MemberState}; + use hypercolor_types::spatial::{RingDef, Winding}; + let (state, _tmp) = isolated_state(); + let app = api::build_router(Arc::clone(&state), None); + seed_tree(&state).await; + let document = read_document(&app).await; + let scene_id = serde_json::from_value(document["data"]["id"].clone()).expect("scene id"); + let zone_id = serde_json::from_value(primary_zone(&document)["id"].clone()).expect("zone id"); + let revision = document["data"]["revision"].as_u64().expect("revision"); + for topology in [ + LedTopology::Ring { + count: 2, + start_angle: f32::NAN, + direction: Winding::Clockwise, + }, + LedTopology::ConcentricRings { + rings: vec![RingDef { + count: 2, + radius: f32::INFINITY, + start_angle: 0.0, + direction: Winding::Clockwise, + }], + }, + LedTopology::Custom { + positions: vec![NormalizedPosition::new(0.0, f32::NEG_INFINITY)], + }, + ] { + let mut output = sample_output("restored-output", Some("ch3")); + output.topology = topology; + let result = hypercolor_daemon::domain::scene_tree::edit_members( + &state.domains.scene_tree, + EditMembersRequest { + scene_id, + assignment: None, + changes: vec![MemberEdit { + before: None, + after: Some(MemberState { + zone_id, + output, + index: 2, + }), + }], + }, + revision, + ) + .await; + assert!(matches!( + result, + Err(hypercolor_daemon::domain::DomainError::Validation { .. }) + )); + assert_eq!(read_document(&app).await["data"], document["data"]); + } +} diff --git a/crates/hypercolor-types/src/api/scene.rs b/crates/hypercolor-types/src/api/scene.rs index 8374ca0b8..3dcf22aa7 100644 --- a/crates/hypercolor-types/src/api/scene.rs +++ b/crates/hypercolor-types/src/api/scene.rs @@ -467,3 +467,84 @@ pub struct ApplyEffectResponse { /// The post-commit power-wake outcome. pub output: SideEffectOutcome, } + +/// One output's complete authored state for reversible membership edits. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] +#[serde(deny_unknown_fields)] +pub struct MemberState { + #[cfg_attr(feature = "schema", schema(value_type = String))] + pub zone_id: ZoneId, + pub output: crate::spatial::Output, + /// Position in the owning zone's ordered output list. + pub index: usize, +} + +/// An atomic add, move, placement change, or removal of one output. +/// +/// Preconditions compare membership identity and public placement fields. +/// The response replaces both sides with complete authoritative snapshots, +/// preserving fields absent from the scene document for later restoration. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] +#[serde(deny_unknown_fields)] +pub struct MemberEdit { + pub before: Option, + pub after: Option, +} + +/// `POST /scene/members/edit`: one revision-fenced membership transaction. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] +#[serde(deny_unknown_fields)] +pub struct EditMembersRequest { + #[cfg_attr(feature = "schema", schema(value_type = String))] + pub scene_id: SceneId, + #[serde(default)] + pub changes: Vec, + /// Resolve a forward assignment from canonical device layout metadata. + /// Mutually exclusive with explicit changes, which replay saved receipts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assignment: Option, +} + +/// Device segments to assign using daemon-owned layout construction. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] +#[serde(deny_unknown_fields)] +pub struct MemberAssignmentTarget { + #[cfg_attr(feature = "schema", schema(value_type = String))] + pub zone_id: ZoneId, + pub device_id: String, + /// Empty selects every light segment, including all attachment instances. + #[serde(default)] + pub segments: Vec, + /// Optional seeded geometry for newly minted outputs, keyed by segment. + #[serde(default)] + pub placements: Vec, +} + +/// Placement-only seed; device binding and topology remain daemon-owned. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] +#[serde(deny_unknown_fields)] +pub struct MemberPlacementHint { + #[serde(default)] + pub segment: Option, + pub position: NormalizedPosition, + pub size: NormalizedPosition, + #[serde(default)] + pub rotation: f32, + #[serde(default = "default_placement_scale")] + pub scale: f32, + #[serde(default)] + pub orientation: Option, +} + +/// Committed scene and reversible, canonical membership changes. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] +pub struct EditMembersResponse { + pub document: SceneDocument, + pub changes: Vec, +} diff --git a/crates/hypercolor-types/tests/api_scene_tests.rs b/crates/hypercolor-types/tests/api_scene_tests.rs index 6988fdc6d..1eb26e20a 100644 --- a/crates/hypercolor-types/tests/api_scene_tests.rs +++ b/crates/hypercolor-types/tests/api_scene_tests.rs @@ -261,3 +261,42 @@ fn the_zone_layout_contract_speaks_placements_only() { "no device-internal vocabulary on the layout wire (Spec 78 §5.1)" ); } + +#[test] +fn membership_edit_contract_round_trips_offline_restore_receipts() { + use hypercolor_types::api::scene::{EditMembersRequest, EditMembersResponse}; + let state = json!({ + "zone_id": "0198c5b6-1111-7000-8000-000000000002", + "index": 1, + "output": { + "id": "out-desk-1", "device_id": "usb:controller-1", "name": "Desk Strip", + "zone_name": "ch1", "position": {"x": 0.25, "y": 0.5}, + "size": {"x": 0.2, "y": 0.3}, "rotation": 0.0, + "orientation": null, "topology": {"type": "strip", "count": 4, "direction": "left_to_right"}, + "sampling_mode": null, "edge_behavior": null, "shape": null, "shape_preset": null, + "led_mapping": [3, 2, 1, 0], "brightness": 0.7 + } + }); + let request = json!({ + "scene_id": "0198c5b6-1111-7000-8000-000000000001", + "changes": [{"before": null, "after": state}] + }); + let typed: EditMembersRequest = + serde_json::from_value(request.clone()).expect("restore request"); + let after = typed.changes[0].after.as_ref().expect("restored state"); + assert_eq!(after.index, 1); + assert_eq!(after.output.led_mapping, Some(vec![3, 2, 1, 0])); + assert_eq!(after.output.brightness, Some(0.7)); + let response = EditMembersResponse { + document: serde_json::from_value(representative_document()).expect("scene"), + changes: typed.changes, + }; + let encoded = serde_json::to_value(&response).expect("serialize receipt"); + assert_eq!( + serde_json::from_value::(encoded).expect("read receipt"), + response + ); + let mut unknown = request; + unknown["replace_layers"] = json!(true); + assert!(serde_json::from_value::(unknown).is_err()); +} diff --git a/crates/hypercolor-ui/input.css b/crates/hypercolor-ui/input.css index b6bf22f0c..13e408d4b 100644 --- a/crates/hypercolor-ui/input.css +++ b/crates/hypercolor-ui/input.css @@ -1207,7 +1207,7 @@ button:hover:not(:disabled) > .relative { border-radius: 999px; background: rgba(225, 53, 255, 0.22); box-shadow: 0 0 0 1px rgba(225, 53, 255, 0.04); - transition: all 200ms ease; + transition: background-color 200ms ease, height 200ms ease, box-shadow 200ms ease; } .resize-handle-zone:hover .resize-handle-line { diff --git a/crates/hypercolor-ui/src/api/zones.rs b/crates/hypercolor-ui/src/api/zones.rs index 65734889a..3e6e255d9 100644 --- a/crates/hypercolor-ui/src/api/zones.rs +++ b/crates/hypercolor-ui/src/api/zones.rs @@ -15,6 +15,20 @@ use crate::control_surface_api::path_segment; pub type ZoneOutcome = MutationOutcome; +/// Apply one reversible membership transaction against the active scene. +pub async fn edit_members( + request: &hypercolor_types::api::scene::EditMembersRequest, + expected_revision: u64, +) -> ApiResult> { + client::send_json_versioned( + HttpMethod::Post, + "/api/v1/scene/members/edit", + Some(request), + Some(expected_revision), + ) + .await +} + pub use hypercolor_types::api::scene::PatchZoneRequest; #[derive(Debug, Clone, PartialEq)] diff --git a/crates/hypercolor-ui/src/components/layout_builder.rs b/crates/hypercolor-ui/src/components/layout_builder.rs index c2b1da8e1..d504da9e0 100644 --- a/crates/hypercolor-ui/src/components/layout_builder.rs +++ b/crates/hypercolor-ui/src/components/layout_builder.rs @@ -15,7 +15,7 @@ use crate::components::layout_palette::LayoutPalette; use crate::components::layout_zone_properties::LayoutZoneProperties; use crate::icons::*; use crate::layout_geometry; -use crate::layout_history::{LayoutEditorSnapshot, LayoutHistoryState, RemovedOutputCache}; +use crate::layout_history::{LayoutEditorSnapshot, RemovedOutputCache}; use crate::storage; use crate::toasts; use hypercolor_leptos_ext::events::target_is_text_entry; @@ -76,7 +76,9 @@ pub struct LayoutWriteHandle { set_compound_depth: WriteSignal, removed_zone_cache: ReadSignal, set_removed_zone_cache: WriteSignal, - history: RwSignal, + studio_history: crate::pages::studio::history::StudioHistory, + selected_surface: Signal>, + interaction_start: RwSignal>, set_dirty: WriteSignal, } @@ -91,7 +93,7 @@ impl LayoutWriteHandle { }) } - fn apply_snapshot(self, snapshot: LayoutEditorSnapshot) { + pub(crate) fn apply_snapshot(self, snapshot: LayoutEditorSnapshot) { let LayoutEditorSnapshot { zones, selected_zone_ids, @@ -109,11 +111,13 @@ impl LayoutWriteHandle { } pub fn in_interaction(self) -> bool { - self.history - .with_untracked(LayoutHistoryState::is_interactive) + self.interaction_start.with_untracked(Option::is_some) } pub fn update(self, f: impl FnOnce(&mut Option)) { + if self.studio_history.busy.get_untracked() { + return; + } // Skip history bookkeeping while a drag/resize interaction is in flight — // begin_interaction already captured the pre-drag snapshot, and // finish_interaction will record the single combined diff on release. @@ -127,8 +131,9 @@ impl LayoutWriteHandle { let (Some(before), Some(after)) = (before, self.capture_snapshot()) else { return; }; - self.history - .update(|state| state.record_edit(before, &after)); + if let Some(zone_id) = self.selected_surface.get_untracked() { + self.studio_history.record_layout(zone_id, before, after); + } } pub fn update_without_history(self, f: impl FnOnce(&mut Option)) { @@ -136,7 +141,7 @@ impl LayoutWriteHandle { } pub fn set(self, value: Option) { - self.history.update(LayoutHistoryState::discard_interaction); + self.interaction_start.set(None); self.set_layout.set(value); self.set_dirty.set(false); } @@ -145,23 +150,24 @@ impl LayoutWriteHandle { self.set_dirty.set(false); } - pub fn reset_history(self) { - self.history.update(LayoutHistoryState::reset); - } - pub fn begin_interaction(self) { - if let Some(snapshot) = self.capture_snapshot() { - self.history - .update(|state| state.begin_interaction(snapshot)); + if self.studio_history.busy.get_untracked() { + return; + } + if self.interaction_start.with_untracked(Option::is_none) { + self.interaction_start.set(self.capture_snapshot()); } } pub fn finish_interaction(self) { - if let Some(current) = self.capture_snapshot() { - self.history - .update(|state| state.finish_interaction(¤t)); - } else { - self.history.update(LayoutHistoryState::discard_interaction); + let before = self.interaction_start.get_untracked(); + self.interaction_start.set(None); + if let (Some(before), Some(after), Some(zone_id)) = ( + before, + self.capture_snapshot(), + self.selected_surface.get_untracked(), + ) { + self.studio_history.record_layout(zone_id, before, after); } } @@ -171,6 +177,9 @@ impl LayoutWriteHandle { /// touches the layout signal, so this is the *only* moment the reactive /// graph sees the change. Returns true if zone state actually changed. pub fn commit_zones(self, zones: Vec) -> bool { + if self.studio_history.busy.get_untracked() { + return false; + } let unchanged = self .layout .with_untracked(|l| l.as_ref().is_some_and(|current| current.zones == zones)); @@ -196,38 +205,20 @@ impl LayoutWriteHandle { } pub fn undo(self) { - let Some(current) = self.capture_snapshot() else { - return; - }; - let mut restored = None; - self.history.update(|state| { - restored = state.undo(current.clone()); - }); - if let Some(snapshot) = restored { - self.apply_snapshot(snapshot); - self.set_dirty.set(true); - } + self.studio_history.replay(false); } pub fn redo(self) { - let Some(current) = self.capture_snapshot() else { - return; - }; - let mut restored = None; - self.history.update(|state| { - restored = state.redo(current.clone()); - }); - if let Some(snapshot) = restored { - self.apply_snapshot(snapshot); - self.set_dirty.set(true); - } + self.studio_history.replay(true); } } mod editor_session; pub(crate) use editor_session::{LayoutEditorContext, LayoutZoneDisplayContext}; -use editor_session::{LayoutEditorSession, embedded_attachment_profiles}; +use editor_session::{ + LayoutEditorSession, ZoneDraft, embedded_attachment_profiles, merge_draft, reconcile_replay, +}; /// The layout editor body with its device palette, canvas viewport, /// zone-properties panel, and resizable-panel state. It consumes the @@ -478,10 +469,6 @@ fn editor_layout_for_zone(zone: &api::ZoneResource) -> SpatialLayout { /// [`ZoneCanvasActions`]. #[component] pub(crate) fn ZoneLayoutProvider( - /// The active scene — the source of the zone set and the - /// scene revision carried as each save's `If-Match` precondition. - #[prop(into)] - active_scene: Signal>, /// The selected zone's id (a `Zone` id). `None`, an unknown /// id, or a Display zone leaves the canvas empty. #[prop(into)] @@ -495,7 +482,8 @@ pub(crate) fn ZoneLayoutProvider( let ws_ctx = expect_context::(); let render_canvas_size = crate::render_canvas::use_render_canvas_size(); - let session = LayoutEditorSession::new(false); + let studio_history = expect_context::(); + let session = LayoutEditorSession::new(false, studio_history, selected_zone_id); let layout = session.layout; let saved_layout = session.saved_layout; let set_saved_layout = session.set_saved_layout; @@ -507,7 +495,6 @@ pub(crate) fn ZoneLayoutProvider( // Previews are keyed by zone alone: the daemon applies them to the // live tree, so the scene is not the client's to choose. let active_preview_key = StoredValue::new(None::); - let save_generation = StoredValue::new(0_u64); let push_preview = Callback::new(move |snapshot: SpatialLayout| { let Some(zone_id) = selected_zone_id.get_untracked() else { return; @@ -540,64 +527,121 @@ pub(crate) fn ZoneLayoutProvider( attachment_profiles, }); - // Reload the canvas when the zone changes, or when the selected - // zone's OUTPUT SET changes (a device assigned / removed elsewhere). - // A placement-only change — including this canvas's own saved edits — - // leaves the signature unchanged, so an unrelated scene refetch never - // clobbers in-flight canvas edits. - let zone_signature = Memo::new(move |_| { + // Canonical placement changes refresh clean fields while local drafts keep + // their edited fields. Layer control updates leave this memo unchanged. + let canonical_zone = Memo::new(move |_| { let zone_id = selected_zone_id.get()?; - active_scene.with(|scene| { - let zone = scene - .as_ref()? - .zones - .iter() - .find(|zone| zone.id.to_string() == zone_id)?; - if zone.role == ZoneRole::Display { - return None; - } - let mut output_ids: Vec = zone - .members - .iter() - .map(|member| member.id.to_string()) - .collect(); - output_ids.sort(); - Some((zone_id, output_ids)) - }) + let scene = studio_history.current_scene_tracked()?; + let zone = scene + .zones + .iter() + .find(|zone| zone.id.to_string() == zone_id)?; + if zone.role == ZoneRole::Display { + return None; + } + let mut canonical = editor_layout_for_zone(zone); + let (width, height) = render_canvas_size.get(); + canonical.canvas_width = width; + canonical.canvas_height = height; + Some(( + scene.id, + zone_id, + layout_geometry::normalize_layout_for_editor(canonical), + )) }); - + let loaded_key = StoredValue::new(None::<(hypercolor_types::scene::SceneId, String)>); + let drafts = StoredValue::new(std::collections::HashMap::< + (hypercolor_types::scene::SceneId, String), + ZoneDraft, + >::new()); Effect::new(move |_| { - set_layout.reset_history(); - set_selected_zone_ids.set(std::collections::HashSet::new()); - set_compound_depth.set(crate::compound_selection::CompoundDepth::Root); - - let Some((zone_id, _)) = zone_signature.get() else { + let next = canonical_zone.get(); + let replay = studio_history + .layout_replay_for_zone(next.as_ref().map(|(_, zone_id, _)| zone_id.as_str())); + // Capture the outgoing editor before changing the selected surface. + if let (Some(key), Some(baseline), Some(snapshot)) = ( + loaded_key.get_value(), + saved_layout.get_untracked(), + set_layout.capture_snapshot(), + ) { + drafts.update_value(|drafts| { + let mut next = ZoneDraft { baseline, snapshot }; + if let Some(previous) = drafts.get(&key) { + // Removed members can return through assignment Undo. Keep + // their drafts without rendering them while unassigned. + for output in &previous.snapshot.zones { + if !next + .snapshot + .zones + .iter() + .any(|current| current.id == output.id) + { + next.snapshot.zones.push(output.clone()); + } + } + for output in &previous.baseline.zones { + if !next + .baseline + .zones + .iter() + .any(|current| current.id == output.id) + { + next.baseline.zones.push(output.clone()); + } + } + } + drafts.insert(key, next); + }); + } + let Some((scene_id, zone_id, canonical)) = next else { set_layout.set(None); set_saved_layout.set(None); + loaded_key.set_value(None); return; }; - let loaded = active_scene.with_untracked(|scene| { - scene.as_ref().and_then(|scene| { - scene - .zones - .iter() - .find(|zone| zone.id.to_string() == zone_id) - .map(editor_layout_for_zone) - }) - }); - match loaded { - Some(mut layout) => { - let (canvas_width, canvas_height) = render_canvas_size.get_untracked(); - layout.canvas_width = canvas_width; - layout.canvas_height = canvas_height; - let layout = layout_geometry::normalize_layout_for_editor(layout); - set_saved_layout.set(Some(layout.clone())); - set_layout.set(Some(layout)); - } - None => { - set_layout.set(None); - set_saved_layout.set(None); + let key = (scene_id, zone_id.clone()); + // Drafts from an old scene cannot be revived by a later scene switch. + drafts.update_value(|drafts| drafts.retain(|(id, _), _| *id == scene_id)); + let draft = drafts.with_value(|drafts| drafts.get(&key).cloned()); + let snapshot = draft.as_ref().map(|draft| merge_draft(&canonical, draft)); + set_saved_layout.set(Some(canonical.clone())); + set_layout.set(Some(canonical.clone())); + if let Some(snapshot) = snapshot { + set_layout.apply_snapshot(snapshot); + } else { + set_selected_zone_ids.set(std::collections::HashSet::new()); + set_compound_depth.set(crate::compound_selection::CompoundDepth::Root); + session + .set_removed_zone_cache + .set(RemovedOutputCache::new()); + } + loaded_key.set_value(Some(key)); + let applied_replay = replay.filter(|replay| replay.zone_id == zone_id); + if let Some(replay) = applied_replay.as_ref() { + let mut snapshot = replay.snapshot.clone(); + let current = layout + .get_untracked() + .expect("selected editor was initialized"); + reconcile_replay(¤t, &replay.previous, &mut snapshot); + set_layout.apply_snapshot(snapshot); + } + set_layout.update_without_history(|current| { + if let Some(layout) = current.take() { + *current = Some(layout_geometry::normalize_layout_for_editor(layout)); } + }); + let dirty = layout.with_untracked(|current| { + current + .as_ref() + .is_some_and(|current| current.zones != canonical.zones) + }); + set_layout.set_dirty.set(dirty); + if !dirty && let Some(preview) = active_preview_key.get_value() { + ws_ctx.clear_zone_layout_preview.run(preview); + active_preview_key.set_value(None); + } + if let Some(replay) = applied_replay { + studio_history.complete_layout(replay.redo); } }); @@ -658,23 +702,43 @@ pub(crate) fn ZoneLayoutProvider( let Some(zone_id) = selected_zone_id.get_untracked() else { return; }; - let Some(revision) = active_scene.get_untracked().map(|scene| scene.revision) else { + let Some(scene) = studio_history.current_scene() else { + return; + }; + let revision = scene.revision; + let Some(generation) = studio_history.begin() else { return; }; - let generation = save_generation.get_value().wrapping_add(1); - save_generation.set_value(generation); leptos::task::spawn_local(async move { match api::zones::update_zone_layout(&zone_id, ¤t, revision).await { - Ok(api::zones::ZoneOutcome::Applied(_)) => { + Ok(api::zones::ZoneOutcome::Applied(zone)) => { + if !studio_history.is_current(generation) { + return; + } + let mut acknowledged = editor_layout_for_zone(&zone); + acknowledged.canvas_width = current.canvas_width; + acknowledged.canvas_height = current.canvas_height; + let acknowledged = layout_geometry::normalize_layout_for_editor(acknowledged); + drafts.update_value(|drafts| { + if let Some(draft) = drafts.get_mut(&(scene.id, zone_id.clone())) { + draft.baseline = acknowledged.clone(); + } + }); + studio_history.record_saved_zone(scene.id, zone, revision.saturating_add(1)); let completion = zone_save_completion( selected_zone_id.get_untracked().as_deref(), active_preview_key.get_value().as_deref(), &zone_id, - save_generation.get_value() == generation, + studio_history.is_current(generation), ); if completion.update_editor { - set_saved_layout.set(Some(current)); - set_layout.mark_clean(); + let clean = layout.with_untracked(|layout| { + layout + .as_ref() + .is_some_and(|layout| layout.zones == acknowledged.zones) + }); + set_saved_layout.set(Some(acknowledged)); + set_layout.set_dirty.set(!clean); toasts::toast_success("Zone layout saved"); } if completion.clear_preview { @@ -686,11 +750,14 @@ pub(crate) fn ZoneLayoutProvider( refresh_scene.run(()); } Ok(api::zones::ZoneOutcome::Stale { .. }) => { + if !studio_history.is_current(generation) { + return; + } let completion = zone_save_completion( selected_zone_id.get_untracked().as_deref(), active_preview_key.get_value().as_deref(), &zone_id, - save_generation.get_value() == generation, + studio_history.is_current(generation), ); if completion.clear_preview { ws_ctx.clear_zone_layout_preview.run(zone_id.clone()); @@ -703,12 +770,20 @@ pub(crate) fn ZoneLayoutProvider( } refresh_scene.run(()); } - Err(error) => toasts::toast_error(&format!("Save failed: {error}")), + Err(error) => { + if studio_history.is_current(generation) { + toasts::toast_error(&format!("Save failed: {error}")); + } + } } + studio_history.finish(generation); }); }); let revert = Callback::new(move |()| { + if studio_history.busy.get_untracked() { + return; + } let Some(saved) = saved_layout.get_untracked() else { return; }; diff --git a/crates/hypercolor-ui/src/components/layout_builder/editor_session.rs b/crates/hypercolor-ui/src/components/layout_builder/editor_session.rs index c9e1a3aac..8d36469b7 100644 --- a/crates/hypercolor-ui/src/components/layout_builder/editor_session.rs +++ b/crates/hypercolor-ui/src/components/layout_builder/editor_session.rs @@ -4,11 +4,121 @@ use leptos::prelude::*; use crate::api; use crate::compound_selection::CompoundDepth; -use crate::layout_history::{LayoutHistoryState, RemovedOutputCache}; +use crate::layout_history::RemovedOutputCache; use hypercolor_types::spatial::SpatialLayout; use super::LayoutWriteHandle; +#[cfg(test)] +mod tests; + +#[derive(Clone)] +pub(super) struct ZoneDraft { + pub baseline: SpatialLayout, + pub snapshot: crate::layout_history::LayoutEditorSnapshot, +} + +/// Apply an edit's placement delta without replacing fields changed elsewhere. +fn apply_placement_delta( + target: &mut hypercolor_types::spatial::Output, + previous: &hypercolor_types::spatial::Output, + desired: &hypercolor_types::spatial::Output, +) { + if desired.position.x != previous.position.x { + target.position.x = desired.position.x; + } + if desired.position.y != previous.position.y { + target.position.y = desired.position.y; + } + if desired.size.x != previous.size.x { + target.size.x = desired.size.x; + } + if desired.size.y != previous.size.y { + target.size.y = desired.size.y; + } + if desired.rotation != previous.rotation { + target.rotation = desired.rotation; + } + if desired.scale != previous.scale { + target.scale = desired.scale; + } + if desired.orientation != previous.orientation { + target.orientation = desired.orientation; + } + if desired.topology != previous.topology { + target.topology.clone_from(&desired.topology); + } + if desired.display_order != previous.display_order { + target.display_order = desired.display_order; + } + if desired.brightness != previous.brightness { + target.brightness = desired.brightness; + } +} + +pub(super) fn merge_draft( + canonical: &SpatialLayout, + draft: &ZoneDraft, +) -> crate::layout_history::LayoutEditorSnapshot { + let mut snapshot = draft.snapshot.clone(); + snapshot.zones = canonical + .zones + .iter() + .map(|output| { + let mut merged = output.clone(); + if let Some(local) = draft + .snapshot + .zones + .iter() + .find(|local| local.id == output.id) + && let Some(saved) = draft + .baseline + .zones + .iter() + .find(|saved| saved.id == output.id) + && local.device_id == output.device_id + && local.zone_name == output.zone_name + { + apply_placement_delta(&mut merged, saved, local); + } + merged + }) + .collect(); + snapshot + .selected_zone_ids + .retain(|id| snapshot.zones.iter().any(|output| &output.id == id)); + snapshot +} + +pub(super) fn reconcile_replay( + current: &SpatialLayout, + previous: &crate::layout_history::LayoutEditorSnapshot, + snapshot: &mut crate::layout_history::LayoutEditorSnapshot, +) { + snapshot.zones = current + .zones + .iter() + .map(|output| { + let mut merged = output.clone(); + if let Some(old) = snapshot.zones.iter().find(|old| { + old.id == output.id + && old.device_id == output.device_id + && old.zone_name == output.zone_name + }) && let Some(before) = previous.zones.iter().find(|before| { + before.id == output.id + && before.device_id == output.device_id + && before.zone_name == output.zone_name + }) { + apply_placement_delta(&mut merged, before, old); + } + merged + }) + .collect(); + snapshot + .selected_zone_ids + .retain(|id| snapshot.zones.iter().any(|output| &output.id == id)); +} + /// Shared layout editor state — provided via context to palette, canvas, and zone properties. #[derive(Clone, Copy)] pub(crate) struct LayoutEditorContext { @@ -92,7 +202,11 @@ pub(super) struct LayoutEditorSession { } impl LayoutEditorSession { - pub(super) fn new(keep_aspect_ratio_initial: bool) -> Self { + pub(super) fn new( + keep_aspect_ratio_initial: bool, + studio_history: crate::pages::studio::history::StudioHistory, + selected_surface: Signal>, + ) -> Self { let (layout, set_layout_signal) = signal(None::); let (saved_layout, set_saved_layout) = signal(None::); let (selected_zone_ids, set_selected_zone_ids) = signal(HashSet::::new()); @@ -103,7 +217,6 @@ impl LayoutEditorSession { let (removed_zone_cache, set_removed_zone_cache) = signal(RemovedOutputCache::new()); let (dirty, set_is_dirty) = signal(false); let pointer_zone_id = RwSignal::new(None::); - let history = RwSignal::new(LayoutHistoryState::default()); let write = LayoutWriteHandle { layout, set_layout: set_layout_signal, @@ -113,7 +226,9 @@ impl LayoutEditorSession { set_compound_depth, removed_zone_cache, set_removed_zone_cache, - history, + studio_history, + selected_surface, + interaction_start: RwSignal::new(None), set_dirty: set_is_dirty, }; @@ -135,8 +250,8 @@ impl LayoutEditorSession { set_removed_zone_cache, write, layout_signal: Signal::derive(move || layout.get()), - can_undo: Signal::derive(move || history.get().can_undo()), - can_redo: Signal::derive(move || history.get().can_redo()), + can_undo: studio_history.can_undo, + can_redo: studio_history.can_redo, is_dirty: Signal::derive(move || dirty.get()), pointer_zone_id, } diff --git a/crates/hypercolor-ui/src/components/layout_builder/editor_session/tests.rs b/crates/hypercolor-ui/src/components/layout_builder/editor_session/tests.rs new file mode 100644 index 000000000..6dc3b5482 --- /dev/null +++ b/crates/hypercolor-ui/src/components/layout_builder/editor_session/tests.rs @@ -0,0 +1,174 @@ +use super::{ZoneDraft, merge_draft, reconcile_replay}; +use crate::compound_selection::CompoundDepth; +use crate::layout_history::{LayoutEditorSnapshot, RemovedOutputCache}; +use hypercolor_types::spatial::{ + EdgeBehavior, LedTopology, NormalizedPosition, Output, SamplingMode, SpatialLayout, + StripDirection, +}; + +fn zone(id: &str, x: f32) -> Output { + Output { + id: id.to_owned(), + name: id.to_owned(), + device_id: "device-1".to_owned(), + zone_name: None, + position: NormalizedPosition::new(x, 0.5), + size: NormalizedPosition::new(0.1, 0.1), + rotation: 0.0, + scale: 1.0, + display_order: 0, + orientation: None, + topology: LedTopology::Strip { + count: 30, + direction: StripDirection::LeftToRight, + }, + led_positions: Vec::new(), + led_mapping: None, + sampling_mode: None, + edge_behavior: None, + shape: None, + shape_preset: None, + attachment: None, + brightness: None, + } +} + +fn layout(zones: Vec) -> SpatialLayout { + SpatialLayout { + id: "layout".to_owned(), + name: "Layout".to_owned(), + description: None, + canvas_width: 320, + canvas_height: 200, + zones, + default_sampling_mode: SamplingMode::Bilinear, + default_edge_behavior: EdgeBehavior::Clamp, + version: 1, + } +} + +fn snapshot(layout: &SpatialLayout, selected: &[&str]) -> LayoutEditorSnapshot { + LayoutEditorSnapshot { + zones: layout.zones.clone(), + selected_zone_ids: selected.iter().map(|id| (*id).to_owned()).collect(), + compound_depth: CompoundDepth::Root, + removed_zone_cache: RemovedOutputCache::new(), + } +} + +#[test] +fn draft_merge_keeps_only_local_field_changes() { + let baseline = layout(vec![zone("one", 0.2)]); + let mut local = baseline.clone(); + local.zones[0].position.x = 0.7; + let mut canonical = baseline.clone(); + canonical.zones[0].rotation = 1.0; + canonical.zones[0].name = "Updated device name".to_owned(); + canonical.zones.push(zone("new", 0.9)); + let merged = merge_draft( + &canonical, + &ZoneDraft { + baseline, + snapshot: snapshot(&local, &["one"]), + }, + ); + assert_eq!(merged.zones[0].position.x, 0.7); + assert_eq!(merged.zones[0].rotation, 1.0); + assert_eq!(merged.zones[0].name, "Updated device name"); + assert_eq!(merged.zones[1], canonical.zones[1]); +} + +#[test] +fn clean_draft_accepts_canonical_placement_changes() { + let baseline = layout(vec![zone("one", 0.2)]); + let canonical = layout(vec![zone("one", 0.8)]); + let merged = merge_draft( + &canonical, + &ZoneDraft { + snapshot: snapshot(&baseline, &[]), + baseline, + }, + ); + assert_eq!(merged.zones, canonical.zones); +} + +#[test] +fn brightness_draft_survives_refresh_and_replays_both_directions() { + let before = layout(vec![zone("one", 0.2)]); + let mut after = before.clone(); + after.zones[0].brightness = Some(0.4); + let mut canonical = before.clone(); + canonical.zones[0].position.x = 0.8; + let merged = merge_draft( + &canonical, + &ZoneDraft { + baseline: before.clone(), + snapshot: snapshot(&after, &["one"]), + }, + ); + assert_eq!(merged.zones[0].brightness, Some(0.4)); + assert_eq!(merged.zones[0].position.x, 0.8); + + canonical.zones = merged.zones; + let mut undo = snapshot(&before, &["one"]); + reconcile_replay(&canonical, &snapshot(&after, &[]), &mut undo); + assert_eq!(undo.zones[0].brightness, None); + assert_eq!(undo.zones[0].position.x, 0.8); + + canonical.zones = undo.zones; + let mut redo = snapshot(&after, &["one"]); + reconcile_replay(&canonical, &snapshot(&before, &[]), &mut redo); + assert_eq!(redo.zones[0].brightness, Some(0.4)); + assert_eq!(redo.zones[0].position.x, 0.8); +} + +#[test] +fn replay_preserves_current_membership_and_binding() { + let old = layout(vec![ + zone("removed", 0.1), + zone("one", 0.2), + zone("rebound", 0.3), + ]); + let mut canonical = layout(vec![ + zone("one", 0.8), + zone("new", 0.9), + zone("rebound", 0.6), + ]); + canonical.zones[2].device_id = "replacement-device".to_owned(); + let mut previous = snapshot(&old, &[]); + previous.zones[1].position.x = 0.8; + previous.zones[2].position.x = 0.6; + let mut replay = snapshot(&old, &["removed", "one"]); + reconcile_replay(&canonical, &previous, &mut replay); + assert_eq!(replay.zones.len(), 3); + assert_eq!(replay.zones[0].position.x, 0.2); + assert_eq!(replay.zones[1], canonical.zones[1]); + assert_eq!(replay.zones[2], canonical.zones[2]); + assert_eq!( + replay.selected_zone_ids, + ["one".to_owned()].into_iter().collect() + ); +} + +#[test] +fn undo_position_preserves_unrelated_remote_fields_and_other_outputs() { + let before = layout(vec![zone("one", 0.2), zone("two", 0.3)]); + let mut after = before.clone(); + after.zones[0].position.x = 0.7; + let mut current = after.clone(); + current.zones[0].size = NormalizedPosition::new(0.25, 0.35); + current.zones[0].position.y = 0.8; + current.zones[1].position.x = 0.9; + let mut undo = snapshot(&before, &["one"]); + reconcile_replay(¤t, &snapshot(&after, &["one"]), &mut undo); + assert_eq!(undo.zones[0].position, NormalizedPosition::new(0.2, 0.8)); + assert_eq!(undo.zones[0].size, current.zones[0].size); + assert_eq!(undo.zones[1], current.zones[1]); + + current.zones.clone_from(&undo.zones); + let mut redo = snapshot(&after, &["one"]); + reconcile_replay(¤t, &snapshot(&before, &["one"]), &mut redo); + assert_eq!(redo.zones[0].position, NormalizedPosition::new(0.7, 0.8)); + assert_eq!(redo.zones[0].size, current.zones[0].size); + assert_eq!(redo.zones[1], current.zones[1]); +} diff --git a/crates/hypercolor-ui/src/components/page_search_bar.rs b/crates/hypercolor-ui/src/components/page_search_bar.rs index 0565430cc..9b9949cad 100644 --- a/crates/hypercolor-ui/src/components/page_search_bar.rs +++ b/crates/hypercolor-ui/src/components/page_search_bar.rs @@ -34,7 +34,7 @@ pub fn PageSearchBar( class="w-full bg-surface-overlay/60 border border-edge-subtle rounded-lg pl-9 pr-10 py-1.5 \ text-sm text-fg-primary placeholder-fg-tertiary \ focus:outline-none focus:border-accent-muted \ - search-glow glow-ring transition-all duration-300" + search-glow glow-ring transition-[color,background-color,border-color,box-shadow,opacity,transform] duration-300" prop:value=move || value.get() on:input=move |ev: ev::Event| { let event = Input::from_event(ev); diff --git a/crates/hypercolor-ui/src/layout_history.rs b/crates/hypercolor-ui/src/layout_history.rs index 4e5dc45f8..bdefc4571 100644 --- a/crates/hypercolor-ui/src/layout_history.rs +++ b/crates/hypercolor-ui/src/layout_history.rs @@ -4,7 +4,7 @@ use hypercolor_types::spatial::Output; use crate::compound_selection::CompoundDepth; -const MAX_HISTORY_DEPTH: usize = 100; +pub(crate) const MAX_HISTORY_DEPTH: usize = 100; pub type RemovedOutputCache = HashMap<(String, Option), Output>; diff --git a/crates/hypercolor-ui/src/pages/effects/zone_controls.rs b/crates/hypercolor-ui/src/pages/effects/zone_controls.rs index f77eb5457..657d37edc 100644 --- a/crates/hypercolor-ui/src/pages/effects/zone_controls.rs +++ b/crates/hypercolor-ui/src/pages/effects/zone_controls.rs @@ -35,8 +35,9 @@ const ZONE_CONTROLS_DEBOUNCE_MS: f64 = 75.0; /// Control schemas cached per effect id. Zone values live in the scene, /// but the control *definitions* come from `fetch_effect_detail`; the -/// cache keeps tab switches from refetching a schema already seen. -pub type ZoneControlSchemaCache = StoredValue>>; +/// cache keeps tab switches from refetching a schema already seen. Entries +/// carry the connection epoch so reconnecting refreshes their definitions. +pub type ZoneControlSchemaCache = StoredValue)>>; /// The controls-card body: an optional zone tab strip plus the panel /// for the selected zone. The primary tab renders the caller-supplied @@ -80,6 +81,30 @@ pub fn ZoneScopedControls( fx.zone_effects .with(|zones| zones.iter().find(|state| state.zone.id == zone_id).cloned()) }); + // Only immutable write authority owns the panel lifetime. Values and + // revisions reconcile inside the existing session instead of remounting it. + let selected_target = Memo::new(move |_| { + let state = selected_state.get()?; + let effect_id = state.effect_id?; + zones_ctx.active_scene.with(|scene| { + let scene = scene.as_ref()?; + let zone = scene + .zones + .iter() + .find(|zone| zone.id.to_string() == state.zone.id)?; + let layer_id = zone + .layers + .iter() + .rev() + .find_map(|layer| match &layer.source { + LayerSource::Effect { + effect_id: current, .. + } if current.to_string() == effect_id => Some(layer.id.to_string()), + _ => None, + })?; + Some((scene.id.to_string(), state.zone.id, layer_id, effect_id)) + }) + }); view! {
@@ -90,7 +115,7 @@ pub fn ZoneScopedControls( .then(|| view! { }) }} {move || { - let Some(state) = selected_state.get() else { + let Some(_) = selected_zone_id.get() else { // Primary tab — today's exact panel. return view! { } @@ -111,7 +136,9 @@ pub fn ZoneScopedControls( view! { } @@ -197,63 +224,60 @@ fn ZoneTabStrip(selected_zone_id: Memo>) -> impl IntoView { /// Controls for one non-primary zone's directly-assigned effect. The /// schema comes from the (cached) effect detail; values seed from the /// zone's scene-stored controls; edits run through the shared patch -/// session against the real layer returned by `/scene`. The host body -/// re-mounts this panel whenever the zone or its effect changes, so the -/// seeds are always fresh. +/// session against the real layer returned by the scene. Only a change to +/// scene, zone, layer, or effect identity retires the panel and its session. #[component] fn ZoneControlsPanel( effect_id: String, - state: ZoneEffectState, + zone_id: String, + layer_id: String, + state: Memo>, schema_cache: ZoneControlSchemaCache, ) -> impl IntoView { let zones_ctx = expect_context::(); - let zone_id = state.zone.id.clone(); - let layer_id = zones_ctx.active_scene.with_untracked(|scene| { - scene - .as_ref()? - .zones - .iter() - .find(|zone| zone.id.to_string() == zone_id)? - .layers - .iter() - .rev() - .find_map(|layer| match &layer.source { - LayerSource::Effect { - effect_id: current, .. - } if current.to_string() == effect_id => Some(layer.id.to_string()), - _ => None, - }) + let ws = expect_context::(); + let accent_rgb = Signal::derive(move || { + let category = state + .get() + .and_then(|state| state.effect_category) + .unwrap_or_default(); + category_accent_rgb(&category).to_string() }); - let Some(layer_id) = layer_id else { - return view! { } - .into_any(); - }; - let accent_rgb = { - let category = state.effect_category.clone().unwrap_or_default(); - Signal::derive(move || category_accent_rgb(&category).to_string()) - }; let schema = api::daemon_resource({ let effect_id = effect_id.clone(); move || { let effect_id = effect_id.clone(); + let generation = ws.connection_generation.get(); async move { - if let Some(defs) = schema_cache.with_value(|cache| cache.get(&effect_id).cloned()) + if let Some((epoch, defs)) = + schema_cache.with_value(|cache| cache.get(&effect_id).cloned()) + && epoch == generation { return Ok(defs); } let detail = api::fetch_effect_detail(&effect_id).await?; - schema_cache.update_value(|cache| { - cache.insert(effect_id, detail.controls.clone()); + schema_cache.try_update_value(|cache| { + cache.insert(effect_id, (generation, detail.controls.clone())); }); Ok::<_, api::ApiError>(detail.controls) } } }); - let defs = Signal::derive(move || schema.get().and_then(Result::ok).unwrap_or_default()); + let schema_value = Signal::derive(move || { + schema.get().and_then(Result::ok).or_else(|| { + schema_cache.with_value(|cache| cache.get(&effect_id).map(|(_, defs)| defs.clone())) + }) + }); + let defs = Signal::derive(move || schema_value.get().unwrap_or_default()); // Optimistic local values, seeded from the zone's scene state. - let (values, set_values) = signal(state.control_values.clone()); + let (values, set_values) = signal( + state + .get_untracked() + .map(|state| state.control_values) + .unwrap_or_default(), + ); // The layer id came from the live document. Replacement retires it, so // a stale control patch cannot land on a newer effect. @@ -282,27 +306,35 @@ fn ZoneControlsPanel( on_error: Callback::new(|error: String| { toasts::toast_error(&format!("Zone controls failed: {error}")); }), - recover: zones_ctx.refresh, + recover: Callback::new(move |()| { + if let Some(state) = state.get_untracked() { + set_values.set(state.control_values); + } + zones_ctx.refresh.run(()); + }), on_committed: None, flush_guard: None, }); + Effect::new(move |_| { + if let Some(state) = state.get() { + session.reconcile_values.run(state.control_values); + } + }); + let on_change = session.on_change; + view! { - {move || { - if schema.get().is_none() { - view! { }.into_any() - } else { - view! { - - } - .into_any() - } - }} + } + > + + } .into_any() } diff --git a/crates/hypercolor-ui/src/pages/studio/device_card.rs b/crates/hypercolor-ui/src/pages/studio/device_card.rs index 29dc143e5..66660dadd 100644 --- a/crates/hypercolor-ui/src/pages/studio/device_card.rs +++ b/crates/hypercolor-ui/src/pages/studio/device_card.rs @@ -14,7 +14,6 @@ use leptos_icons::Icon; use hypercolor_types::scene::ZoneRole; -use crate::api::zones::ZoneOutcome; use crate::api::{self, DeviceSummary, SegmentTopologySummary}; use crate::channel_names; use crate::components::device_card::{ @@ -566,6 +565,8 @@ fn card_actions(args: CardActionsArgs) -> impl IntoView { class="btn-press flex h-6 w-6 items-center justify-center rounded-md transition-colors" style="color: rgba(80, 250, 123, 0.78)" title="Add to this zone" + disabled=move || studio.history.busy.get() + class=("opacity-40", move || studio.history.busy.get()) on:click=move |ev: web_sys::MouseEvent| { ev.stop_propagation(); assign_device_to_zone(studio, device.clone(), select.clone()); @@ -587,6 +588,8 @@ fn card_actions(args: CardActionsArgs) -> impl IntoView { class=("bg-surface-hover/40", move || ops_open.get()) style="color: rgba(80, 250, 123, 0.78)" title="Add to a zone" + disabled=move || studio.history.busy.get() + class=("opacity-40", move || studio.history.busy.get()) on:click=move |ev: web_sys::MouseEvent| { ev.stop_propagation(); ops_open.update(|open| *open = !*open); @@ -643,6 +646,8 @@ fn unassigned_add_menu( ops_open.set(false); assign_device_to_zone(studio, device.get_value(), zone_id.clone()); } + disabled=move || studio.history.busy.get() + class=("opacity-40", move || studio.history.busy.get()) > {format!("Add to {zone_name}")} @@ -725,6 +730,8 @@ fn card_ops_menu( ops_open.set(false); move_outputs_to_zone(studio, zone_id.clone(), ids.get_value()); } + disabled=move || studio.history.busy.get() + class=("opacity-40", move || studio.history.busy.get()) > {format!("Move to {zone_name}")} @@ -742,6 +749,8 @@ fn card_ops_menu( ops_open.set(false); remove_device_from_zone(studio, current.get_value(), remove_device_id.get_value()); } + disabled=move || studio.history.busy.get() + class=("opacity-40", move || studio.history.busy.get()) > "Remove from zone" @@ -754,33 +763,41 @@ fn card_ops_menu( /// as existing outputs in one call. The daemon moves each output out of /// whatever zone currently holds it. fn move_outputs_to_zone(studio: StudioContext, target_zone_id: String, output_ids: Vec) { - if output_ids.is_empty() { + use hypercolor_types::api::scene::{MemberEdit, MemberState}; + let history = studio.history; + let Some(scene) = history.current_scene() else { return; - } - let Some(scene) = studio.active_scene.get_untracked() else { - toasts::toast_error("No active scene is available"); + }; + let Some(target) = scene + .zones + .iter() + .find(|zone| zone.id.to_string() == target_zone_id) + else { return; }; - let count = output_ids.len(); - let assignments = output_ids - .into_iter() - .map(|id| api::zones::OutputAssignment::Existing { id }) - .collect::>(); - let revision = scene.revision; - spawn_local(async move { - match api::zones::assign_devices(&target_zone_id, assignments, false, revision).await { - Ok(ZoneOutcome::Applied(_)) => { - let suffix = if count == 1 { "" } else { "s" }; - toasts::toast_success(&format!("Moved {count} output{suffix}")); - studio.refresh_scene.run(()); - } - Ok(ZoneOutcome::Stale { .. }) => { - toasts::toast_error("Scene changed elsewhere \u{2014} reloaded, try again"); - studio.refresh_scene.run(()); + let mut changes = Vec::new(); + for zone in &scene.zones { + if zone.id == target.id { + continue; + } + for (index, output) in api::zone_outputs(zone).into_iter().enumerate() { + if output_ids.contains(&output.id) { + changes.push(MemberEdit { + before: Some(MemberState { + zone_id: zone.id, + output: output.clone(), + index, + }), + after: Some(MemberState { + zone_id: target.id, + output, + index: target.members.len() + changes.len(), + }), + }); } - Err(error) => toasts::toast_error(&format!("Move failed: {error}")), } - }); + } + history.edit_members(changes); } /// Render one per-channel row beneath the card body. @@ -1051,45 +1068,32 @@ fn identify_device_now(device_id: &str, set_identifying: WriteSignal) { /// revision into the next call lets a multi-output controller leave the /// zone in a single user action. pub(super) fn remove_device_from_zone(studio: StudioContext, zone_id: String, device_id: String) { - let Some(scene) = studio.active_scene.get_untracked() else { - toasts::toast_error("No active scene is available"); + use hypercolor_types::api::scene::{MemberEdit, MemberState}; + let history = studio.history; + let Some(scene) = history.current_scene() else { return; }; - let output_ids: Vec = scene + let Some(zone) = scene .zones .iter() .find(|zone| zone.id.to_string() == zone_id) - .map(|zone| { - zone.members - .iter() - .filter(|member| member.device_id == device_id) - .map(|member| member.id.to_string()) - .collect() - }) - .unwrap_or_default(); - if output_ids.is_empty() { + else { return; - } - let mut revision = scene.revision; - spawn_local(async move { - for output_id in output_ids { - match api::zones::unassign_device(&zone_id, &output_id, revision).await { - Ok(ZoneOutcome::Applied(next)) => revision = next, - Ok(ZoneOutcome::Stale { .. }) => { - toasts::toast_error("Scene changed elsewhere — reloaded, try again"); - studio.refresh_scene.run(()); - return; - } - Err(error) => { - toasts::toast_error(&format!("Remove failed: {error}")); - studio.refresh_scene.run(()); - return; - } - } - } - toasts::toast_success("Device removed from zone"); - studio.refresh_scene.run(()); - }); + }; + let changes = api::zone_outputs(zone) + .into_iter() + .enumerate() + .filter(|(_, output)| output.device_id == device_id) + .map(|(index, output)| MemberEdit { + before: Some(MemberState { + zone_id: zone.id, + output, + index, + }), + after: None, + }) + .collect(); + history.edit_members(changes); } /// Group a number's digits in threes: `230400` → `"230,400"`. diff --git a/crates/hypercolor-ui/src/pages/studio/history.rs b/crates/hypercolor-ui/src/pages/studio/history.rs new file mode 100644 index 000000000..f4d2ddd8c --- /dev/null +++ b/crates/hypercolor-ui/src/pages/studio/history.rs @@ -0,0 +1,516 @@ +//! One chronological journal for Studio placements and persisted assignments. + +use crate::{api, layout_history::LayoutEditorSnapshot, toasts}; +use hypercolor_types::api::scene::{EditMembersRequest, EditMembersResponse, MemberEdit}; +use leptos::prelude::*; + +#[derive(Clone, Debug, PartialEq)] +pub enum StudioEdit { + Layout { + zone_id: String, + before: Box, + after: Box, + }, + Members(Vec), +} + +#[derive(Clone, Debug, Default)] +pub struct StudioJournal { + entries: Vec, + cursor: usize, +} + +impl StudioJournal { + pub fn record(&mut self, edit: StudioEdit) { + self.entries.truncate(self.cursor); + self.entries.push(edit); + if self.entries.len() > crate::layout_history::MAX_HISTORY_DEPTH { + self.entries.remove(0); + } + self.cursor = self.entries.len(); + } + pub fn undo_edit(&self) -> Option<&StudioEdit> { + self.cursor.checked_sub(1).and_then(|i| self.entries.get(i)) + } + pub fn redo_edit(&self) -> Option<&StudioEdit> { + self.entries.get(self.cursor) + } + pub fn complete(&mut self, redo: bool) { + if redo && self.cursor < self.entries.len() { + self.cursor += 1; + } else if !redo && self.cursor > 0 { + self.cursor -= 1; + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct LayoutReplay { + pub zone_id: String, + pub snapshot: LayoutEditorSnapshot, + pub previous: LayoutEditorSnapshot, + pub redo: bool, +} + +#[derive(Clone, Copy)] +pub(crate) struct StudioHistory { + pub journal: RwSignal, + pub busy: RwSignal, + pub pending_layout: RwSignal>, + pub can_undo: Signal, + pub can_redo: Signal, + scene: Signal>, + latest: RwSignal>, + generation: RwSignal, + selected: RwSignal>, + refresh: Callback<()>, +} + +impl StudioHistory { + pub fn new( + scene: Signal>, + selected: RwSignal>, + refresh: Callback<()>, + ) -> Self { + let journal = RwSignal::new(StudioJournal::default()); + let busy = RwSignal::new(false); + let pending_layout = RwSignal::new(None); + let latest = RwSignal::new(None); + let generation = RwSignal::new(0_u64); + let scene_id = Memo::new(move |_| scene.with(|s| s.as_ref().map(|s| s.id))); + Effect::new(move |_| { + scene_id.get(); + journal.set(StudioJournal::default()); + pending_layout.set(None); + latest.set(None); + generation.update(|g| *g = g.wrapping_add(1)); + busy.set(false); + }); + Self { + journal, + busy, + pending_layout, + scene, + latest, + generation, + selected, + refresh, + can_undo: Signal::derive(move || { + !busy.get() && journal.with(|j| j.undo_edit().is_some()) + }), + can_redo: Signal::derive(move || { + !busy.get() && journal.with(|j| j.redo_edit().is_some()) + }), + } + } + + pub fn current_scene(self) -> Option { + let scene = self.scene.get_untracked()?; + Some( + self.latest + .with_untracked(|latest| { + latest + .as_ref() + .filter(|s| s.id == scene.id && s.revision > scene.revision) + .cloned() + }) + .unwrap_or(scene), + ) + } + + pub fn current_scene_tracked(self) -> Option { + let scene = self.scene.get()?; + Some( + self.latest + .with(|latest| { + latest + .as_ref() + .filter(|s| s.id == scene.id && s.revision > scene.revision) + .cloned() + }) + .unwrap_or(scene), + ) + } + + pub fn record_saved_zone( + self, + scene_id: hypercolor_types::scene::SceneId, + zone: api::ZoneResource, + revision: u64, + ) { + if let Some(mut scene) = self + .current_scene() + .filter(|scene| scene.id == scene_id && scene.revision <= revision) + { + if let Some(current) = scene.zones.iter_mut().find(|current| current.id == zone.id) { + *current = zone; + } + scene.revision = revision; + self.latest.set(Some(scene)); + } + } + + pub fn begin(self) -> Option { + if self.busy.get_untracked() { + return None; + } + self.busy.set(true); + self.generation.update(|g| *g = g.wrapping_add(1)); + Some(self.generation.get_untracked()) + } + + pub fn is_current(self, generation: u64) -> bool { + self.generation.try_get_untracked() == Some(generation) + } + pub fn finish(self, generation: u64) { + if self.is_current(generation) { + self.busy.set(false); + } + } + pub fn complete_layout(self, redo: bool) { + self.journal.update(|j| j.complete(redo)); + self.pending_layout.set(None); + self.busy.set(false); + } + + pub fn layout_replay_for_zone(self, zone_id: Option<&str>) -> Option { + let replay = self.pending_layout.get()?; + if zone_id == Some(replay.zone_id.as_str()) { + return Some(replay); + } + // Selection can change between the toolbar event and the provider's + // effect. Cancel the unapplied step without consuming its history. + self.pending_layout.set(None); + self.busy.set(false); + toasts::toast_info("Layout history cancelled after the selected zone changed"); + None + } + + pub fn record_layout( + self, + zone_id: String, + before: LayoutEditorSnapshot, + after: LayoutEditorSnapshot, + ) { + if before != after { + self.journal.update(|j| { + j.record(StudioEdit::Layout { + zone_id, + before: Box::new(before), + after: Box::new(after), + }) + }); + } + } + + pub fn assign_device( + self, + zone_id: String, + device_id: String, + segments: Vec, + placements: Vec, + ) { + let Some(scene) = self.current_scene() else { + return; + }; + let Some(zone) = scene + .zones + .iter() + .find(|zone| zone.id.to_string() == zone_id) + else { + return; + }; + self.submit( + EditMembersRequest { + scene_id: scene.id, + changes: Vec::new(), + assignment: Some(hypercolor_types::api::scene::MemberAssignmentTarget { + zone_id: zone.id, + device_id, + segments, + placements, + }), + }, + scene.revision, + ); + } + + pub fn edit_members(self, changes: Vec) { + if changes.is_empty() { + return; + } + let Some(scene) = self.current_scene() else { + return; + }; + self.submit( + EditMembersRequest { + scene_id: scene.id, + changes, + assignment: None, + }, + scene.revision, + ); + } + + fn submit(self, request: EditMembersRequest, revision: u64) { + let Some(generation) = self.begin() else { + return; + }; + let selected = self.selected.get_untracked(); + let destination = request.assignment.as_ref().and_then(|assignment| { + (selected.as_deref() == Some(super::surface::UNASSIGNED_SURFACE_ID)) + .then(|| assignment.zone_id.to_string()) + }); + leptos::task::spawn_local(async move { + let result = api::zones::edit_members(&request, revision).await; + // Disposed owners and superseded operations cannot consume a reply. + if !self.is_current(generation) { + return; + } + match result { + Ok(api::zones::ZoneOutcome::Applied(response)) => { + if !response.changes.is_empty() { + self.journal + .update(|j| j.record(StudioEdit::Members(response.changes.clone()))); + } + self.accept(response); + // Keep the successful assignment and its Undo action visible, + // unless the user selected another surface while it saved. + if let Some(destination) = destination + && self.selected.get_untracked() == selected + { + self.selected.set(Some(destination)); + } + toasts::toast_success("Assignment saved"); + } + Ok(api::zones::ZoneOutcome::Stale { .. }) => toasts::toast_error( + "Scene changed elsewhere; assignment was not saved. Try again.", + ), + Err(error) => toasts::toast_error(&format!("Assignment failed: {error}")), + } + self.refresh.run(()); + self.finish(generation); + }); + } + + fn accept(self, response: EditMembersResponse) { + self.latest.set(Some(response.document)); + } + + pub fn replay(self, redo: bool) { + if self.busy.get_untracked() { + return; + } + let edit = self + .journal + .with_untracked(|j| if redo { j.redo_edit() } else { j.undo_edit() }.cloned()); + let Some(edit) = edit else { + return; + }; + match edit { + StudioEdit::Layout { + zone_id, + before, + after, + } => { + if !self.current_scene().is_some_and(|scene| { + scene.zones.iter().any(|zone| { + zone.id.to_string() == zone_id + && zone.role != hypercolor_types::scene::ZoneRole::Display + }) + }) { + toasts::toast_error("The edited zone is no longer available"); + return; + } + self.busy.set(true); + self.selected.set(Some(zone_id.clone())); + self.pending_layout.set(Some(LayoutReplay { + zone_id, + snapshot: if redo { + after.as_ref().clone() + } else { + before.as_ref().clone() + }, + previous: if redo { *before } else { *after }, + redo, + })); + } + StudioEdit::Members(changes) => { + let Some(scene) = self.current_scene() else { + return; + }; + let changes = changes + .into_iter() + .map(|change| { + if redo { + change + } else { + MemberEdit { + before: change.after, + after: change.before, + } + } + }) + .collect::>(); + let Ok(changes) = prepare_members_replay(&scene, changes) else { + toasts::toast_error("Assignment changed elsewhere; history was not applied"); + return; + }; + let Some(generation) = self.begin() else { + return; + }; + leptos::task::spawn_local(async move { + let request = EditMembersRequest { + scene_id: scene.id, + changes, + assignment: None, + }; + let result = api::zones::edit_members(&request, scene.revision).await; + if !self.is_current(generation) { + return; + } + match result { + Ok(api::zones::ZoneOutcome::Applied(response)) => { + if self.is_current(generation) + && self.scene.with_untracked(|s| { + s.as_ref().is_some_and(|s| s.id == scene.id) + }) + { + self.journal.update(|j| { + let index = if redo { j.cursor } else { j.cursor - 1 }; + let changes = response + .changes + .iter() + .cloned() + .map(|change| { + if redo { + change + } else { + MemberEdit { + before: change.after, + after: change.before, + } + } + }) + .collect(); + j.entries[index] = StudioEdit::Members(changes); + j.complete(redo); + }); + self.accept(response); + toasts::toast_success(if redo { + "Assignment redone and saved" + } else { + "Assignment undone and saved" + }); + } + } + Ok(api::zones::ZoneOutcome::Stale { .. }) => toasts::toast_error( + "Scene changed elsewhere; history was not applied. Try again.", + ), + Err(error) => { + toasts::toast_error(&format!("History could not be applied: {error}")) + } + } + self.refresh.run(()); + self.finish(generation); + }); + } + } + } +} + +/// Resolve live preconditions while retaining unrelated placement edits. +/// Ownership and binding must still match the operation being reversed. +pub fn prepare_members_replay( + scene: &api::SceneDocument, + changes: Vec, +) -> Result, String> { + changes + .into_iter() + .map(|mut change| { + if let Some(before) = change.before.as_mut() { + let zone = scene + .zones + .iter() + .find(|zone| zone.id == before.zone_id) + .ok_or("Zone no longer exists")?; + let (index, output) = api::zone_outputs(zone) + .into_iter() + .enumerate() + .find(|(_, output)| output.id == before.output.id) + .ok_or("Output no longer belongs to the zone")?; + if output.device_id != before.output.device_id + || output.zone_name != before.output.zone_name + { + return Err("Output binding changed".into()); + } + before.output = output.clone(); + before.index = index; + if let Some(after) = change.after.as_mut() { + after.output = output; + } + } + Ok(change) + }) + .collect() +} + +#[cfg(test)] +mod replay_tests { + use super::*; + + #[test] + fn changed_selection_cancels_pending_replay_without_consuming_history() { + let owner = Owner::new(); + owner.with(|| { + let snapshot = LayoutEditorSnapshot { + zones: Vec::new(), + selected_zone_ids: Default::default(), + compound_depth: crate::compound_selection::CompoundDepth::Root, + removed_zone_cache: Default::default(), + }; + let journal = RwSignal::new(StudioJournal::default()); + journal.update(|journal| { + journal.record(StudioEdit::Layout { + zone_id: "one".into(), + before: Box::new(snapshot.clone()), + after: Box::new(snapshot.clone()), + }) + }); + let pending_layout = RwSignal::new(Some(LayoutReplay { + zone_id: "one".into(), + snapshot: snapshot.clone(), + previous: snapshot, + redo: false, + })); + let history = StudioHistory { + journal, + busy: RwSignal::new(true), + pending_layout, + can_undo: Signal::stored(true), + can_redo: Signal::stored(false), + scene: Signal::stored(None), + latest: RwSignal::new(None), + generation: RwSignal::new(1), + selected: RwSignal::new(Some("two".into())), + refresh: Callback::new(|()| {}), + }; + assert!(history.layout_replay_for_zone(Some("one")).is_some()); + assert!(history.busy.get_untracked()); + assert!(history.layout_replay_for_zone(Some("two")).is_none()); + assert!(!history.busy.get_untracked()); + assert!(history.pending_layout.get_untracked().is_none()); + assert!( + history + .journal + .with_untracked(|journal| journal.undo_edit().is_some()) + ); + assert!( + history + .journal + .with_untracked(|journal| journal.redo_edit().is_none()) + ); + }); + owner.cleanup(); + } +} diff --git a/crates/hypercolor-ui/src/pages/studio/mod.rs b/crates/hypercolor-ui/src/pages/studio/mod.rs index b36c68fc3..6e2dfeebf 100644 --- a/crates/hypercolor-ui/src/pages/studio/mod.rs +++ b/crates/hypercolor-ui/src/pages/studio/mod.rs @@ -9,6 +9,7 @@ mod composition_panel; pub mod device_assignment; mod device_card; mod face_composition; +pub mod history; mod offline_device_card; mod scene_selector; mod stage; @@ -85,6 +86,7 @@ fn empty_layer_stack() -> api::LayerStackResponse { /// provided to the columns so surface selection is one source of truth. #[derive(Clone, Copy)] pub struct StudioContext { + pub(crate) history: history::StudioHistory, pub selected_surface_id: RwSignal>, pub active_scene: Signal>, /// Re-fetch the active scene. Zone mutations call this so the tree and @@ -372,7 +374,11 @@ pub fn StudioPage() -> impl IntoView { let zone_rename_draft = RwSignal::new(None::<(String, String)>); let pointer_output_id = RwSignal::new(None::); + let history = history::StudioHistory::new(active_scene, selected_surface_id, refresh_scene); + provide_context(history); + provide_context(StudioContext { + history, selected_surface_id, active_scene, refresh_scene, @@ -450,7 +456,6 @@ pub fn StudioPage() -> impl IntoView {
diff --git a/crates/hypercolor-ui/src/pages/studio/offline_device_card.rs b/crates/hypercolor-ui/src/pages/studio/offline_device_card.rs index 02f62d5dc..7fc5cf634 100644 --- a/crates/hypercolor-ui/src/pages/studio/offline_device_card.rs +++ b/crates/hypercolor-ui/src/pages/studio/offline_device_card.rs @@ -77,6 +77,8 @@ pub(super) fn OfflineDeviceCard(row: ZoneDeviceRow, select: String, placed: bool {placed.then(|| view! { diff --git a/crates/hypercolor-ui/src/pages/studio/stage.rs b/crates/hypercolor-ui/src/pages/studio/stage.rs index 3bf4387f3..8242f34cd 100644 --- a/crates/hypercolor-ui/src/pages/studio/stage.rs +++ b/crates/hypercolor-ui/src/pages/studio/stage.rs @@ -330,7 +330,9 @@ fn SurfaceStage() -> impl IntoView { // a flex-1 child with no h-full, so a plain block // here collapses the canvas to zero height.
- +
+ +
} @@ -350,6 +352,7 @@ fn SurfaceStage() -> impl IntoView { fn ZoneCanvasBar() -> impl IntoView { let editor = expect_context::(); let actions = expect_context::(); + let history = expect_context::(); let write = editor.set_layout; let can_undo = editor.can_undo; let can_redo = editor.can_redo; @@ -382,42 +385,29 @@ fn ZoneCanvasBar() -> impl IntoView {
- // Revert / Save — Save doubles as the dirty indicator. - {move || { - let dirty = is_dirty.get(); - let save_style = if dirty { - "background: rgba(80, 250, 123, 0.14); border-color: rgba(80, 250, 123, 0.35); color: rgb(80, 250, 123); box-shadow: 0 0 12px rgba(80, 250, 123, 0.16)" - } else { - "background: var(--color-surface-overlay); border-color: var(--color-border-subtle); color: var(--color-text-tertiary); opacity: 0.4; pointer-events: none" - }; - let revert_style = if dirty { - "background: rgba(241, 250, 140, 0.08); border-color: rgba(241, 250, 140, 0.25); color: rgb(241, 250, 140)" - } else { - "background: var(--color-surface-overlay); border-color: var(--color-border-subtle); color: var(--color-text-tertiary); opacity: 0.4; pointer-events: none" - }; - view! { - - - } - }} + + {move || if history.busy.get() { "Saving…" } else if is_dirty.get() { "Unsaved layout" } else { "Saved" }} + + +
} diff --git a/crates/hypercolor-ui/src/pages/studio/zone_add_device.rs b/crates/hypercolor-ui/src/pages/studio/zone_add_device.rs index ac21aaec1..6f5aa3e1f 100644 --- a/crates/hypercolor-ui/src/pages/studio/zone_add_device.rs +++ b/crates/hypercolor-ui/src/pages/studio/zone_add_device.rs @@ -1,26 +1,18 @@ //! The "+ Add device" affordance on each Studio zone. //! -//! Picking a device brings every output it has into this zone. A device -//! placed in another zone is moved (its `Output`s reassigned); a -//! device the scene has not placed at all is minted (a fresh -//! `Output` per channel). Minting prefers a seeded hardware footprint -//! when one exists, and asks the daemon to keep it; otherwise the daemon -//! grid-places the outputs. +//! Picking a device brings its canonical light outputs into this zone. +//! The daemon resolves current bindings and hardware topology, and returns +//! the complete reversible assignment for Studio history. use leptos::prelude::*; -use leptos::task::spawn_local; use leptos_icons::Icon; use hypercolor_types::scene::ZoneRole; use crate::api; -use crate::api::zones::{OutputAssignment, ZoneOutcome}; use crate::app::DevicesContext; use crate::components::silk_select::SilkSelect; use crate::icons::*; -use crate::layout_geometry; -use crate::layout_utils; -use crate::style_utils::uuid_v4_hex; use crate::toasts; use super::StudioContext; @@ -110,6 +102,7 @@ pub fn ZoneAddDevice(zone_id: String) -> impl IntoView { impl IntoView { type="button" class="chip-interactive flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-edge-subtle/55 px-3 py-1.5 text-[11px] font-medium text-fg-tertiary hover:border-accent-muted hover:text-fg-secondary" on:click=move |_| picking.set(true) + disabled=move || studio.history.busy.get() + class=("opacity-40", move || studio.history.busy.get()) > "Add device" @@ -143,168 +138,53 @@ pub fn ZoneAddDevice(zone_id: String) -> impl IntoView { } } -/// Bring a device's outputs into `zone_id`. Existing outputs in another -/// zone are moved and the daemon grid-places them; a device the scene has -/// not placed is minted fresh, keeping its seeded footprint when it has -/// one. Shared by the per-zone picker and the single-zone "available -/// device" add button on the device card. +/// Assign the device through the daemon's canonical hardware-output factory. +/// The response records one reversible operation for the complete device. pub(super) fn assign_device_to_zone( studio: StudioContext, device: api::DeviceSummary, zone_id: String, ) { - let Some(scene) = studio.active_scene.get_untracked() else { - toasts::toast_error("No active scene is available"); - return; - }; - let mut assignments: Vec = Vec::new(); - for zone in &scene.zones { - if zone.id.to_string() == zone_id { - continue; - } - for member in &zone.members { - if member.device_id == device.layout_device_id { - assignments.push(OutputAssignment::Existing { - id: member.id.to_string(), - }); - } - } - } - let mut preserve_placement = false; - if assignments.is_empty() { - // A seeded footprint is fitted to a canvas aspect ratio, so it has to - // be built against the daemon's canvas, the one it will live on. - let canvas = studio.render_canvas_size.get_untracked(); - let minted = mint_device_zones(&device, canvas); - assignments = minted.assignments; - preserve_placement = minted.preserve_placement; - } - if assignments.is_empty() { - // No existing outputs and no channels to mint from; nothing the - // daemon can place. - toasts::toast_error("Device has no channels to add"); - return; - } - let revision = scene.revision; - let device_name = device.name.clone(); - spawn_local(async move { - match api::zones::assign_devices(&zone_id, assignments, preserve_placement, revision).await - { - Ok(ZoneOutcome::Applied(_)) => { - toasts::toast_success(&format!("{device_name} added to the zone")); - studio.refresh_scene.run(()); - } - Ok(ZoneOutcome::Stale { .. }) => { - toasts::toast_error("Scene changed elsewhere — reloaded, try again"); - studio.refresh_scene.run(()); - } - Err(error) => toasts::toast_error(&format!("Add failed: {error}")), - } - }); -} - -/// Outputs minted for a device the scene has not placed, plus whether -/// their geometry is deliberate enough that the daemon should keep it. -struct MintedOutputs { - assignments: Vec, - preserve_placement: bool, -} - -/// Build a fresh `Output` per channel for a device that no scene -/// has placed: one zone per declared `SegmentSummary`, or a single zone -/// for a device with no channels. -/// -/// A device with a seeded footprint (the Push 2's pads, display, and -/// touch strip sit at fixed offsets on the real hardware) mints that -/// arrangement and asks the daemon to preserve it. Everything else mints -/// topology and shape only, and the daemon grid-places it. -fn mint_device_zones( - device: &api::DeviceSummary, - (canvas_width, canvas_height): (u32, u32), -) -> MintedOutputs { - let layout_id = device.layout_device_id.as_str(); - let physical_id = device.id.as_str(); - let name = device.name.as_str(); - let total_leds = device.total_leds as usize; + let (width, height) = studio.render_canvas_size.get_untracked(); let light_segments = device .segments .iter() - .filter(|segment| is_light_segment(segment)) + .filter(|segment| { + segment.led_count > 0 + && !matches!( + segment.topology_hint, + Some(api::SegmentTopologySummary::Display { .. }) + ) + }) .cloned() .collect::>(); - - if let Some(seed) = layout_geometry::seeded_device_layout( - layout_id, - name, + // Seeded footprints describe editor geometry only. The daemon keeps + // ownership of output identity, hardware topology, and binding metadata. + let placements = crate::layout_geometry::seeded_device_layout( + &device.layout_device_id, + &device.name, &light_segments, - canvas_width, - canvas_height, + width, + height, 0, - ) { - return MintedOutputs { - assignments: seed - .zones - .into_iter() - .map(|mut output| { - // seeded_device_layout derives ids by folding every - // non-alphanumeric character to '_', so device ids that - // differ only in punctuation collapse together — two Push - // 2s on usb paths `...-0-12` and `...-0-1-2` land on the - // same id. assign_output_to_zone treats an output id as a - // scene-global ownership key, so the second device would - // silently take over the first one's outputs. - output.id = format!("zone_{}", uuid_v4_hex()); - OutputAssignment::New(Box::new(output)) - }) - .collect(), - preserve_placement: true, - }; - } - - if device.segments.is_empty() { - return MintedOutputs { - assignments: vec![OutputAssignment::New(Box::new( - layout_utils::create_default_zone( - layout_id, - physical_id, - name, - None, - total_leds, - canvas_width, - canvas_height, - 0, - ), - ))], - preserve_placement: false, - }; - } - MintedOutputs { - assignments: light_segments - .iter() - .enumerate() - .map(|(order, channel)| { - OutputAssignment::New(Box::new(layout_utils::create_default_zone( - layout_id, - physical_id, - name, - Some(channel), - total_leds, - canvas_width, - canvas_height, - i32::try_from(order).unwrap_or(i32::MAX), - ))) + ) + .map(|seed| { + seed.zones + .into_iter() + .map(|output| hypercolor_types::api::scene::MemberPlacementHint { + segment: output.zone_name, + position: output.position, + size: output.size, + rotation: output.rotation, + scale: output.scale, + orientation: output.orientation, }) - .collect(), - preserve_placement: false, - } -} - -fn is_light_segment(segment: &api::SegmentSummary) -> bool { - segment.led_count > 0 - && !matches!( - segment.topology_hint, - Some(api::SegmentTopologySummary::Display { .. }) - ) + .collect() + }) + .unwrap_or_default(); + studio + .history + .assign_device(zone_id, device.layout_device_id, Vec::new(), placements); } /// The non-target zone that currently owns a device's outputs, or @@ -342,51 +222,3 @@ pub(super) fn zone_display_name(zone: &api::ZoneResource) -> String { zone.name.clone() } } - -#[cfg(test)] -mod tests { - use super::*; - - fn push_segment( - name: &str, - led_count: u32, - topology_hint: api::SegmentTopologySummary, - ) -> api::SegmentSummary { - api::SegmentSummary { - id: name.to_ascii_lowercase().replace(' ', "_"), - name: name.to_owned(), - led_count, - topology: "fixture".to_owned(), - topology_hint: Some(topology_hint), - } - } - - #[test] - fn light_segment_filter_excludes_the_push_display_surface() { - let segments = [ - push_segment("Buttons Above", 40, api::SegmentTopologySummary::Strip), - push_segment("Buttons Below", 40, api::SegmentTopologySummary::Strip), - push_segment( - "Pads", - 64, - api::SegmentTopologySummary::Matrix { rows: 8, cols: 8 }, - ), - push_segment( - "Display", - 0, - api::SegmentTopologySummary::Display { - width: 960, - height: 160, - circular: false, - }, - ), - ]; - let names = segments - .iter() - .filter(|segment| is_light_segment(segment)) - .map(|segment| segment.name.as_str()) - .collect::>(); - - assert_eq!(names, ["Buttons Above", "Buttons Below", "Pads"]); - } -} diff --git a/crates/hypercolor-ui/tests/studio_history_tests.rs b/crates/hypercolor-ui/tests/studio_history_tests.rs new file mode 100644 index 000000000..48d440768 --- /dev/null +++ b/crates/hypercolor-ui/tests/studio_history_tests.rs @@ -0,0 +1,102 @@ +use hypercolor_types::api::scene::{MemberEdit, MemberState, SceneDocument}; +use hypercolor_ui::compound_selection::CompoundDepth; +use hypercolor_ui::layout_history::LayoutEditorSnapshot; +use hypercolor_ui::pages::studio::history::{StudioEdit, StudioJournal, prepare_members_replay}; +use std::collections::{HashMap, HashSet}; + +fn snapshot() -> LayoutEditorSnapshot { + LayoutEditorSnapshot { + zones: Vec::new(), + selected_zone_ids: HashSet::new(), + compound_depth: CompoundDepth::Root, + removed_zone_cache: HashMap::new(), + } +} + +fn scene() -> SceneDocument { + serde_json::from_value(serde_json::json!({ + "id":"33333333-3333-4333-8333-333333333333", "name":"Desk", "kind":"named", "is_default":false,"revision":8, + "zones":[{"id":"11111111-1111-4111-8111-111111111111","name":"Case","role":"primary","enabled":true,"brightness":1, + "members":[{"id":"stand","device_id":"usb:stand","segment":"Main","name":"Stand"}], + "layout":{"placements":[{"member":"stand","position":{"x":0.7,"y":0.5},"size":{"x":0.1,"y":0.1},"topology":{"type":"strip","count":15,"direction":"left_to_right"}}]},"layers":[] }] + })).expect("valid scene fixture") +} + +#[test] +fn journal_interleaves_membership_and_geometry_and_discards_only_redo_branch() { + let mut journal = StudioJournal::default(); + assert!(journal.undo_edit().is_none()); + assert!(journal.redo_edit().is_none()); + let layout = StudioEdit::Layout { + zone_id: "zone".into(), + before: Box::new(snapshot()), + after: Box::new(snapshot()), + }; + let members = StudioEdit::Members(Vec::new()); + journal.record(layout.clone()); + journal.record(members.clone()); + assert_eq!(journal.undo_edit(), Some(&members)); + journal.complete(false); + assert_eq!(journal.undo_edit(), Some(&layout)); + assert_eq!(journal.redo_edit(), Some(&members)); + journal.complete(false); + assert!(journal.undo_edit().is_none()); + journal.complete(true); + assert_eq!(journal.redo_edit(), Some(&members)); + journal.record(layout.clone()); + assert!(journal.redo_edit().is_none()); + assert_eq!(journal.undo_edit(), Some(&layout)); +} + +#[test] +fn membership_replay_preserves_newer_geometry_but_rejects_changed_binding() { + let scene = scene(); + let zone = &scene.zones[0]; + let mut output = hypercolor_ui::api::zone_outputs(zone).remove(0); + output.position.x = 0.2; + let before = MemberState { + zone_id: zone.id, + output: output.clone(), + index: 0, + }; + let after = MemberState { + zone_id: zone.id, + output, + index: 1, + }; + let change = MemberEdit { + before: Some(before), + after: Some(after), + }; + let replay = prepare_members_replay(&scene, vec![change.clone()]).expect("same binding"); + assert_eq!( + replay[0].before.as_ref().expect("before").output.position.x, + 0.7 + ); + assert_eq!( + replay[0].after.as_ref().expect("after").output.position.x, + 0.7 + ); + let mut replaced = scene.clone(); + replaced.zones[0].members[0].device_id = "different".into(); + assert!(prepare_members_replay(&replaced, vec![change]).is_err()); +} + +#[test] +fn removed_output_restoration_keeps_recorded_snapshot_without_live_hardware() { + let scene = scene(); + let zone = &scene.zones[0]; + let output = hypercolor_ui::api::zone_outputs(zone).remove(0); + let change = MemberEdit { + before: None, + after: Some(MemberState { + zone_id: zone.id, + output, + index: 0, + }), + }; + assert_eq!( + prepare_members_replay(&scene, vec![change.clone()]).expect("server validates restore"), + vec![change] + ); +} diff --git a/docs/content/api/rest.md b/docs/content/api/rest.md index df921682f..84f1fa7b1 100644 --- a/docs/content/api/rest.md +++ b/docs/content/api/rest.md @@ -146,6 +146,10 @@ returns it in the document and in the `ETag` header. Structural mutations may send that value as `If-Match`; a stale value returns `412 Precondition Failed` with the current revision instead of overwriting a concurrent edit. +The atomic membership route, `POST /scene/members/edit`, requires a numeric +`If-Match` revision. A quoted or bare integer is accepted; a missing header +or `*` is rejected. + Control-value patches never use `If-Match`. They address a real layer id read from the live document. Replacing a layer mints a fresh id, so a stale control write returns `404 layer_not_found` rather than landing on the replacement. @@ -810,6 +814,28 @@ ids, which are the resource identities for later removal. Remove one membership by the member id returned in the live zone document. {% %} +{% %} +Add, move, or remove outputs in one atomic transaction. Supply the active +`scene_id` and a numeric `If-Match` revision, then choose one request form: + +- **Explicit changes:** a `changes` array containing `before` and `after` + states. Each state contains `zone_id`, the complete `output`, and its ordered + `index`. A null `before` adds an output; a null `after` removes it. +- **Device assignment:** an `assignment` object containing the target + `zone_id`, `device_id`, optional `segments`, and optional `placements`. + Leave `changes` empty. The daemon creates canonical outputs for the device; + placement hints preserve prepared geometry without choosing device bindings. + +All changes validate before committing. A failed request leaves membership +unchanged. The response contains the committed `document` and canonical +`changes` receipts with complete output snapshots for undo and redo. The +committed revision is available in `document.revision` and the response `ETag`. + +Omitting `If-Match` or sending `*` is rejected. A stale revision returns `412`. +Explicit edits also validate their `before` states, so rebasing requires fresh +member states from the current document. +{% %} + {% %} Replace the zone-scoped spatial placement override. The compact body contains `placements`, keyed by member id. Add or remove members through the member diff --git a/docs/content/studio/device-assignment.md b/docs/content/studio/device-assignment.md index 75ca967c4..5ec878ba1 100644 --- a/docs/content/studio/device-assignment.md +++ b/docs/content/studio/device-assignment.md @@ -44,8 +44,8 @@ Each zone in the tree ends with an **Add device** button. Click it to open a pic Picking a device brings every output it has into this zone: -- A device already placed in **another zone** is **moved**: its existing outputs are reassigned to the target zone. -- A device the scene has **not placed at all** is **minted** fresh, one output per channel, or a single output for a device with no channels. The daemon resets position and size on assign, so the device drops onto the zone's canvas with sensible defaults. +- A device already placed in **another zone** is **moved**: its existing outputs and placement are retained in the target zone. +- A device the scene has **not placed at all** is **minted** fresh, one output per channel, or a single output for a device with no channels. Known device shapes keep their prepared arrangement; other outputs receive an initial grid placement. In a **single-zone scene**, devices that sit in no zone fold under the sole LED zone as one-tap **Available** rows. Each Available card carries a green **add** (`+`) action that drops the device straight into the zone, no picker needed. @@ -76,7 +76,19 @@ picker of every LED zone, because "add" has to say where. **Identify** flashes the device's LEDs through the daemon so you can match the on-screen card to a physical light. On a placed card it lives in the kebab menu; on an Available or Unassigned card it is a lightning button in the cluster itself. A brief toast confirms the flash. -**Remove from zone** pulls every one of the device's outputs out of this zone. For a multi-output controller the removals run in sequence as a single user action, so the whole device leaves the zone in one click. The device then becomes Unassigned (or Available, in a single-zone scene) and can be added to a different zone. +**Remove from zone** pulls every one of the device's outputs out of this zone. All outputs are removed in one atomic operation, so a failed request leaves the whole device in its original zone. The device then becomes Unassigned (or Available, in a single-zone scene) and can be added to a different zone. + +### Saving and undoing assignments + +Adding, moving, and removing devices save automatically. The Stage status shows +`Saving…` during the write and `Saved` after it succeeds. If you also have +unsaved placement edits, the status remains `Unsaved layout` until you save +those edits. + +Assignments and canvas edits share one chronological **Undo / Redo** history. +Undoing or redoing an assignment also saves that assignment change. Moving or +resizing an output on the canvas still requires **Save**. History belongs to +the current Studio session and resets when you switch scenes or leave Studio. ### Offline devices diff --git a/docs/content/studio/layouts.md b/docs/content/studio/layouts.md index 3a4250f64..5129136a6 100644 --- a/docs/content/studio/layouts.md +++ b/docs/content/studio/layouts.md @@ -83,14 +83,18 @@ When more than one output is selected, the properties panel switches to group co ## Undo, save, and revert -The Stage header carries the canvas controls. - -- **Undo / Redo** with the toolbar buttons or `Ctrl+Z` / `Ctrl+Shift+Z` (and `Ctrl+Y` for redo). The shortcuts are suppressed while you are typing in a text field. -- **Save** writes the layout to the zone. The Save button doubles as the dirty indicator: it glows green when you have unsaved changes and dims when the layout is clean. -- **Revert** discards every change since the last save and restores the canvas to its saved state. - -{% %} -Dragging an output pushes a live preview to the daemon so you can see the result on your hardware immediately, but that preview is temporary. The placement is not persisted to the zone until you hit Save. If you switch zones or close Studio with the Save button still glowing, your arrangement is lost. Revert is the safety net while you experiment; Save is the commit. +The Stage header shows `Saved`, `Unsaved layout`, or `Saving…` alongside +the editing controls. + +- **Undo / Redo** follows one chronological history of assignments and canvas edits. Use the toolbar or `Ctrl+Z` / `Ctrl+Shift+Z` (also `Ctrl+Y` for redo). Shortcuts are suppressed while you type in a text field. Assignment undo and redo save automatically; placement undo and redo update the local layout. +- **Save** persists the selected zone's placement edits. Assigning, moving, or removing devices saves automatically and does not require this button. +- **Revert** restores the selected zone's saved placement. It does not undo device assignments. + +{% %} +Canvas edits preview immediately on the hardware, but only **Save** persists +them. Switching zones preserves each zone's draft while Studio remains open. +Leaving Studio, reloading the page, or switching scenes discards unsaved drafts +and resets the undo history. Save each edited zone before leaving. {% %} If someone changes the same scene from another client or the CLI while you are editing, a save can come back stale. Studio reloads the scene and asks you to try again rather than clobbering the other change. Your in-flight edits to placement survive an unrelated refetch, so a device assigned elsewhere does not wipe the box you are dragging. diff --git a/docs/content/studio/zone-api-and-concurrency.md b/docs/content/studio/zone-api-and-concurrency.md index 049f9c4af..80d5b9619 100644 --- a/docs/content/studio/zone-api-and-concurrency.md +++ b/docs/content/studio/zone-api-and-concurrency.md @@ -10,7 +10,8 @@ Fine-grained writes stay under that same root. Stored scenes remain a collection under `/api/v1/scenes`, but they do not expose a second nested zone API. The live document also carries the only concurrency token on the REST wire: -`revision`. Structural writes may guard themselves with `If-Match`. Control +`revision`. Structural writes generally accept optional `If-Match`; atomic +membership edits require a numeric revision. Control values are unguarded and target a real layer id from the document. A replaced layer has a new id, so a stale control write cannot reach the replacement. @@ -101,13 +102,32 @@ The response carries minted member ids. Remove one membership by its member id. Segment names are not resource ids. {% %} +{% %} +Apply an atomic set of membership changes, including moves between zones. +The request contains the active `scene_id` and a `changes` array. Each change +has `before` and `after` states: a state identifies its `zone_id`, complete +`output`, and ordered `index`. Use a null `before` to add an output or a null +`after` to remove one. + +To assign a device through the daemon's canonical output factory, send +`assignment` with the target `zone_id`, `device_id`, optional `segments`, and +optional `placements`, leaving `changes` empty. Studio uses this form when +adding a device, preserving its prepared geometry when available. + +A numeric `If-Match` is required. Omitting it or sending `*` is rejected. +All changes validate before any commit, so a failed transaction makes no +partial assignment changes. The response contains the committed `document` +and canonical `changes` receipts, with complete output snapshots for undo +and redo. Studio uses this route for device add, move, and remove actions. +{% %} + {% %} Write a compact zone layout containing member placements. Every placement names a member id from the live zone document. {% %} -All mutating routes in this section are structural. Each may carry the scene -document's current `revision` in `If-Match`. +All mutating routes in this section are structural. The atomic membership-edit +route requires the current revision; the other routes accept it optionally. ### Layers @@ -247,7 +267,7 @@ No resource-specific version counters exist on the REST wire. Internal bookkeeping may use more detail, but clients coordinate through the one document revision. -### Structural writes use optional `If-Match` +### Structural preconditions Send the last revision when overwriting a stale structure would be harmful: @@ -259,9 +279,11 @@ Content-Type: application/json { "name": "Desk halo", "color": "#7c5cff" } ``` -The daemon accepts a quoted integer, a bare integer, or `*`. Omitting the -header, or sending `*`, applies without a precondition. A stale integer returns -the canonical `412 Precondition Failed` envelope: +Most structural routes accept a quoted integer, a bare integer, or `*`. +Omitting the header, or sending `*`, applies without a precondition on those +routes. The exception is `POST /scene/members/edit`: it requires a quoted or +bare numeric revision and rejects both missing headers and `*`. A stale +integer returns the canonical `412 Precondition Failed` envelope: ```json { @@ -282,7 +304,8 @@ Structural writes include scene metadata, zone create, patch, and delete, zone layout replacement, member assignment and removal, layer create, replace, delete, and reorder, scene clear, stored-scene replacement, and both effect apply forms. After a `412`, read `/scene`, rebase the intended edit, and retry -with the new revision. +with the new revision. Membership edits also validate their `before` states; +rebuild those states from the current document when rebasing. ### Control values never use `If-Match` diff --git a/docs/specs/78-api-resource-model.md b/docs/specs/78-api-resource-model.md index 5e4aa41ce..9d0505167 100644 --- a/docs/specs/78-api-resource-model.md +++ b/docs/specs/78-api-resource-model.md @@ -308,7 +308,7 @@ Waves are atomic PRs from lane worktrees, every in-repo consumer updated in-PR ( --- -## Appendix A: Normative route inventory (87 paths, 123 operations) +## Appendix A: Normative route inventory (88 paths, 124 operations) Scope: the `/api/v1` surface (JSON routes plus the one `/ws` upgrade endpoint, which the convergence test matches by path without asserting a JSON shape) and `/health`. Document routes are deliberately outside the inventory and the convergence test: `/` (SPA), `/api/v1/docs`, `/api/v1/openapi.json`, and the `/mcp` mount are served pages and protocol endpoints, not API resources (`/preview` was on this list until wave 3.2c deleted the page). Config rows landed via Spec 76 wave 4.3; logical-devices rows are intentionally absent pending the §8 downstream check (re-add via spec amendment if the check fails). `⚡` marks routes whose handler is new or substantially rewritten by this spec. @@ -371,6 +371,7 @@ Scope: the `/api/v1` surface (JSON routes plus the one `/ws` upgrade endpoint, w | `/api/v1/scene/zones/{zone}` | GET, PATCH, DELETE | ⚡ | | `/api/v1/scene/zones/{zone}/layout` | PUT | ⚡ | | `/api/v1/scene/zones/{zone}/members` | POST | ⚡ | +| `/api/v1/scene/members/edit` | POST | Atomic reversible assignments; numeric `If-Match` required | | `/api/v1/scene/zones/{zone}/members/{member}` | DELETE | ⚡ | | `/api/v1/scene/zones/{zone}/layers` | GET, POST | ⚡ | | `/api/v1/scene/zones/{zone}/layers/order` | PATCH | ⚡ | diff --git a/e2e/tests/control-fixture.mjs b/e2e/tests/control-fixture.mjs new file mode 100644 index 000000000..0fb0e738f --- /dev/null +++ b/e2e/tests/control-fixture.mjs @@ -0,0 +1,133 @@ +import { expect } from "@playwright/test"; +import { getStack } from "./helpers.mjs"; + +export const ZONE = "22222222-2222-4222-8222-222222222222"; +export const EFFECT = "44444444-4444-4444-8444-444444444444"; +export const LAYER = "55555555-5555-4555-8555-555555555555"; +export const replacementId = (revision) => `55555555-5555-4555-8555-${String(revision).padStart(12, "0")}`; +export const float = (value) => ({ kind: "float", value }); + +export async function openControls(page, { rejectControls = false, surface = "studio", beforeControlReply } = {}) { + const zone = (id, name, role, layers = []) => ({ + id, name, role, enabled: true, brightness: 1, + members: [], layout: { placements: [] }, layers, + }); + const scene = { + id: "33333333-3333-4333-8333-333333333333", + name: "Layer controls fixture", kind: "named", is_default: false, revision: 42, + zones: [ + zone("11111111-1111-4111-8111-111111111111", "Primary lights", "primary", [{ + id: "77777777-7777-4777-8777-777777777777", name: "Primary effect layer", + source: { type: "effect", effect_id: "88888888-8888-4888-8888-888888888888", controls: {} }, + }]), + zone(ZONE, "Control lights", "custom", [{ + id: LAYER, name: "Fixture layer", opacity: 1, blend: "replace", + source: { type: "effect", effect_id: EFFECT, controls: { + upper: float(0.2), lower: float(0.4), + } }, + }]), + ], + }; + let socket; + const reads = []; + const writes = []; + const unexpectedWrites = []; + const envelope = (data) => ({ data, meta: { + api_version: "v1", request_id: "req_controls_fixture", timestamp: "2026-09-09T00:00:00Z", + } }); + const sendEvent = (event, data) => socket.send(JSON.stringify({ type: "event", event, data })); + const controlEvent = (controlId, value, oldValue = 0.4) => sendEvent("effect_control_changed", { + effect_id: scene.zones[1].layers[0].source.effect_id, + zone_id: ZONE, layer_id: scene.zones[1].layers[0].id, + control_id: controlId, old_value: float(oldValue), new_value: float(value), trigger: "api", + }); + await page.setViewportSize({ width: 1440, height: 800 }); + await page.routeWebSocket(/\/api\/v1\/ws(?:\?|$)/, (route) => { socket = route; }); + await page.route("**/api/v1/**", async (route) => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + if (["GET", "HEAD"].includes(request.method())) { + reads.push(pathname); + if (pathname === "/api/v1/scene") { + await route.fulfill({ json: envelope(scene) }); + return; + } + if (pathname.startsWith("/api/v1/effects/") && !pathname.endsWith("/active")) { + const id = pathname.split("/").at(-1); + await route.fulfill({ json: envelope({ + id, name: "Fixture effect", description: "Scrollable effect controls", author: "Hypercolor tests", + category: "ambient", source: "native", runnable: true, tags: [], version: "1.0.0", + audio_reactive: false, + controls: ["upper", ...Array.from({ length: 18 }, (_, i) => `middle${i}`), "lower"] + .map((id) => ({ id, name: `Fixture ${id}`, control_type: "slider", + default_value: float(0.2), min: 0, max: 1, step: 0.01 })), + }) }); + return; + } + await route.continue(); + return; + } + const layerPath = `/api/v1/scene/zones/${ZONE}/layers/${scene.zones[1].layers[0].id}`; + const body = request.postDataJSON(); + writes.push({ method: request.method(), pathname, body, revision: request.headers()["if-match"] }); + if (request.method() === "PATCH" && pathname === `${layerPath}/controls`) { + if (rejectControls) { + await route.fulfill({ status: 400, json: { error: { code: "invalid_control", message: "Fixture rejects this value" } } }); + return; + } + if (beforeControlReply) await beforeControlReply({ scene, body, sendEvent }); + Object.assign(scene.zones[1].layers[0].source.controls, body.values); + scene.revision += 1; + await route.fulfill({ json: envelope(scene.zones[1]) }); + sendEvent("zone_changed", { scene_id: scene.id, zone_id: ZONE, role: "custom", kind: "controls_patched" }); + for (const [id, value] of Object.entries(body.values)) controlEvent(id, value.value); + return; + } + if (request.method() === "PUT" && pathname === layerPath) { + if (request.headers()["if-match"] !== String(scene.revision)) { + await route.fulfill({ status: 412, json: { error: { code: "stale_revision", message: "Stale revision" } } }); + return; + } + scene.revision += 1; + scene.zones[1].layers[0] = { ...body, id: replacementId(scene.revision) }; + await route.fulfill({ json: envelope(scene.zones[1]) }); + sendEvent("zone_changed", { scene_id: scene.id, zone_id: ZONE, role: "custom", kind: "updated" }); + return; + } + unexpectedWrites.push(`${request.method()} ${pathname}`); + await route.abort("blockedbyclient"); + }); + await page.goto(getStack().appOrigin + "/" + surface, { waitUntil: "networkidle" }); + await expect.poll(() => Boolean(socket)).toBe(true); + let inspector; + if (surface === "studio") { + await page.getByTitle("Open the composition panel", { exact: true }).click(); + await page.getByRole("button", { name: "Control lights 0 devices", exact: true }).click(); + inspector = page.getByRole("complementary"); + } else { + await page.getByRole("tab", { name: "Control lights", exact: true }).click(); + inspector = page.locator("body"); + } + const control = (name) => inspector.locator(`div:has(> label:text-is("Fixture ${name}")) > input[type="range"]`); + await expect(control("lower")).toHaveValue("0.4"); + await page.waitForLoadState("networkidle"); + return { scene, inspector, control, reads, writes, unexpectedWrites, controlEvent, sendEvent }; +} + +export async function settle(page) { + await page.waitForLoadState("networkidle"); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); +} + +export async function scrollState(input) { + return input.evaluate((element) => { + const scrollers = []; + for (let parent = element.parentElement; parent; parent = parent.parentElement) { + if (parent.scrollHeight > parent.clientHeight && /auto|scroll/.test(getComputedStyle(parent).overflowY)) { + scrollers.push(parent.scrollTop); + } + } + return scrollers; + }); +} + diff --git a/e2e/tests/effects-controls.spec.mjs b/e2e/tests/effects-controls.spec.mjs new file mode 100644 index 000000000..656de307c --- /dev/null +++ b/e2e/tests/effects-controls.spec.mjs @@ -0,0 +1,106 @@ +import { test, expect } from "@playwright/test"; +import { openControls, settle, scrollState, ZONE, replacementId, float } from "./control-fixture.mjs"; + +test("Effects zone control edits and remote snapshots preserve the scrolled, focused widget", async ({ page }) => { + const fixture = await openControls(page, { surface: "effects" }); + const lower = fixture.control("lower"); + await lower.scrollIntoViewIfNeeded(); + await lower.focus(); + const original = await lower.elementHandle(); + const beforeScroll = await scrollState(lower); + expect(beforeScroll.some((offset) => offset > 100)).toBe(true); + const beforeReads = fixture.reads.length; + await lower.press("ArrowRight"); + await expect.poll(() => fixture.writes.length).toBe(1); + await settle(page); + expect(fixture.writes[0].body.values.lower.value).toBeCloseTo(0.41, 6); + expect(await original.evaluate((element) => element.isConnected && document.activeElement === element)).toBe(true); + expect(await scrollState(lower)).toEqual(beforeScroll); + const editReads = fixture.reads.slice(beforeReads); + expect(editReads.filter((path) => path.startsWith("/api/v1/effects/"))).toEqual([]); + expect(editReads.filter((path) => path === "/api/v1/scene").length).toBeLessThanOrEqual(1); + + // Another client changes the control while this inspector stays selected. + fixture.scene.zones[1].layers[0].source.controls.lower = float(0.73); + fixture.scene.revision += 1; + fixture.sendEvent("zone_changed", { scene_id: fixture.scene.id, zone_id: ZONE, role: "custom", kind: "controls_patched" }); + fixture.controlEvent("lower", 0.73, 0.41); + await expect(lower).toHaveValue("0.73"); + await settle(page); + expect(await original.evaluate((element) => element.isConnected && document.activeElement === element)).toBe(true); + expect(await scrollState(lower)).toEqual(beforeScroll); + expect(fixture.unexpectedWrites).toEqual([]); +}); + +test("Effects zone source replacement retargets effect controls to the new layer", async ({ page }) => { + const fixture = await openControls(page, { surface: "effects" }); + const oldControl = await fixture.control("lower").elementHandle(); + const nextEffect = "66666666-6666-4666-8666-666666666666"; + fixture.scene.revision += 1; + fixture.scene.zones[1].layers[0] = { + ...fixture.scene.zones[1].layers[0], + id: replacementId(fixture.scene.revision), + source: { type: "effect", effect_id: nextEffect, controls: { lower: float(0.6) } }, + }; + fixture.sendEvent("zone_changed", { + scene_id: fixture.scene.id, zone_id: ZONE, role: "custom", kind: "updated", + }); + await expect(fixture.control("lower")).toHaveValue("0.6"); + expect(await oldControl.evaluate((element) => element.isConnected)).toBe(false); + expect(fixture.reads).toContain(`/api/v1/effects/${nextEffect}`); + await fixture.control("lower").focus(); + await fixture.control("lower").press("ArrowRight"); + await expect.poll(() => fixture.writes.length).toBe(1); + expect(fixture.writes[0].pathname).toBe(`/api/v1/scene/zones/${ZONE}/layers/${replacementId(43)}/controls`); + expect(fixture.writes[0].body.values.lower.value).toBeCloseTo(0.61, 6); + expect(fixture.unexpectedWrites).toEqual([]); +}); + +test("Effects zone rejected control edits restore the canonical value without remounting", async ({ page }) => { + const fixture = await openControls(page, { rejectControls: true, surface: "effects" }); + const lower = fixture.control("lower"); + await lower.scrollIntoViewIfNeeded(); + await lower.focus(); + const original = await lower.elementHandle(); + const beforeScroll = await scrollState(lower); + await lower.press("ArrowRight"); + await expect.poll(() => fixture.writes.length).toBe(1); + await expect(lower).toHaveValue("0.4"); + await settle(page); + expect(fixture.scene.zones[1].layers[0].source.controls.lower).toEqual(float(0.4)); + expect(await original.evaluate((element) => element.isConnected && document.activeElement === element)).toBe(true); + expect(await scrollState(lower)).toEqual(beforeScroll); + expect(fixture.unexpectedWrites).toEqual([]); +}); + + +test("Effects zone snapshots preserve in-flight and queued edits", async ({ page }) => { + let releaseFirst; + let requests = 0; + const firstReply = new Promise((resolve) => { releaseFirst = resolve; }); + const fixture = await openControls(page, { + surface: "effects", + beforeControlReply: async () => { if (++requests === 1) await firstReply; }, + }); + const lower = fixture.control("lower"); + await lower.focus(); + await lower.press("ArrowRight"); + await expect.poll(() => fixture.writes.length).toBe(1); + fixture.scene.zones[1].layers[0].source.controls.lower = float(0.12); + fixture.scene.zones[1].layers[0].source.controls.upper = float(0.82); + fixture.scene.revision += 1; + fixture.sendEvent("zone_changed", { + scene_id: fixture.scene.id, zone_id: ZONE, role: "custom", kind: "controls_patched", + }); + await expect(fixture.control("upper")).toHaveValue("0.82"); + await expect(lower).toHaveValue("0.41"); + await lower.press("ArrowRight"); + await expect(lower).toHaveValue("0.42"); + releaseFirst(); + await expect.poll(() => fixture.writes.length).toBe(2); + await settle(page); + await expect(lower).toHaveValue("0.42"); + expect(fixture.writes[0].body.values.lower.value).toBeCloseTo(0.41, 6); + expect(fixture.writes[1].body.values.lower.value).toBeCloseTo(0.42, 6); + expect(fixture.unexpectedWrites).toEqual([]); +}); diff --git a/e2e/tests/studio-ambient.spec.mjs b/e2e/tests/studio-ambient.spec.mjs new file mode 100644 index 000000000..408701ca9 --- /dev/null +++ b/e2e/tests/studio-ambient.spec.mjs @@ -0,0 +1,34 @@ +import { test, expect } from "@playwright/test"; +import { openControls } from "./control-fixture.mjs"; + +test("Studio ambient hue updates do not animate scrollbars on non-scrolling chrome", async ({ page }) => { + await openControls(page); + const result = await page.evaluate(async () => { + const root = document.documentElement; + const previous = root.style.getPropertyValue("--ambient-hue"); + const targets = [ + document.querySelector("#page-search-input"), + document.querySelector(".resize-handle-line"), + ]; + const frame = () => new Promise(requestAnimationFrame); + root.style.setProperty("--ambient-hue", "0"); + await frame(); + await frame(); + root.style.setProperty("--ambient-hue", "180"); + await frame(); + await frame(); + const found = targets.map((element) => ({ + present: element !== null, + scrollbarTransitions: element?.getAnimations().filter( + (animation) => animation.transitionProperty === "scrollbar-color", + ).length, + })); + if (previous) root.style.setProperty("--ambient-hue", previous); + else root.style.removeProperty("--ambient-hue"); + return found; + }); + expect(result).toEqual([ + { present: true, scrollbarTransitions: 0 }, + { present: true, scrollbarTransitions: 0 }, + ]); +}); diff --git a/e2e/tests/studio-controls.spec.mjs b/e2e/tests/studio-controls.spec.mjs index ea482da0f..a5589bf60 100644 --- a/e2e/tests/studio-controls.spec.mjs +++ b/e2e/tests/studio-controls.spec.mjs @@ -1,129 +1,5 @@ import { test, expect } from "@playwright/test"; - -import { getStack } from "./helpers.mjs"; - -const ZONE = "22222222-2222-4222-8222-222222222222"; -const EFFECT = "44444444-4444-4444-8444-444444444444"; -const LAYER = "55555555-5555-4555-8555-555555555555"; -const replacementId = (revision) => `55555555-5555-4555-8555-${String(revision).padStart(12, "0")}`; -const float = (value) => ({ kind: "float", value }); - -async function openControls(page, { rejectControls = false } = {}) { - const zone = (id, name, role, layers = []) => ({ - id, name, role, enabled: true, brightness: 1, - members: [], layout: { placements: [] }, layers, - }); - const scene = { - id: "33333333-3333-4333-8333-333333333333", - name: "Layer controls fixture", kind: "named", is_default: false, revision: 42, - zones: [ - zone("11111111-1111-4111-8111-111111111111", "Primary lights", "primary", [{ - id: "77777777-7777-4777-8777-777777777777", name: "Primary effect layer", - source: { type: "effect", effect_id: "88888888-8888-4888-8888-888888888888", controls: {} }, - }]), - zone(ZONE, "Control lights", "custom", [{ - id: LAYER, name: "Fixture layer", opacity: 1, blend: "replace", - source: { type: "effect", effect_id: EFFECT, controls: { - upper: float(0.2), lower: float(0.4), - } }, - }]), - ], - }; - let socket; - const reads = []; - const writes = []; - const unexpectedWrites = []; - const envelope = (data) => ({ data, meta: { - api_version: "v1", request_id: "req_controls_fixture", timestamp: "2026-09-09T00:00:00Z", - } }); - const sendEvent = (event, data) => socket.send(JSON.stringify({ type: "event", event, data })); - const controlEvent = (controlId, value, oldValue = 0.4) => sendEvent("effect_control_changed", { - effect_id: scene.zones[1].layers[0].source.effect_id, - zone_id: ZONE, layer_id: scene.zones[1].layers[0].id, - control_id: controlId, old_value: float(oldValue), new_value: float(value), trigger: "api", - }); - await page.setViewportSize({ width: 1440, height: 800 }); - await page.routeWebSocket(/\/api\/v1\/ws(?:\?|$)/, (route) => { socket = route; }); - await page.route("**/api/v1/**", async (route) => { - const request = route.request(); - const pathname = new URL(request.url()).pathname; - if (["GET", "HEAD"].includes(request.method())) { - reads.push(pathname); - if (pathname === "/api/v1/scene") { - await route.fulfill({ json: envelope(scene) }); - return; - } - if (pathname.startsWith("/api/v1/effects/") && !pathname.endsWith("/active")) { - const id = pathname.split("/").at(-1); - await route.fulfill({ json: envelope({ - id, name: "Fixture effect", description: "Scrollable effect controls", author: "Hypercolor tests", - category: "ambient", source: "native", runnable: true, tags: [], version: "1.0.0", - audio_reactive: false, - controls: ["upper", ...Array.from({ length: 18 }, (_, i) => `middle${i}`), "lower"] - .map((id) => ({ id, name: `Fixture ${id}`, control_type: "slider", - default_value: float(0.2), min: 0, max: 1, step: 0.01 })), - }) }); - return; - } - await route.continue(); - return; - } - const layerPath = `/api/v1/scene/zones/${ZONE}/layers/${scene.zones[1].layers[0].id}`; - const body = request.postDataJSON(); - writes.push({ method: request.method(), pathname, body, revision: request.headers()["if-match"] }); - if (request.method() === "PATCH" && pathname === `${layerPath}/controls`) { - if (rejectControls) { - await route.fulfill({ status: 400, json: { error: { code: "invalid_control", message: "Fixture rejects this value" } } }); - return; - } - Object.assign(scene.zones[1].layers[0].source.controls, body.values); - scene.revision += 1; - await route.fulfill({ json: envelope(scene.zones[1]) }); - sendEvent("zone_changed", { scene_id: scene.id, zone_id: ZONE, role: "custom", kind: "controls_patched" }); - for (const [id, value] of Object.entries(body.values)) controlEvent(id, value.value); - return; - } - if (request.method() === "PUT" && pathname === layerPath) { - if (request.headers()["if-match"] !== String(scene.revision)) { - await route.fulfill({ status: 412, json: { error: { code: "stale_revision", message: "Stale revision" } } }); - return; - } - scene.revision += 1; - scene.zones[1].layers[0] = { ...body, id: replacementId(scene.revision) }; - await route.fulfill({ json: envelope(scene.zones[1]) }); - sendEvent("zone_changed", { scene_id: scene.id, zone_id: ZONE, role: "custom", kind: "updated" }); - return; - } - unexpectedWrites.push(`${request.method()} ${pathname}`); - await route.abort("blockedbyclient"); - }); - await page.goto(`${getStack().appOrigin}/studio`, { waitUntil: "networkidle" }); - await expect.poll(() => Boolean(socket)).toBe(true); - await page.getByTitle("Open the composition panel", { exact: true }).click(); - await page.getByRole("button", { name: "Control lights 0 devices", exact: true }).click(); - const inspector = page.getByRole("complementary"); - const control = (name) => inspector.locator(`div:has(> label:text-is("Fixture ${name}")) > input[type="range"]`); - await expect(control("lower")).toHaveValue("0.4"); - await page.waitForLoadState("networkidle"); - return { scene, inspector, control, reads, writes, unexpectedWrites, controlEvent, sendEvent }; -} - -async function settle(page) { - await page.waitForLoadState("networkidle"); - await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); -} - -async function scrollState(input) { - return input.evaluate((element) => { - const scrollers = []; - for (let parent = element.parentElement; parent; parent = parent.parentElement) { - if (parent.scrollHeight > parent.clientHeight && /auto|scroll/.test(getComputedStyle(parent).overflowY)) { - scrollers.push(parent.scrollTop); - } - } - return scrollers; - }); -} +import { openControls, settle, scrollState, ZONE, EFFECT, replacementId, float } from "./control-fixture.mjs"; test("Studio control edits and remote snapshots preserve the scrolled, focused widget", async ({ page }) => { const fixture = await openControls(page); diff --git a/e2e/tests/studio-history-fixture.mjs b/e2e/tests/studio-history-fixture.mjs new file mode 100644 index 000000000..c0a283a9b --- /dev/null +++ b/e2e/tests/studio-history-fixture.mjs @@ -0,0 +1,171 @@ +import { expect } from "@playwright/test"; +import { getStack } from "./helpers.mjs"; + +export const FIRST = "11111111-1111-4111-8111-111111111111"; +export const SECOND = "22222222-2222-4222-8222-222222222222"; +export const STAND = "fixture:stand"; +export const ANCHOR = "fixture:anchor"; +const output = (id, device, name) => ({ + id, device_id: device, name, zone_name: null, + position: { x: 0.35, y: 0.4 }, size: { x: 0.18, y: 0.1 }, + rotation: 0, scale: 1, display_order: 0, + topology: { type: "strip", count: 12, direction: "left_to_right" }, + orientation: null, sampling_mode: null, edge_behavior: null, + shape: null, shape_preset: null, +}); +const member = (output) => ({ id: output.id, device_id: output.device_id, name: output.name, segment: output.zone_name }); +const placement = (output) => ({ + member: output.id, position: output.position, size: output.size, + rotation: output.rotation, scale: output.scale, orientation: output.orientation, + topology: output.topology, +}); +const device = (id, name) => ({ + id, layout_device_id: id, name, + origin: { driver_id: "fixture", backend_id: "usb", transport: "usb", protocol_id: "fixture/test" }, + presentation: { label: "Fixture" }, status: "connected", brightness: 100, total_leds: 12, segments: [], +}); + +export async function openHistory(page, { placedStand = false, offline = false, beforeMemberReply } = {}) { + const anchor = output("anchor-output", ANCHOR, "Anchor light"); + const stand = output("stand-output", STAND, "Laptop stand"); + const outputs = new Map([[anchor.id, anchor], ...(placedStand ? [[stand.id, stand]] : [])]); + const zone = (id, name, role, members) => ({ + id, name, role, enabled: true, brightness: 1, + members: members.map(member), layout: { placements: members.map(placement) }, layers: [], + }); + const scene = { + id: "33333333-3333-4333-8333-333333333333", name: "History fixture", + kind: "named", is_default: false, revision: 42, + zones: [zone(FIRST, "Desk lights", "primary", [anchor, ...(placedStand ? [stand] : [])]), + zone(SECOND, "Accent lights", "custom", [])], + }; + let socket; + let rejectNext = false; + const writes = []; + const rejected = []; + const unexpected = []; + const envelope = (data) => ({ data, meta: { api_version: "v1", request_id: "history_fixture", timestamp: "2026-09-10T00:00:00Z" } }); + const notify = () => socket.send(JSON.stringify({ type: "event", event: "zone_changed", data: { + scene_id: scene.id, zone_id: FIRST, role: "primary", kind: "updated", + } })); + await page.setViewportSize({ width: 1600, height: 1000 }); + await page.routeWebSocket(/\/api\/v1\/ws(?:\?|$)/, (route) => { socket = route; }); + await page.route("**/api/v1/**", async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (["GET", "HEAD"].includes(request.method())) { + if (path === "/api/v1/scene") return route.fulfill({ json: envelope(scene) }); + if (path === "/api/v1/devices") { + const items = [device(ANCHOR, "Anchor light"), ...(!offline ? [device(STAND, "Laptop stand")] : [])]; + return route.fulfill({ json: envelope({ items, total: items.length }) }); + } + // Avoid allowing unrelated persisted layouts to contribute offline cards. + if (path === "/api/v1/layouts") return route.fulfill({ json: envelope({ items: [], total: 0 }) }); + return route.continue(); + } + const body = request.postDataJSON(); + const write = { path, method: request.method(), body, revision: request.headers()["if-match"] }; + writes.push(write); + if (path === "/api/v1/scene/members/edit" && request.method() === "POST") { + if (beforeMemberReply) await beforeMemberReply(write); + if (body.scene_id !== scene.id || write.revision !== String(scene.revision) || rejectNext) { + rejectNext = false; + rejected.push(write); + return route.fulfill({ status: 412, json: envelope(scene), headers: { etag: String(scene.revision) } }); + } + let changes = structuredClone(body.changes); + if (body.assignment) { + expect(changes).toEqual([]); + const assignment = body.assignment; + expect(assignment.device_id).toBe(STAND); + expect(assignment.segments).toEqual([]); + expect(assignment.placements ?? []).toEqual([]); + const target = scene.zones.find((zone) => zone.id === assignment.zone_id); + expect(target).toBeDefined(); + const owner = scene.zones.find((zone) => zone.members.some((member) => member.device_id === STAND)); + const existing = owner?.members.find((member) => member.device_id === STAND); + const canonical = existing ? outputs.get(existing.id) : stand; + changes = [{ + before: existing ? { zone_id: owner.id, output: canonical, index: owner.members.indexOf(existing) } : null, + after: { zone_id: target.id, output: structuredClone(canonical), index: target.members.length }, + }]; + } + // Validate every precondition before mutating any membership. + for (const change of changes) { + if (!change.before) continue; + const source = scene.zones.find((zone) => zone.id === change.before.zone_id); + expect(source.members[change.before.index]?.id).toBe(change.before.output.id); + change.before.output = structuredClone(outputs.get(change.before.output.id)); + } + for (const change of changes) { + if (!change.before) continue; + const source = scene.zones.find((zone) => zone.id === change.before.zone_id); + source.members = source.members.filter((member) => member.id !== change.before.output.id); + source.layout.placements = source.layout.placements.filter((placement) => placement.member !== change.before.output.id); + outputs.delete(change.before.output.id); + } + for (const change of changes) { + if (!change.after) continue; + const target = scene.zones.find((zone) => zone.id === change.after.zone_id); + const next = structuredClone(change.after.output); + outputs.set(next.id, next); + target.members.splice(change.after.index, 0, member(next)); + target.layout.placements.splice(change.after.index, 0, placement(next)); + } + scene.revision += 1; + await route.fulfill({ json: envelope({ document: scene, changes }) }); + notify(); + return; + } + const layoutMatch = path.match(/^\/api\/v1\/scene\/zones\/([^/]+)\/layout$/); + if (layoutMatch && request.method() === "PUT") { + expect(write.revision).toBe(String(scene.revision)); + const zone = scene.zones.find((zone) => zone.id === layoutMatch[1]); + zone.layout = body; + for (const p of body.placements) Object.assign(outputs.get(p.member), p); + scene.revision += 1; + await route.fulfill({ json: envelope(zone) }); + notify(); + return; + } + unexpected.push(write); + await route.abort("blockedbyclient"); + }); + await page.goto(getStack().appOrigin + "/studio", { waitUntil: "networkidle" }); + await expect.poll(() => Boolean(socket)).toBe(true); + await expect(page.locator('[data-zone-id="anchor-output"]')).toBeVisible(); + return { + scene, writes, rejected, unexpected, + rejectNext: () => { rejectNext = true; }, + remotePlacement: (id, fields) => { + Object.assign(outputs.get(id), fields); + for (const zone of scene.zones) { + const existing = zone.layout.placements.find((placement) => placement.member === id); + if (existing) Object.assign(existing, fields); + } + scene.revision += 1; + notify(); + }, + assigned: (deviceId = STAND) => scene.zones.flatMap((zone) => zone.members + .filter((member) => member.device_id === deviceId).map((member) => ({ zone: zone.id, id: member.id }))), + undo: page.getByRole("button", { name: "Undo (Ctrl+Z)", exact: true }), + redo: page.getByRole("button", { name: "Redo (Ctrl+Shift+Z)", exact: true }), + }; +} + +export async function addStand(page) { + await page.getByTitle("Add to a zone", { exact: true }).click(); + await page.getByRole("button", { name: "Add to Desk lights", exact: true }).click(); +} + +export async function dragOutput(page, id) { + const output = page.locator(`[data-zone-id="${id}"]`); + await output.evaluate((element) => { + const bounds = element.getBoundingClientRect(); + const x = Math.round(bounds.x + bounds.width / 2); + const y = Math.round(bounds.y + bounds.height / 2); + for (const [type, dx, dy, buttons] of [["mousedown", 0, 0, 1], ["mousemove", 35, 20, 1], ["mouseup", 35, 20, 0]]) { + element.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, buttons, clientX: x + dx, clientY: y + dy })); + } + }); +} diff --git a/e2e/tests/studio-history.spec.mjs b/e2e/tests/studio-history.spec.mjs new file mode 100644 index 000000000..52aa4e4e7 --- /dev/null +++ b/e2e/tests/studio-history.spec.mjs @@ -0,0 +1,325 @@ +import { test, expect } from "@playwright/test"; +import { openHistory, addStand, dragOutput, FIRST, SECOND } from "./studio-history-fixture.mjs"; + +const standCard = (page) => page.locator('div.group\\/card').filter({ has: page.getByText("Laptop stand", { exact: true }) }); +const geometry = (element) => ({ left: element.style.left, top: element.style.top }); + +test("Studio device assignment saves immediately and supports undo and redo", async ({ page }) => { + const fixture = await openHistory(page); + await expect(fixture.undo).toBeDisabled(); + await addStand(page); + await expect.poll(() => fixture.assigned().length).toBe(1); + const assignment = fixture.assigned(); + expect(assignment[0].zone).toBe(FIRST); + await expect(page.getByText("Assignment saved", { exact: true })).toBeVisible(); + await expect(fixture.undo).toBeEnabled(); + await fixture.undo.click(); + await expect.poll(() => fixture.assigned()).toEqual([]); + await expect(fixture.redo).toBeEnabled(); + await fixture.redo.click(); + await expect.poll(() => fixture.assigned()).toEqual(assignment); + await expect(fixture.redo).toBeDisabled(); + expect(fixture.writes.map((write) => write.path)).toEqual(Array(3).fill("/api/v1/scene/members/edit")); + expect(fixture.rejected).toEqual([]); + expect(fixture.unexpected).toEqual([]); +}); + +test("Studio moving a device restores its original zone and placement on undo", async ({ page }) => { + const fixture = await openHistory(page, { placedStand: true }); + const original = structuredClone(fixture.scene.zones[0]); + await standCard(page).getByTitle("Device options").click(); + await page.getByRole("button", { name: "Move to Accent lights", exact: true }).click(); + await expect.poll(() => fixture.assigned()).toEqual([{ zone: SECOND, id: "stand-output" }]); + await expect(fixture.undo).toBeEnabled(); + await fixture.undo.click(); + await expect.poll(() => fixture.scene.zones[0].members).toEqual(original.members); + expect(fixture.scene.zones[0].layout).toEqual(original.layout); + await expect(fixture.redo).toBeEnabled(); + await fixture.redo.click(); + await expect.poll(() => fixture.assigned()).toEqual([{ zone: SECOND, id: "stand-output" }]); + expect(fixture.rejected).toEqual([]); + expect(fixture.unexpected).toEqual([]); +}); + +test("Studio can undo removing an offline device without requiring its registry entry", async ({ page }) => { + const fixture = await openHistory(page, { placedStand: true, offline: true }); + const original = structuredClone(fixture.scene.zones[0]); + await page.getByRole("button", { name: "Remove from this zone", exact: true }).click(); + await expect.poll(() => fixture.assigned()).toEqual([]); + await expect(fixture.undo).toBeEnabled(); + await fixture.undo.click(); + await expect.poll(() => fixture.scene.zones[0].members).toEqual(original.members); + expect(fixture.scene.zones[0].layout).toEqual(original.layout); + await expect(page.getByRole("button", { name: "Remove from this zone", exact: true })).toBeVisible(); + expect(fixture.rejected).toEqual([]); + expect(fixture.unexpected).toEqual([]); +}); + +test("Studio undo follows geometry and assignment edits in chronological order", async ({ page }) => { + const fixture = await openHistory(page); + const anchor = page.locator('[data-zone-id="anchor-output"]'); + const original = await anchor.evaluate(geometry); + await dragOutput(page, "anchor-output"); + await expect.poll(() => anchor.evaluate(geometry)).not.toEqual(original); + const moved = await anchor.evaluate(geometry); + await addStand(page); + await expect.poll(() => fixture.assigned().length).toBe(1); + await expect(fixture.undo).toBeEnabled(); + await fixture.undo.click(); + await expect.poll(() => fixture.assigned()).toEqual([]); + await expect.poll(() => anchor.evaluate(geometry)).toEqual(moved); + await expect(fixture.undo).toBeEnabled(); + await fixture.undo.click(); + await expect.poll(() => anchor.evaluate(geometry)).toEqual(original); + await expect(fixture.redo).toBeEnabled(); + await fixture.redo.click(); + await expect.poll(() => anchor.evaluate(geometry)).toEqual(moved); + await fixture.redo.click(); + await expect.poll(() => fixture.assigned().length).toBe(1); + expect(fixture.rejected).toEqual([]); + expect(fixture.unexpected).toEqual([]); +}); + +test("Studio rejected assignment leaves existing history and device membership intact", async ({ page }) => { + const fixture = await openHistory(page); + const anchor = page.locator('[data-zone-id="anchor-output"]'); + const original = await anchor.evaluate(geometry); + await dragOutput(page, "anchor-output"); + await expect.poll(() => anchor.evaluate(geometry)).not.toEqual(original); + fixture.rejectNext(); + await addStand(page); + await expect.poll(() => fixture.rejected.length).toBe(1); + await expect(fixture.undo).toBeEnabled(); + expect(fixture.assigned()).toEqual([]); + await expect(fixture.redo).toBeDisabled(); + await fixture.undo.click(); + await expect.poll(() => anchor.evaluate(geometry)).toEqual(original); + await expect(fixture.undo).toBeDisabled(); + await expect(fixture.redo).toBeEnabled(); + expect(fixture.unexpected).toEqual([]); +}); + +test("Studio assignment completion after navigation does not access a disposed editor", async ({ page }) => { + let release; + const gate = new Promise((resolve) => { release = resolve; }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + const fixture = await openHistory(page, { beforeMemberReply: () => gate }); + await addStand(page); + await expect.poll(() => fixture.writes.length).toBe(1); + await page.getByRole("link", { name: "Effects", exact: true }).click(); + await expect(page).toHaveURL(/\/effects$/); + release(); + await expect.poll(() => fixture.assigned().length).toBe(1); + await page.waitForLoadState("networkidle"); + expect(errors).toEqual([]); + expect(fixture.unexpected).toEqual([]); +}); + +test("Studio geometry undo preserves unrelated placement fields changed remotely", async ({ page }) => { + const fixture = await openHistory(page); + const anchor = page.locator('[data-zone-id="anchor-output"]'); + const original = await anchor.evaluate(geometry); + await dragOutput(page, "anchor-output"); + await expect.poll(() => anchor.evaluate(geometry)).not.toEqual(original); + const oldWidth = await anchor.evaluate((element) => element.style.width); + fixture.remotePlacement("anchor-output", { size: { x: 0.3, y: 0.15 } }); + await expect.poll(() => anchor.evaluate((element) => element.style.width)).not.toBe(oldWidth); + const remoteSize = await anchor.evaluate((element) => ({ width: element.style.width, height: element.style.height })); + await fixture.undo.click(); + await expect.poll(() => anchor.evaluate(geometry)).toEqual(original); + expect(await anchor.evaluate((element) => ({ width: element.style.width, height: element.style.height }))).toEqual(remoteSize); + await fixture.redo.click(); + await expect.poll(() => anchor.evaluate(geometry)).not.toEqual(original); + expect(await anchor.evaluate((element) => ({ width: element.style.width, height: element.style.height }))).toEqual(remoteSize); + expect(fixture.unexpected).toEqual([]); +}); + +test("Studio Save commits geometry and undo redo track the saved baseline", async ({ page }) => { + const fixture = await openHistory(page); + const anchor = page.locator('[data-zone-id="anchor-output"]'); + const original = await anchor.evaluate(geometry); + const save = page.getByTitle("Save layout changes", { exact: true }); + const status = page.getByRole("status").filter({ hasText: /^(Saved|Unsaved layout|Saving…)$/ }); + await dragOutput(page, "anchor-output"); + await expect.poll(() => anchor.evaluate(geometry)).not.toEqual(original); + const moved = await anchor.evaluate(geometry); + await expect(status).toHaveText("Unsaved layout"); + await expect(save).toBeEnabled(); + await save.click(); + await expect.poll(() => fixture.writes.filter((write) => write.method === "PUT").length).toBe(1); + await expect(status).toHaveText("Saved"); + await expect(save).toBeDisabled(); + const persisted = structuredClone(fixture.scene.zones[0].layout); + expect(fixture.writes[0].path).toBe(`/api/v1/scene/zones/${FIRST}/layout`); + expect(persisted.placements[0].position.x).not.toBe(0.35); + await fixture.undo.click(); + await expect.poll(() => anchor.evaluate(geometry)).toEqual(original); + await expect(status).toHaveText("Unsaved layout"); + await expect(save).toBeEnabled(); + expect(fixture.scene.zones[0].layout).toEqual(persisted); + await fixture.redo.click(); + await expect.poll(() => anchor.evaluate(geometry)).toEqual(moved); + await expect(status).toHaveText("Saved"); + await expect(save).toBeDisabled(); + expect(fixture.writes).toHaveLength(1); + expect(fixture.unexpected).toEqual([]); +}); + +test("Studio removing and restoring a device retains its unsaved placement draft", async ({ page }) => { + const fixture = await openHistory(page, { placedStand: true }); + const stand = page.locator('[data-zone-id="stand-output"]'); + const original = await stand.evaluate(geometry); + await dragOutput(page, "stand-output"); + await expect.poll(() => stand.evaluate(geometry)).not.toEqual(original); + const draft = await stand.evaluate(geometry); + await standCard(page).getByTitle("Device options").click(); + await page.getByRole("button", { name: "Remove from zone", exact: true }).click(); + await expect.poll(() => fixture.assigned()).toEqual([]); + await expect(stand).toHaveCount(0); + await fixture.undo.click(); + await expect.poll(() => stand.evaluate(geometry)).toEqual(draft); + await expect(page.getByTitle("Save layout changes", { exact: true })).toBeEnabled(); + await fixture.undo.click(); + await expect.poll(() => stand.evaluate(geometry)).toEqual(original); + expect(fixture.unexpected).toEqual([]); +}); + +test("Studio saving assignment blocks canvas edits without changing history order", async ({ page }) => { + let release; + let replyCount = 0; + const gate = new Promise((resolve) => { release = resolve; }); + const fixture = await openHistory(page, { beforeMemberReply: () => ++replyCount === 1 ? gate : undefined }); + const anchor = page.locator('[data-zone-id="anchor-output"]'); + const original = await anchor.evaluate(geometry); + await dragOutput(page, "anchor-output"); + await expect.poll(() => anchor.evaluate(geometry)).not.toEqual(original); + const moved = await anchor.evaluate(geometry); + await addStand(page); + await expect.poll(() => fixture.writes.length).toBe(1); + await expect(page.getByRole("status").filter({ hasText: "Saving…" })).toBeVisible(); + await expect(fixture.undo).toBeDisabled(); + await expect(fixture.redo).toBeDisabled(); + expect(await anchor.evaluate((element) => Boolean(element.closest("[inert]")))).toBe(true); + const bounds = await anchor.boundingBox(); + await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); + await page.mouse.down(); + await page.mouse.move(bounds.x + bounds.width / 2 + 35, bounds.y + bounds.height / 2 + 20); + await page.mouse.up(); + await page.keyboard.press("Control+z"); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(resolve))); + expect(await anchor.evaluate(geometry)).toEqual(moved); + release(); + await expect.poll(() => fixture.assigned().length).toBe(1); + await expect(fixture.undo).toBeEnabled(); + await fixture.undo.click(); + await expect.poll(() => fixture.assigned()).toEqual([]); + await expect.poll(() => anchor.evaluate(geometry)).toEqual(moved); + await fixture.undo.click(); + await expect.poll(() => anchor.evaluate(geometry)).toEqual(original); + await expect(fixture.undo).toBeDisabled(); + expect(fixture.unexpected).toEqual([]); +}); + +test("Studio assignment from the selected Unassigned bucket opens the destination undo toolbar", async ({ page }) => { + const fixture = await openHistory(page); + await page.getByRole("button", { name: /^Unassigned Hardware in no zone/ }).click(); + await expect(fixture.undo).toHaveCount(0); + await addStand(page); + await expect.poll(() => fixture.assigned().length).toBe(1); + await expect(fixture.undo).toBeVisible(); + await expect(fixture.undo).toBeEnabled(); + await fixture.undo.click(); + await expect.poll(() => fixture.assigned()).toEqual([]); + await expect(fixture.redo).toBeEnabled(); + expect(fixture.unexpected).toEqual([]); +}); + +test("Studio assignment completion respects a surface selected while saving", async ({ page }) => { + let release; + const gate = new Promise((resolve) => { release = resolve; }); + const fixture = await openHistory(page, { beforeMemberReply: () => gate }); + await page.getByRole("button", { name: /^Unassigned Hardware in no zone/ }).click(); + await addStand(page); + await expect.poll(() => fixture.writes.length).toBe(1); + await page.getByRole("button", { name: "Accent lights 0 devices", exact: true }).click(); + release(); + await expect.poll(() => fixture.assigned().length).toBe(1); + await expect(fixture.undo).toBeEnabled(); + await expect(page.locator('[data-zone-id="stand-output"]')).toHaveCount(0); + await expect(page.locator('[data-zone-id="anchor-output"]')).toHaveCount(0); + expect(fixture.unexpected).toEqual([]); +}); + +test("Studio rejected assignment keeps the Unassigned bucket selected", async ({ page }) => { + const fixture = await openHistory(page); + await page.getByRole("button", { name: /^Unassigned Hardware in no zone/ }).click(); + fixture.rejectNext(); + await addStand(page); + await expect.poll(() => fixture.rejected.length).toBe(1); + await page.waitForLoadState("networkidle"); + await expect(fixture.undo).toHaveCount(0); + expect(fixture.assigned()).toEqual([]); + expect(fixture.unexpected).toEqual([]); +}); + +test("Studio pending assignment disables open device menus and the add picker", async ({ page }) => { + let release; + const gate = new Promise((resolve) => { release = resolve; }); + const fixture = await openHistory(page, { beforeMemberReply: () => gate }); + await page.getByTitle("Device options", { exact: true }).click(); + const move = page.getByRole("button", { name: "Move to Accent lights", exact: true }); + const remove = page.getByRole("button", { name: "Remove from zone", exact: true }); + await expect(move).toBeEnabled(); + await page.getByRole("button", { name: "Add device", exact: true }).last().click(); + const picker = page.getByRole("button", { name: "Pick a device…", exact: true }); + await expect(picker).toBeEnabled(); + await addStand(page); + await expect.poll(() => fixture.writes.length).toBe(1); + // The rail may close its picker when the operation starts. Either state + // must make another assignment unavailable until the reply arrives. + if (await picker.count()) { + await expect(picker).toBeDisabled(); + } else { + await expect(page.getByRole("button", { name: "Add device", exact: true }).last()).toBeDisabled(); + } + if (!(await move.isVisible())) await page.getByTitle("Device options", { exact: true }).click(); + await expect(move).toBeDisabled(); + await expect(remove).toBeDisabled(); + await expect(page.getByTitle("Add to a zone", { exact: true })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Accent lights 0 devices", exact: true })).toBeEnabled(); + release(); + await expect.poll(() => fixture.assigned().length).toBe(1); + if (!(await picker.count())) await page.getByRole("button", { name: "Add device", exact: true }).last().click(); + await expect(picker).toBeEnabled(); + if (!(await move.isVisible())) { + await page.locator('div.group\\/card').filter({ has: page.getByText("Anchor light", { exact: true }) }) + .getByTitle("Device options", { exact: true }).click(); + } + await expect(move).toBeEnabled(); + await expect(remove).toBeEnabled(); + expect(fixture.writes).toHaveLength(1); + expect(fixture.unexpected).toEqual([]); +}); + +test("Studio cancels an undo superseded by another zone selection without locking history", async ({ page }) => { + const fixture = await openHistory(page); + const anchor = page.locator('[data-zone-id="anchor-output"]'); + const original = await anchor.evaluate(geometry); + await dragOutput(page, "anchor-output"); + await expect.poll(() => anchor.evaluate(geometry)).not.toEqual(original); + await expect(fixture.undo).toBeEnabled(); + const otherZone = page.getByRole("button", { name: "Accent lights 0 devices", exact: true }); + // Both handlers run before the reactive provider consumes the queued replay. + await page.evaluate(({ undo, otherZone }) => { undo.click(); otherZone.click(); }, { + undo: await fixture.undo.elementHandle(), otherZone: await otherZone.elementHandle(), + }); + await expect(fixture.undo).toBeEnabled(); + await expect(page.getByRole("status").filter({ hasText: "Saving…" })).toHaveCount(0); + await expect(anchor).toHaveCount(0); + await fixture.undo.click(); + await expect.poll(() => anchor.evaluate(geometry)).toEqual(original); + await expect(fixture.undo).toBeDisabled(); + await expect(fixture.redo).toBeEnabled(); + expect(fixture.unexpected).toEqual([]); +}); diff --git a/e2e/tests/studio-performance.spec.mjs b/e2e/tests/studio-performance.spec.mjs index 8b73ceb55..952edcdda 100644 --- a/e2e/tests/studio-performance.spec.mjs +++ b/e2e/tests/studio-performance.spec.mjs @@ -322,12 +322,13 @@ test("Studio preserves the inspector during control events and refreshes values // Other app contexts may fetch their own primary-effect state; control // events must leave this inspector mounted while the user is editing. expect(await originalControl.evaluate((element) => element.isConnected)).toBe(true); + await expect(speed).toHaveValue("0.8"); const before = fixture.sceneRequests(); await page.getByRole("button", { name: "Fixture lights 80 devices", exact: true }).click(); await expect(page.locator(outputSelector)).toHaveCount(OUTPUT_COUNT); await selectEmpty.click(); await expect(speed).toHaveValue("0.8"); - expect(fixture.sceneRequests()).toBeGreaterThan(before); + expect(fixture.sceneRequests()).toBe(before); // The write is aborted by openStudio's route before reaching the daemon; // inspecting it proves the refreshed revision reached mutation callbacks. diff --git a/python/src/hypercolor/_generated/api/scenes/edit_live_scene_members.py b/python/src/hypercolor/_generated/api/scenes/edit_live_scene_members.py new file mode 100644 index 000000000..bc928ebf0 --- /dev/null +++ b/python/src/hypercolor/_generated/api/scenes/edit_live_scene_members.py @@ -0,0 +1,226 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.api_error_body import ApiErrorBody +from ...models.edit_live_scene_members_response_200 import ( + EditLiveSceneMembersResponse200, +) +from ...models.edit_members_request import EditMembersRequest +from ...types import Response + + +def _get_kwargs( + *, + body: EditMembersRequest, + if_match: str, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + headers["If-Match"] = if_match + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/api/v1/scene/members/edit", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ApiErrorBody | EditLiveSceneMembersResponse200 | None: + if response.status_code == 200: + response_200 = EditLiveSceneMembersResponse200.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = ApiErrorBody.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = ApiErrorBody.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = ApiErrorBody.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = ApiErrorBody.from_dict(response.json()) + + return response_404 + + if response.status_code == 409: + response_409 = ApiErrorBody.from_dict(response.json()) + + return response_409 + + if response.status_code == 412: + response_412 = ApiErrorBody.from_dict(response.json()) + + return response_412 + + if response.status_code == 422: + response_422 = ApiErrorBody.from_dict(response.json()) + + return response_422 + + if response.status_code == 429: + response_429 = ApiErrorBody.from_dict(response.json()) + + return response_429 + + if response.status_code == 500: + response_500 = ApiErrorBody.from_dict(response.json()) + + return response_500 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ApiErrorBody | EditLiveSceneMembersResponse200]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + body: EditMembersRequest, + if_match: str, +) -> Response[ApiErrorBody | EditLiveSceneMembersResponse200]: + """Atomically edit live scene memberships + + Args: + if_match (str): + body (EditMembersRequest): `POST /scene/members/edit`: one revision-fenced membership + transaction. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ApiErrorBody | EditLiveSceneMembersResponse200] + """ + + kwargs = _get_kwargs( + body=body, + if_match=if_match, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + body: EditMembersRequest, + if_match: str, +) -> ApiErrorBody | EditLiveSceneMembersResponse200 | None: + """Atomically edit live scene memberships + + Args: + if_match (str): + body (EditMembersRequest): `POST /scene/members/edit`: one revision-fenced membership + transaction. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ApiErrorBody | EditLiveSceneMembersResponse200 + """ + + return sync_detailed( + client=client, + body=body, + if_match=if_match, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + body: EditMembersRequest, + if_match: str, +) -> Response[ApiErrorBody | EditLiveSceneMembersResponse200]: + """Atomically edit live scene memberships + + Args: + if_match (str): + body (EditMembersRequest): `POST /scene/members/edit`: one revision-fenced membership + transaction. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ApiErrorBody | EditLiveSceneMembersResponse200] + """ + + kwargs = _get_kwargs( + body=body, + if_match=if_match, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + body: EditMembersRequest, + if_match: str, +) -> ApiErrorBody | EditLiveSceneMembersResponse200 | None: + """Atomically edit live scene memberships + + Args: + if_match (str): + body (EditMembersRequest): `POST /scene/members/edit`: one revision-fenced membership + transaction. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ApiErrorBody | EditLiveSceneMembersResponse200 + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + if_match=if_match, + ) + ).parsed diff --git a/python/src/hypercolor/_generated/models/__init__.py b/python/src/hypercolor/_generated/models/__init__.py index c872a3d2a..d4493fce4 100644 --- a/python/src/hypercolor/_generated/models/__init__.py +++ b/python/src/hypercolor/_generated/models/__init__.py @@ -351,6 +351,9 @@ from .edge_behavior_fade_to_black import EdgeBehaviorFadeToBlack from .edge_behavior_fade_to_black_value import EdgeBehaviorFadeToBlackValue from .edge_behavior_type_0 import EdgeBehaviorType0 +from .edit_live_scene_members_response_200 import EditLiveSceneMembersResponse200 +from .edit_members_request import EditMembersRequest +from .edit_members_response import EditMembersResponse from .effect_capability_set import EffectCapabilitySet from .effect_category import EffectCategory from .effect_detail_response import EffectDetailResponse @@ -480,7 +483,11 @@ from .media_authorization_request import MediaAuthorizationRequest from .media_authorization_response import MediaAuthorizationResponse from .media_playback import MediaPlayback +from .member_assignment_target import MemberAssignmentTarget +from .member_edit import MemberEdit from .member_placement import MemberPlacement +from .member_placement_hint import MemberPlacementHint +from .member_state import MemberState from .normalized_position import NormalizedPosition from .normalized_rect import NormalizedRect from .open_rgb_endpoint_status import OpenRgbEndpointStatus @@ -956,6 +963,9 @@ "EdgeBehaviorFadeToBlack", "EdgeBehaviorFadeToBlackValue", "EdgeBehaviorType0", + "EditLiveSceneMembersResponse200", + "EditMembersRequest", + "EditMembersResponse", "EffectCapabilitySet", "EffectCategory", "EffectDetailResponse", @@ -1081,7 +1091,11 @@ "MediaAuthorizationRequest", "MediaAuthorizationResponse", "MediaPlayback", + "MemberAssignmentTarget", + "MemberEdit", "MemberPlacement", + "MemberPlacementHint", + "MemberState", "NormalizedPosition", "NormalizedRect", "OpenRgbEndpointStatus", diff --git a/python/src/hypercolor/_generated/models/edit_live_scene_members_response_200.py b/python/src/hypercolor/_generated/models/edit_live_scene_members_response_200.py new file mode 100644 index 000000000..be9e02f72 --- /dev/null +++ b/python/src/hypercolor/_generated/models/edit_live_scene_members_response_200.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.edit_members_response import EditMembersResponse + from ..models.response_meta import ResponseMeta + + +T = TypeVar("T", bound="EditLiveSceneMembersResponse200") + + +@_attrs_define +class EditLiveSceneMembersResponse200: + """ + Attributes: + data (EditMembersResponse): Committed scene and reversible, canonical membership changes. + meta (ResponseMeta): Response metadata included in every envelope. + """ + + data: EditMembersResponse + meta: ResponseMeta + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + data = self.data.to_dict() + + meta = self.meta.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "data": data, + "meta": meta, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.edit_members_response import EditMembersResponse + from ..models.response_meta import ResponseMeta + + d = dict(src_dict) + data = EditMembersResponse.from_dict(d.pop("data")) + + meta = ResponseMeta.from_dict(d.pop("meta")) + + edit_live_scene_members_response_200 = cls( + data=data, + meta=meta, + ) + + edit_live_scene_members_response_200.additional_properties = d + return edit_live_scene_members_response_200 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/python/src/hypercolor/_generated/models/edit_members_request.py b/python/src/hypercolor/_generated/models/edit_members_request.py new file mode 100644 index 000000000..94df2b8c6 --- /dev/null +++ b/python/src/hypercolor/_generated/models/edit_members_request.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.member_assignment_target import MemberAssignmentTarget + from ..models.member_edit import MemberEdit + + +T = TypeVar("T", bound="EditMembersRequest") + + +@_attrs_define +class EditMembersRequest: + """`POST /scene/members/edit`: one revision-fenced membership transaction. + + Attributes: + scene_id (str): + assignment (MemberAssignmentTarget | None | Unset): + changes (list[MemberEdit] | Unset): + """ + + scene_id: str + assignment: MemberAssignmentTarget | None | Unset = UNSET + changes: list[MemberEdit] | Unset = UNSET + + def to_dict(self) -> dict[str, Any]: + from ..models.member_assignment_target import MemberAssignmentTarget + + scene_id = self.scene_id + + assignment: dict[str, Any] | None | Unset + if isinstance(self.assignment, Unset): + assignment = UNSET + elif isinstance(self.assignment, MemberAssignmentTarget): + assignment = self.assignment.to_dict() + else: + assignment = self.assignment + + changes: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.changes, Unset): + changes = [] + for changes_item_data in self.changes: + changes_item = changes_item_data.to_dict() + changes.append(changes_item) + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "scene_id": scene_id, + } + ) + if assignment is not UNSET: + field_dict["assignment"] = assignment + if changes is not UNSET: + field_dict["changes"] = changes + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.member_assignment_target import MemberAssignmentTarget + from ..models.member_edit import MemberEdit + + d = dict(src_dict) + scene_id = d.pop("scene_id") + + def _parse_assignment(data: object) -> MemberAssignmentTarget | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + assignment_type_1 = MemberAssignmentTarget.from_dict(data) + + return assignment_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(MemberAssignmentTarget | None | Unset, data) + + assignment = _parse_assignment(d.pop("assignment", UNSET)) + + _changes = d.pop("changes", UNSET) + changes: list[MemberEdit] | Unset = UNSET + if _changes is not UNSET: + changes = [] + for changes_item_data in _changes: + changes_item = MemberEdit.from_dict(changes_item_data) + + changes.append(changes_item) + + edit_members_request = cls( + scene_id=scene_id, + assignment=assignment, + changes=changes, + ) + + return edit_members_request diff --git a/python/src/hypercolor/_generated/models/edit_members_response.py b/python/src/hypercolor/_generated/models/edit_members_response.py new file mode 100644 index 000000000..3c2240488 --- /dev/null +++ b/python/src/hypercolor/_generated/models/edit_members_response.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.member_edit import MemberEdit + from ..models.scene_document import SceneDocument + + +T = TypeVar("T", bound="EditMembersResponse") + + +@_attrs_define +class EditMembersResponse: + """Committed scene and reversible, canonical membership changes. + + Attributes: + changes (list[MemberEdit]): + document (SceneDocument): The `GET /scene` document: the full live tree. + + Always present — an active scene always exists (Spec 78 §1.1), so + there is no idle sentinel and no all-optional shape. + """ + + changes: list[MemberEdit] + document: SceneDocument + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + changes = [] + for changes_item_data in self.changes: + changes_item = changes_item_data.to_dict() + changes.append(changes_item) + + document = self.document.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "changes": changes, + "document": document, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.member_edit import MemberEdit + from ..models.scene_document import SceneDocument + + d = dict(src_dict) + changes = [] + _changes = d.pop("changes") + for changes_item_data in _changes: + changes_item = MemberEdit.from_dict(changes_item_data) + + changes.append(changes_item) + + document = SceneDocument.from_dict(d.pop("document")) + + edit_members_response = cls( + changes=changes, + document=document, + ) + + edit_members_response.additional_properties = d + return edit_members_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/python/src/hypercolor/_generated/models/member_assignment_target.py b/python/src/hypercolor/_generated/models/member_assignment_target.py new file mode 100644 index 000000000..e7b340b95 --- /dev/null +++ b/python/src/hypercolor/_generated/models/member_assignment_target.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.member_placement_hint import MemberPlacementHint + + +T = TypeVar("T", bound="MemberAssignmentTarget") + + +@_attrs_define +class MemberAssignmentTarget: + """Device segments to assign using daemon-owned layout construction. + + Attributes: + device_id (str): + zone_id (str): + placements (list[MemberPlacementHint] | Unset): Optional seeded geometry for newly minted outputs, keyed by + segment. + segments (list[str] | Unset): Empty selects every light segment, including all attachment instances. + """ + + device_id: str + zone_id: str + placements: list[MemberPlacementHint] | Unset = UNSET + segments: list[str] | Unset = UNSET + + def to_dict(self) -> dict[str, Any]: + device_id = self.device_id + + zone_id = self.zone_id + + placements: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.placements, Unset): + placements = [] + for placements_item_data in self.placements: + placements_item = placements_item_data.to_dict() + placements.append(placements_item) + + segments: list[str] | Unset = UNSET + if not isinstance(self.segments, Unset): + segments = self.segments + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "device_id": device_id, + "zone_id": zone_id, + } + ) + if placements is not UNSET: + field_dict["placements"] = placements + if segments is not UNSET: + field_dict["segments"] = segments + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.member_placement_hint import MemberPlacementHint + + d = dict(src_dict) + device_id = d.pop("device_id") + + zone_id = d.pop("zone_id") + + _placements = d.pop("placements", UNSET) + placements: list[MemberPlacementHint] | Unset = UNSET + if _placements is not UNSET: + placements = [] + for placements_item_data in _placements: + placements_item = MemberPlacementHint.from_dict(placements_item_data) + + placements.append(placements_item) + + segments = cast(list[str], d.pop("segments", UNSET)) + + member_assignment_target = cls( + device_id=device_id, + zone_id=zone_id, + placements=placements, + segments=segments, + ) + + return member_assignment_target diff --git a/python/src/hypercolor/_generated/models/member_edit.py b/python/src/hypercolor/_generated/models/member_edit.py new file mode 100644 index 000000000..fe99a59f3 --- /dev/null +++ b/python/src/hypercolor/_generated/models/member_edit.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.member_state import MemberState + + +T = TypeVar("T", bound="MemberEdit") + + +@_attrs_define +class MemberEdit: + """An atomic add, move, placement change, or removal of one output. + + Preconditions compare membership identity and public placement fields. + The response replaces both sides with complete authoritative snapshots, + preserving fields absent from the scene document for later restoration. + + Attributes: + after (MemberState | None | Unset): + before (MemberState | None | Unset): + """ + + after: MemberState | None | Unset = UNSET + before: MemberState | None | Unset = UNSET + + def to_dict(self) -> dict[str, Any]: + from ..models.member_state import MemberState + + after: dict[str, Any] | None | Unset + if isinstance(self.after, Unset): + after = UNSET + elif isinstance(self.after, MemberState): + after = self.after.to_dict() + else: + after = self.after + + before: dict[str, Any] | None | Unset + if isinstance(self.before, Unset): + before = UNSET + elif isinstance(self.before, MemberState): + before = self.before.to_dict() + else: + before = self.before + + field_dict: dict[str, Any] = {} + + field_dict.update({}) + if after is not UNSET: + field_dict["after"] = after + if before is not UNSET: + field_dict["before"] = before + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.member_state import MemberState + + d = dict(src_dict) + + def _parse_after(data: object) -> MemberState | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + after_type_1 = MemberState.from_dict(data) + + return after_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(MemberState | None | Unset, data) + + after = _parse_after(d.pop("after", UNSET)) + + def _parse_before(data: object) -> MemberState | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + before_type_1 = MemberState.from_dict(data) + + return before_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(MemberState | None | Unset, data) + + before = _parse_before(d.pop("before", UNSET)) + + member_edit = cls( + after=after, + before=before, + ) + + return member_edit diff --git a/python/src/hypercolor/_generated/models/member_placement_hint.py b/python/src/hypercolor/_generated/models/member_placement_hint.py new file mode 100644 index 000000000..943febbeb --- /dev/null +++ b/python/src/hypercolor/_generated/models/member_placement_hint.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define + +from ..models.orientation import Orientation +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.normalized_position import NormalizedPosition + + +T = TypeVar("T", bound="MemberPlacementHint") + + +@_attrs_define +class MemberPlacementHint: + """Placement-only seed; device binding and topology remain daemon-owned. + + Attributes: + position (NormalizedPosition): A position in normalized `[0.0, 1.0]` canvas space. + + - `(0.0, 0.0)` = top-left corner of the canvas + - `(1.0, 1.0)` = bottom-right corner of the canvas + - `(0.5, 0.5)` = center of the canvas + + Values outside `[0.0, 1.0]` are permitted — they represent positions + beyond the canvas bounds and are handled by [`EdgeBehavior`]. + + Used for zone positions and sizes on the canvas, LED positions within + a zone's bounding box, and space regions in multi-room layouts. + size (NormalizedPosition): A position in normalized `[0.0, 1.0]` canvas space. + + - `(0.0, 0.0)` = top-left corner of the canvas + - `(1.0, 1.0)` = bottom-right corner of the canvas + - `(0.5, 0.5)` = center of the canvas + + Values outside `[0.0, 1.0]` are permitted — they represent positions + beyond the canvas bounds and are handled by [`EdgeBehavior`]. + + Used for zone positions and sizes on the canvas, LED positions within + a zone's bounding box, and space regions in multi-room layouts. + orientation (None | Orientation | Unset): + rotation (float | Unset): + scale (float | Unset): + segment (None | str | Unset): + """ + + position: NormalizedPosition + size: NormalizedPosition + orientation: None | Orientation | Unset = UNSET + rotation: float | Unset = UNSET + scale: float | Unset = UNSET + segment: None | str | Unset = UNSET + + def to_dict(self) -> dict[str, Any]: + position = self.position.to_dict() + + size = self.size.to_dict() + + orientation: None | str | Unset + if isinstance(self.orientation, Unset): + orientation = UNSET + elif isinstance(self.orientation, Orientation): + orientation = self.orientation.value + else: + orientation = self.orientation + + rotation = self.rotation + + scale = self.scale + + segment: None | str | Unset + if isinstance(self.segment, Unset): + segment = UNSET + else: + segment = self.segment + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "position": position, + "size": size, + } + ) + if orientation is not UNSET: + field_dict["orientation"] = orientation + if rotation is not UNSET: + field_dict["rotation"] = rotation + if scale is not UNSET: + field_dict["scale"] = scale + if segment is not UNSET: + field_dict["segment"] = segment + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.normalized_position import NormalizedPosition + + d = dict(src_dict) + position = NormalizedPosition.from_dict(d.pop("position")) + + size = NormalizedPosition.from_dict(d.pop("size")) + + def _parse_orientation(data: object) -> None | Orientation | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + orientation_type_1 = Orientation(data) + + return orientation_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(None | Orientation | Unset, data) + + orientation = _parse_orientation(d.pop("orientation", UNSET)) + + rotation = d.pop("rotation", UNSET) + + scale = d.pop("scale", UNSET) + + def _parse_segment(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + segment = _parse_segment(d.pop("segment", UNSET)) + + member_placement_hint = cls( + position=position, + size=size, + orientation=orientation, + rotation=rotation, + scale=scale, + segment=segment, + ) + + return member_placement_hint diff --git a/python/src/hypercolor/_generated/models/member_state.py b/python/src/hypercolor/_generated/models/member_state.py new file mode 100644 index 000000000..bc8ceaeb7 --- /dev/null +++ b/python/src/hypercolor/_generated/models/member_state.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define + +if TYPE_CHECKING: + from ..models.output import Output + + +T = TypeVar("T", bound="MemberState") + + +@_attrs_define +class MemberState: + """One output's complete authored state for reversible membership edits. + + Attributes: + index (int): Position in the owning zone's ordered output list. + output (Output): A device output: the spatial binding between a physical device and a + region of the effect canvas. + + The output's bounding rectangle is defined by `position` (center) and + `size` (width, height), both in normalized `[0.0, 1.0]` canvas coordinates. + LED positions within the output are computed from the `topology` and stored + in `led_positions` as output-local normalized coordinates. + zone_id (str): + """ + + index: int + output: Output + zone_id: str + + def to_dict(self) -> dict[str, Any]: + index = self.index + + output = self.output.to_dict() + + zone_id = self.zone_id + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "index": index, + "output": output, + "zone_id": zone_id, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.output import Output + + d = dict(src_dict) + index = d.pop("index") + + output = Output.from_dict(d.pop("output")) + + zone_id = d.pop("zone_id") + + member_state = cls( + index=index, + output=output, + zone_id=zone_id, + ) + + return member_state