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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions crates/hypercolor-core/src/scene/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1705,6 +1705,18 @@ pub fn default_primary_zone(mut layout: SpatialLayout) -> Zone {
}
}

/// Whether the scene's primary zone covers the full scope and so follows
/// the active spatial layout.
///
/// Until a custom LED zone exists, effect applies and layout publications
/// rewrite the primary zone's layout from the active layout, so a placement
/// that should last belongs in that layout. Once a custom LED zone claims
/// outputs, the primary zone's layout is scene-local.
#[must_use]
pub fn primary_zone_follows_active_layout(scene: &Scene) -> bool {
!scene_has_custom_led_zones(scene)
}

fn bump_zones_revision(scene: &mut Scene) {
scene.zones_revision = scene.zones_revision.saturating_add(1);
}
Expand Down
36 changes: 36 additions & 0 deletions crates/hypercolor-core/tests/zone_lifecycle_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use std::collections::HashMap;

use hypercolor_core::scene::{
OutputPlacement, SceneManager, ZoneMetaPatch, ZoneMutationError, make_scene,
primary_zone_follows_active_layout,
};
use hypercolor_types::device::DeviceId;
use hypercolor_types::effect::{EffectCategory, EffectId, EffectMetadata, EffectSource};
Expand Down Expand Up @@ -607,6 +608,41 @@ fn layout_sync_preserves_primary_assignment_when_custom_zones_exist() {
assert_eq!(after, before);
}

#[test]
fn primary_zone_follows_active_layout_until_a_custom_led_zone_exists() {
let mut manager = SceneManager::with_default();
let scene_id = SceneId::DEFAULT;
manager
.upsert_primary_zone(
&sample_effect("Primary"),
HashMap::new(),
None,
sample_layout("primary-zone"),
)
.expect("primary should be created");
let follows = |manager: &SceneManager| {
primary_zone_follows_active_layout(
manager
.active_scene()
.expect("default scene should be active"),
)
};
assert!(follows(&manager));

let custom = manager
.create_zone(&scene_id, "Custom".to_owned(), None, (320, 200))
.expect("custom zone should be created");
assert!(
!follows(&manager),
"an empty custom LED zone already makes the primary layout scene-local"
);

manager
.delete_zone(&scene_id, custom)
.expect("custom zone should delete");
assert!(follows(&manager));
}

#[test]
fn update_zone_layout_merges_placement_and_preserves_identity() {
let mut manager = SceneManager::with_default();
Expand Down
147 changes: 146 additions & 1 deletion crates/hypercolor-daemon/src/domain/layout/workflows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ use hypercolor_types::api::layouts::{
LayoutSummary, PreviewLayoutResponse, UpdateLayoutRequest,
};
use hypercolor_types::identity::LayoutId;
use hypercolor_types::spatial::{SamplingMode, SpatialLayout};
use hypercolor_types::spatial::{Output, SamplingMode, SpatialLayout};

use crate::domain::DomainError;
use crate::scene_transactions::LayoutUpdateGuard;

use super::{
LayoutContext, LayoutMutationResult, LayoutMutationTestOperation, LayoutMutationTestPoint,
Expand Down Expand Up @@ -330,6 +331,124 @@ impl LayoutContext {
})
}

/// Carry a Studio save on a primary zone that follows the active
/// layout into that layout.
///
/// Effect applies, layout publications, and the startup restore
/// rebuild such a zone from the active layout, so a placement saved
/// only on the zone is lost at the next rebuild. Only what the save
/// changed travels: for every output whose placement differs between
/// `before` and `after`, the placement fields are copied by output id
/// onto the stored entry and onto the published layout, wherever each
/// already holds that output. Outputs the zone holds that neither
/// layout knows (members assigned in Studio) are added to both, as the
/// startup restore would. Nothing is removed, output order and every
/// other field stay as they were, auto-layout exclusions are left
/// alone, and a stored edit that was never applied stays stored and
/// unapplied. An active layout with no stored entry is only published.
///
/// The stored entry is persisted first, then the published layout goes
/// out under the caller's guard; a rejected publication restores the
/// stored entry. Returns whether a layout was published. The caller
/// runs connectivity convergence only after releasing the guard,
/// because that pass takes the guard itself.
///
/// # Errors
///
/// Persistence and publication failures, after the stored entry has
/// been rolled back, or naming the rollback failure as well when it
/// could not be.
pub(crate) async fn mirror_primary_zone_save_under_guard(
&self,
guard: &LayoutUpdateGuard,
before: &[Output],
after: &[Output],
runtime: &LayoutRuntime,
) -> Result<bool, DomainError> {
let current = self.current();
let layout_id = current.id.clone();
let stored = self.catalog.entries().read().await.get(&layout_id).cloned();

let mut published = current.clone();
let mut stored_next = stored.clone();
for saved in after {
let moved = before
.iter()
.find(|output| output.id == saved.id)
.is_none_or(|output| placement_differs(output, saved));
let in_published = copy_placement_if_held(&mut published, saved, moved);
let in_stored = stored_next
.as_mut()
.is_some_and(|layout| copy_placement_if_held(layout, saved, moved));
if !in_published && !in_stored {
published.zones.push(saved.clone());
if let Some(layout) = stored_next.as_mut() {
layout.zones.push(saved.clone());
}
}
}

let stored_changed = stored_next != stored;
let published_changed = published != current;
if stored_changed && let Some(layout) = stored_next {
self.catalog
.entries()
.write()
.await
.insert(layout_id.clone(), layout);
if let Err(error) = self.catalog.persist().await {
let rollback = self
.restore_catalog_entry(&layout_id, stored.clone())
.await
.err()
.map(|error| format!("layout store rollback failed: {error}"));
return Err(layout_store_persistence_error(
"zone layout save",
error,
rollback,
));
}
}
if published_changed
&& let Err(error) = self
.admit_persisted_update_under_guard(guard, published, runtime)
.await
{
if stored_changed
&& let Err(rollback_error) = self.restore_catalog_entry(&layout_id, stored).await
{
return Err(DomainError::Internal(anyhow::anyhow!(
"{error}; layout store rollback failed: {rollback_error}"
)));
}
return Err(layout_update_domain_error(error));
}

if stored_changed || published_changed {
self.publish_layout_changed(None, layout_id);
}
Ok(published_changed)
}

async fn restore_catalog_entry(
&self,
layout_id: &str,
previous: Option<SpatialLayout>,
) -> anyhow::Result<()> {
{
let mut layouts = self.catalog.entries().write().await;
match previous {
Some(previous) => {
layouts.insert(layout_id.to_owned(), previous);
}
None => {
layouts.remove(layout_id);
}
}
}
self.catalog.persist().await
}

pub(crate) async fn preview(
&self,
layout: SpatialLayout,
Expand Down Expand Up @@ -640,3 +759,29 @@ impl LayoutContext {
active_layout_error.map_or(Ok(()), Err)
}
}

/// Whether the fields a zone layout save carries differ between two copies
/// of one output.
fn placement_differs(before: &Output, after: &Output) -> bool {
before.position != after.position
|| before.size != after.size
|| before.rotation.to_bits() != after.rotation.to_bits()
|| before.scale.to_bits() != after.scale.to_bits()
|| before.orientation != after.orientation
}

/// Copy a saved placement onto `layout`'s copy of the output when `moved`.
/// Returns whether `layout` holds the output at all.
fn copy_placement_if_held(layout: &mut SpatialLayout, saved: &Output, moved: bool) -> bool {
let Some(output) = layout.zones.iter_mut().find(|output| output.id == saved.id) else {
return false;
};
if moved {
output.position = saved.position;
output.size = saved.size;
output.rotation = saved.rotation;
output.scale = saved.scale;
output.orientation = saved.orientation;
}
true
}
26 changes: 26 additions & 0 deletions crates/hypercolor-daemon/src/domain/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1393,6 +1393,27 @@ impl SceneMutation {
self.candidate.sync_primary_zone_layout(layout);
}

/// Re-align a newly active scene's primary zone with the active layout
/// when that zone follows it.
///
/// A scene keeps the primary zone it was created or last active with,
/// and activation alone moves only the priority stack. Without a custom
/// LED zone that copy goes stale as soon as the layout moves on, so a
/// scene switch re-aligns it the way a layout publication does for the
/// scene active at the time. A scene that names its own layout is
/// re-aligned once more when that layout is published after activation,
/// and a snapshot-locked scene keeps what it captured.
pub fn realign_primary_layout(&mut self, layout: &SpatialLayout) {
if self
.candidate
.active_scene()
.is_some_and(Scene::blocks_runtime_mutation)
{
return;
}
self.candidate.sync_primary_zone_layout(layout);
}

/// Restore a persisted scene without scheduling a redundant store write.
pub fn restore_scene(&mut self, scene: Scene) -> Result<(), DomainError> {
self.candidate.update(scene).map_err(|error| {
Expand Down Expand Up @@ -2504,6 +2525,7 @@ pub async fn activate_scene(
SceneChangeReason::UserActivate,
)?;
mutation.sync_active_display_surfaces(&display_surfaces);
mutation.realign_primary_layout(&ctx.layout.current());

let commit = ctx.scene.commit(mutation).await?;

Expand Down Expand Up @@ -3094,6 +3116,9 @@ pub async fn delete_scene(
let mut mutation = ctx.scene.begin_mutation().await;
let previous_scene_id = mutation.scenes().active_scene_id().copied();
let scene = mutation.delete_scene(&scene_id)?;
if is_active {
mutation.realign_primary_layout(&ctx.layout.current());
}
let current_scene = mutation.scenes().active_scene().cloned();

mutation.retire_scene_previews(scene_id);
Expand Down Expand Up @@ -3132,6 +3157,7 @@ pub async fn deactivate_scene(ctx: &SceneLibraryContext) -> Result<SceneDeactiva
let previous_scene = mutation.scenes().active_scene().cloned();
mutation.deactivate_current(SceneChangeReason::UserDeactivate);
mutation.sync_active_display_surfaces(&display_surfaces);
mutation.realign_primary_layout(&ctx.layout.current());
let current_scene = mutation.scenes().active_scene().cloned();

let commit = ctx.scene.commit(mutation).await?;
Expand Down
1 change: 1 addition & 0 deletions crates/hypercolor-daemon/src/domain/scene_activation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ pub async fn activate_selected_fields(
SceneChangeReason::UserActivate,
)?;
mutation.sync_active_display_surfaces(&display);
mutation.realign_primary_layout(&ctx.layout.current());
let admitted = mutation
.scenes()
.get(&expected.scene.id)
Expand Down
Loading
Loading