diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4af399c4..9b7a9500 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -23,12 +23,17 @@ Update it whenever you learn something new about the project's patterns, convent - Include complete state in events rather than deltas to provide full context to handlers. - Prefer grouping semantically paired values into a single parameter or type when they are always used together. - Use cohesive domain types as API boundaries when related values are expected to move together. +- When a domain struct already models paired values, prefer it over tuple payloads in change streams and method signatures. +- When a cohesive domain struct is the canonical state, prefer a single accessor returning that struct over parallel field-specific accessors. +- For layout APIs, prefer named transform+offset structs over tuple returns so ordering and intent stay explicit. +- For small paired-value structs, prefer constructor derives (for example `derive_more::Constructor`) and use constructors at call sites instead of field-literal repetition. - Prefer behavior-named capability methods on presenters/components over exposing raw mode enums to system-level callers. ## Safety & Quality - Avoid unsafe or experimental APIs unless required. - Preserve backwards compatibility unless instructed otherwise. - When refactoring, don't add trait implementations that weren't present; prefer deriving over manual implementation. +- For event transition summaries used by side effects, collect all relevant transition payloads rather than stopping at the first match. - Prefer proper platform-native solutions over UI-level workarounds or quick fixes. - Keep one source of truth for mutable state; avoid mirrored caches and route reads through narrow accessors. - For transient UI indicators (hover/focus highlights), derive visibility/target from current resolved state rather than only from enter/exit edge events. diff --git a/desktop/src/desktop_system.rs b/desktop/src/desktop_system.rs index bdb6fd0a..3e0d0424 100644 --- a/desktop/src/desktop_system.rs +++ b/desktop/src/desktop_system.rs @@ -26,8 +26,8 @@ use std::time::Duration; use massive_animation::Animated; use massive_applications::{InstanceId, ViewId}; -use massive_geometry::{PixelCamera, Rect, RectPx, SizePx}; -use massive_layout::{IncrementalLayouter, LayoutTopology}; +use massive_geometry::{PixelCamera, SizePx}; +use massive_layout::{IncrementalLayouter, LayoutTopology, Placement}; use massive_scene::{Location, Object, Transform}; use massive_shell::{FontManager, Scene}; @@ -78,10 +78,9 @@ pub struct DesktopSystem { event_router: EventRouter, camera: Animated, pointer_feedback_enabled: bool, - last_effects_focus: Option, #[debug(skip)] - layouter: IncrementalLayouter, + layouter: IncrementalLayouter, aggregates: Aggregates, } @@ -144,7 +143,6 @@ impl DesktopSystem { event_router, camera: scene.animated(PixelCamera::default()), pointer_feedback_enabled: true, - last_effects_focus: None, layouter, aggregates: Aggregates::new(OrderedHierarchy::default(), project_presenter), @@ -219,11 +217,8 @@ impl DesktopSystem { Ok(()) } - fn rect(&self, target: &DesktopTarget) -> Option { - self.layouter.rect(target).map(|rect| { - let rect_px: RectPx = (*rect).into(); - rect_px.into() - }) + fn placement(&self, target: &DesktopTarget) -> Option> { + self.layouter.placement(target).copied() } } diff --git a/desktop/src/desktop_system/event_forwarding.rs b/desktop/src/desktop_system/event_forwarding.rs index fa1a1bf0..f3838ac2 100644 --- a/desktop/src/desktop_system/event_forwarding.rs +++ b/desktop/src/desktop_system/event_forwarding.rs @@ -55,13 +55,7 @@ impl DesktopSystem { return Ok(Cmd::None); }; - // Need to translate the event. The view has its own coordinate system. - let event = if let Some(rect) = self.rect(&target) { - event.translate(-rect.origin()) - } else { - // This happens on startup on PresentView, because the layout isn't there yet. - event - }; + // Hit test already returns view-local coordinates. if let Err(e) = instance_manager.send_view_event((instance, view_id), event.clone()) { diff --git a/desktop/src/desktop_system/focus_input.rs b/desktop/src/desktop_system/focus_input.rs index ca47144e..c6e0511b 100644 --- a/desktop/src/desktop_system/focus_input.rs +++ b/desktop/src/desktop_system/focus_input.rs @@ -30,11 +30,11 @@ impl DesktopSystem { &self.aggregates.hierarchy, &self.layouter, &self.aggregates.launchers, - &self.aggregates.instances, render_geometry, ); let transitions = self.event_router.process(event, &hit_tester)?; + self.invalidate_layout_for_focus_change(transitions.keyboard_focus_change()); self.forward_event_transitions(transitions, instance_manager)? }; @@ -74,6 +74,7 @@ impl DesktopSystem { instance_manager: &InstanceManager, ) -> Result<()> { let transitions = self.event_router.focus(target); + self.invalidate_layout_for_focus_change(transitions.keyboard_focus_change()); // Invariant: Programmatic focus changes must not trigger commands. assert!( @@ -115,7 +116,6 @@ impl DesktopSystem { &self.aggregates.hierarchy, &self.layouter, &self.aggregates.launchers, - &self.aggregates.instances, render_geometry, ))?; diff --git a/desktop/src/desktop_system/layout_algorithm.rs b/desktop/src/desktop_system/layout_algorithm.rs index b7e93e6a..22731e82 100644 --- a/desktop/src/desktop_system/layout_algorithm.rs +++ b/desktop/src/desktop_system/layout_algorithm.rs @@ -1,74 +1,41 @@ use std::cmp::max; +use std::collections::HashMap; -use massive_geometry::SizePx; -use massive_layout::{LayoutAlgorithm, LayoutAxis, Offset, Size}; +use massive_geometry::{RectPx, SizePx, Transform, Vector3}; +use massive_layout::{ + LayoutAlgorithm, LayoutAxis, Offset, Rect as LayoutRect, Size, TransformOffset, +}; + +use massive_applications::InstanceId; use super::{Aggregates, DesktopTarget}; use crate::layout::{LayoutSpec, ToContainer}; +use crate::projects::LauncherInstanceLayoutInput; const SECTION_SPACING: u32 = 20; pub(super) struct DesktopLayoutAlgorithm<'a> { pub(super) aggregates: &'a Aggregates, pub(super) default_panel_size: SizePx, + pub(super) focused_instance: Option, } -impl DesktopLayoutAlgorithm<'_> { - fn resolve_layout_spec(&self, target: &DesktopTarget) -> LayoutSpec { - match target { - DesktopTarget::Desktop => LayoutAxis::VERTICAL - .to_container() - .spacing(SECTION_SPACING) - .into(), - DesktopTarget::Group(group_id) => self.aggregates.groups[group_id] - .properties - .layout - .axis() - .to_container() - .spacing(10) - .padding((10, 10)) - .into(), - DesktopTarget::Launcher(_) => { - if self.aggregates.hierarchy.get_nested(target).is_empty() { - self.default_panel_size.into() - } else { - LayoutAxis::HORIZONTAL.into() - } - } - DesktopTarget::Instance(instance) => { - let instance = &self.aggregates.instances[instance]; - if !instance.presents_primary_view() { - self.default_panel_size.into() - } else { - LayoutAxis::HORIZONTAL.into() - } - } - DesktopTarget::View(_) => self.default_panel_size.into(), +impl LayoutAlgorithm for DesktopLayoutAlgorithm<'_> { + fn place_children( + &self, + id: &DesktopTarget, + parent_offset: Offset<2>, + child_sizes: &[Size<2>], + ) -> Vec> { + if let DesktopTarget::Launcher(_) = id { + // Launcher panels run a dedicated path because transform assignment is + // a second phase over the regular 2D child placement. + return self.place_launcher_children(id, parent_offset, child_sizes); } - } -} - -pub(crate) fn place_container_children( - axis: LayoutAxis, - spacing: i32, - mut offset: Offset<2>, - child_sizes: &[Size<2>], -) -> Vec> { - let axis_index: usize = axis.into(); - let mut child_offsets = Vec::with_capacity(child_sizes.len()); - for (index, &child_size) in child_sizes.iter().enumerate() { - if index > 0 { - offset[axis_index] += spacing; - } - child_offsets.push(offset); - offset[axis_index] += child_size[axis_index] as i32; + self.place_standard_children(id, parent_offset, child_sizes) } - child_offsets -} - -impl LayoutAlgorithm for DesktopLayoutAlgorithm<'_> { fn measure(&self, id: &DesktopTarget, child_sizes: &[Size<2>]) -> Size<2> { if let DesktopTarget::Launcher(launcher_id) = id && let Some(size) = @@ -104,23 +71,80 @@ impl LayoutAlgorithm for DesktopLayoutAlgorithm<'_> { } } } +} - fn place_children( +impl DesktopLayoutAlgorithm<'_> { + fn place_launcher_children( &self, id: &DesktopTarget, parent_offset: Offset<2>, child_sizes: &[Size<2>], - ) -> Vec> { - if let DesktopTarget::Launcher(launcher_id) = id - && let Some(offsets) = self.aggregates.launchers[launcher_id].panel_child_offsets( - parent_offset, - child_sizes, - self.default_panel_size, - ) + ) -> Vec> { + let DesktopTarget::Launcher(launcher_id) = id else { + panic!("place_launcher_children requires a launcher target") + }; + + let launcher = &self.aggregates.launchers[launcher_id]; + + // Launchers can provide a custom 2D placement pass. If unavailable, + // we reuse the standard container algorithm to keep behavior consistent. + let child_placements = if let Some(placements) = + launcher.panel_child_offsets(parent_offset, child_sizes, self.default_panel_size) { - return offsets; - } + placements + } else { + self.place_standard_children(id, parent_offset, child_sizes) + }; + + let children = self.aggregates.hierarchy.get_nested(id); + // The launcher then upgrades instance transforms based on panel context + // (focus/depth/arrangement), while preserving offsets from the 2D pass. + let instance_inputs: Vec = children + .iter() + .zip(child_placements.iter().zip(child_sizes.iter())) + .filter_map(|(target, (child_transform_offset, size))| match target { + DesktopTarget::Instance(instance_id) => { + let rect_px: RectPx = + LayoutRect::new(child_transform_offset.offset, *size).into(); + Some(LauncherInstanceLayoutInput { + instance_id: *instance_id, + rect: rect_px, + }) + } + _ => None, + }) + .collect(); + + let layout_targets = + launcher.compute_instance_layout_targets(&instance_inputs, self.focused_instance); + + let mut transform_by_instance: HashMap = layout_targets + .into_iter() + .map(|target| (target.instance_id, target.layout_transform)) + .collect(); + + children + .iter() + .zip(child_placements) + .map(|(target, child_transform_offset)| { + let transform = match target { + DesktopTarget::Instance(instance_id) => transform_by_instance + .remove(instance_id) + .unwrap_or(child_transform_offset.transform), + _ => child_transform_offset.transform, + }; + TransformOffset::new(transform, child_transform_offset.offset) + }) + .collect() + } + + fn place_standard_children( + &self, + id: &DesktopTarget, + parent_offset: Offset<2>, + child_sizes: &[Size<2>], + ) -> Vec> { match self.resolve_layout_spec(id) { LayoutSpec::Leaf(_) => Vec::new(), LayoutSpec::Container { @@ -129,9 +153,64 @@ impl LayoutAlgorithm for DesktopLayoutAlgorithm<'_> { spacing, } => { let offset = parent_offset + Offset::from(padding.leading); - place_container_children(axis, spacing as i32, offset, child_sizes) } } } + + fn resolve_layout_spec(&self, target: &DesktopTarget) -> LayoutSpec { + match target { + DesktopTarget::Desktop => LayoutAxis::VERTICAL + .to_container() + .spacing(SECTION_SPACING) + .into(), + DesktopTarget::Group(group_id) => self.aggregates.groups[group_id] + .properties + .layout + .axis() + .to_container() + .spacing(10) + .padding((10, 10)) + .into(), + DesktopTarget::Launcher(_) => { + if self.aggregates.hierarchy.get_nested(target).is_empty() { + self.default_panel_size.into() + } else { + LayoutAxis::HORIZONTAL.into() + } + } + DesktopTarget::Instance(instance) => { + let instance = &self.aggregates.instances[instance]; + if !instance.presents_primary_view() { + self.default_panel_size.into() + } else { + LayoutAxis::HORIZONTAL.into() + } + } + DesktopTarget::View(_) => self.default_panel_size.into(), + } + } +} + +pub(crate) fn place_container_children( + axis: LayoutAxis, + spacing: i32, + mut offset: Offset<2>, + child_sizes: &[Size<2>], +) -> Vec> { + let axis_index: usize = axis.into(); + let mut child_placements = Vec::with_capacity(child_sizes.len()); + + for (index, &child_size) in child_sizes.iter().enumerate() { + if index > 0 { + offset[axis_index] += spacing; + } + let rect: RectPx = LayoutRect::new(offset, child_size).into(); + let center = rect.center().to_f64(); + let transform = Transform::from_translation(Vector3::new(center.x, center.y, 0.0)); + child_placements.push(TransformOffset::new(transform, offset)); + offset[axis_index] += child_size[axis_index] as i32; + } + + child_placements } diff --git a/desktop/src/desktop_system/layout_effects.rs b/desktop/src/desktop_system/layout_effects.rs index 5ffc23ae..85431e8d 100644 --- a/desktop/src/desktop_system/layout_effects.rs +++ b/desktop/src/desktop_system/layout_effects.rs @@ -1,16 +1,14 @@ -use std::collections::HashSet; - use anyhow::Result; use massive_animation::Interpolation; use massive_applications::InstanceId; -use massive_geometry::{PointPx, Rect, RectPx}; -use massive_layout::Rect as LayoutRect; +use massive_geometry::{PointPx, SizePx, Transform}; +use massive_layout::Placement; use super::{DesktopLayoutAlgorithm, DesktopSystem, DesktopTarget, TransactionEffectsMode}; use crate::focus_path::PathResolver; use crate::instance_presenter::STRUCTURAL_ANIMATION_DURATION; -use crate::projects::{LaunchProfileId, LauncherInstanceLayoutInput, LauncherInstanceLayoutTarget}; +use crate::projects::LaunchProfileId; impl DesktopSystem { /// Update layout changes and the camera position. @@ -21,10 +19,16 @@ impl DesktopSystem { None => (true, true), }; - // Layout & apply rects. + // Layout & apply rects + transforms. + let focused_instance = self + .aggregates + .hierarchy + .resolve_path(self.event_router.focused()) + .instance(); let algorithm = DesktopLayoutAlgorithm { aggregates: &self.aggregates, default_panel_size: self.default_panel_size, + focused_instance, }; let changed = self .layouter @@ -32,11 +36,6 @@ impl DesktopSystem { .changed; self.apply_layout_changes(changed, animate); - let from_focus = self.last_effects_focus.take(); - let to_focus = self.event_router.focused().cloned(); - self.apply_launcher_layout_for_focus_change(from_focus, to_focus.clone(), animate); - self.last_effects_focus = to_focus; - // Camera if permit_camera_moves && let Some(focused) = self.event_router.focused() { @@ -68,47 +67,42 @@ impl DesktopSystem { fn apply_layout_changes( &mut self, - changed: Vec<(DesktopTarget, LayoutRect<2>)>, + changed: Vec<(DesktopTarget, Placement)>, animate: bool, ) { - let mut launchers_to_relayout: HashSet = HashSet::new(); - - for (id, layout_rect) in changed { - let rect_px: RectPx = layout_rect.into(); - let rect: Rect = rect_px.into(); + for (id, placement) in changed { + let layout_size = placement.rect.size; + let size_px = SizePx::new(layout_size[0], layout_size[1]); + let transform = placement.transform; match id { DesktopTarget::Desktop => {} DesktopTarget::Instance(instance_id) => { - if let Some(launcher_id) = self.instance_launcher(instance_id) { - launchers_to_relayout.insert(launcher_id); - } + self.aggregates + .instances + .get_mut(&instance_id) + .expect("Instance missing") + .set_layout(size_px, transform, animate); } DesktopTarget::Group(group_id) => { self.aggregates .groups .get_mut(&group_id) .expect("Missing group") - .rect = rect; + .size = size_px; } DesktopTarget::Launcher(launcher_id) => { - launchers_to_relayout.insert(launcher_id); - self.aggregates .launchers .get_mut(&launcher_id) .expect("Launcher missing") - .set_rect(rect, animate); + .set_layout(size_px, transform, animate); } DesktopTarget::View(..) => { // Robustness: Support resize here? } } } - - for launcher_id in launchers_to_relayout { - self.apply_launcher_instance_layout(launcher_id, animate); - } } fn instance_launcher(&self, instance_id: InstanceId) -> Option { @@ -119,96 +113,22 @@ impl DesktopSystem { } } - fn apply_launcher_instance_layout(&mut self, launcher_id: LaunchProfileId, animate: bool) { - let launcher_target = DesktopTarget::Launcher(launcher_id); - let instance_inputs: Vec = self - .aggregates - .hierarchy - .get_nested(&launcher_target) - .iter() - .filter_map(|target| match target { - DesktopTarget::Instance(instance_id) => { - let instance_target = DesktopTarget::Instance(*instance_id); - let rect_px: RectPx = - (*self.layouter.rect(&instance_target).unwrap_or_else(|| { - panic!("Internal error: Missing layout rect for {instance_target:?}") - })) - .into(); - - Some(LauncherInstanceLayoutInput { - instance_id: *instance_id, - rect: rect_px, - }) - } - _ => None, - }) - .collect(); - - let focused_instance = self - .aggregates - .hierarchy - .resolve_path(self.event_router.focused()) - .instance(); - let layouts: Vec = self - .aggregates - .launchers - .get(&launcher_id) - .expect("Launcher missing") - .compute_instance_layout_targets(&instance_inputs, focused_instance); - - // Apply transform updates so presenter animations can interpolate to the new cylinder state. - for layout in layouts { - self.aggregates - .instances - .get_mut(&layout.instance_id) - .expect("Instance missing") - .set_layout(layout.rect, layout.layout_transform, animate); - } - } - - /// Recomputes instance layout transforms for launchers impacted by a keyboard focus change. - /// - /// This does not change panel geometry; it only updates per-instance layout targets - /// (for example visor cylinder yaw/translation) by rerunning - /// [`Self::apply_launcher_instance_layout`] for affected launchers. - /// - /// Behavior: - /// - If `from == to`, this is a no-op. - /// - Only launchers that own either the old or new focus target are considered. - /// - A launcher is updated only when `should_relayout_on_focus_change` says its current - /// mode/instance-count requires focus-driven relayout. - pub(super) fn apply_launcher_layout_for_focus_change( + /// Marks launchers that need relayout due to a keyboard focus change as reflow-pending. + pub(super) fn invalidate_layout_for_focus_change<'a>( &mut self, - from: Option, - to: Option, - animate: bool, + targets: impl IntoIterator, ) { - // Architecture: I don't like this before/after focus comparison test. - // No focus transition means there is no cylinder rotation target change. - if from == to { - return; - } - - // Update at most the launchers touched by the old/new focus targets. - let mut launchers_to_update: HashSet = HashSet::new(); - for target in [from.as_ref(), to.as_ref()] { + for target in targets { if let Some(launcher_id) = self.focus_target_launcher_for_layout(target) { - launchers_to_update.insert(launcher_id); + self.layouter + .mark_reflow_pending(DesktopTarget::Launcher(launcher_id)); } } - - // Recompute launcher transforms immediately so the focus move animates right away. - for launcher_id in launchers_to_update { - self.apply_launcher_instance_layout(launcher_id, animate); - } } - fn focus_target_launcher_for_layout( - &self, - target: Option<&DesktopTarget>, - ) -> Option { - // Resolve from any focus target (instance/view/etc.) to its owning instance. - let target = target?; + /// Returns the launcher that should be re-laid-out when focus moves to/from `target`, or + /// `None` if the target's launcher does not require focus-driven relayout. + fn focus_target_launcher_for_layout(&self, target: &DesktopTarget) -> Option { let focused_path = self.aggregates.hierarchy.resolve_path(Some(target)); let focused_instance = focused_path.instance()?; let launcher_id = self.instance_launcher(focused_instance)?; @@ -225,13 +145,10 @@ impl DesktopSystem { .map(|_| launcher_id) } - fn sync_hover_rect_to_pointer_path( - &mut self, - pointer_focus: Option<&DesktopTarget>, - ) { - let hover_rect = match pointer_focus { + fn sync_hover_rect_to_pointer_path(&mut self, pointer_focus: Option<&DesktopTarget>) { + let hover_placement = match pointer_focus { Some(DesktopTarget::Instance(instance_id)) => { - self.rect(&DesktopTarget::Instance(*instance_id)) + self.placement(&DesktopTarget::Instance(*instance_id)) } Some(DesktopTarget::View(view_id)) => match self .aggregates @@ -239,17 +156,19 @@ impl DesktopSystem { .parent(&DesktopTarget::View(*view_id)) { Some(DesktopTarget::Instance(instance_id)) => { - self.rect(&DesktopTarget::Instance(*instance_id)) + self.placement(&DesktopTarget::Instance(*instance_id)) } Some(_) => panic!("Internal error: View parent is not an instance"), None => None, }, Some(DesktopTarget::Launcher(launcher_id)) => { - self.rect(&DesktopTarget::Launcher(*launcher_id)) + self.placement(&DesktopTarget::Launcher(*launcher_id)) } _ => None, }; - self.aggregates.project_presenter.set_hover_rect(hover_rect); + self.aggregates + .project_presenter + .set_hover_placement(hover_placement); } } diff --git a/desktop/src/desktop_system/navigation.rs b/desktop/src/desktop_system/navigation.rs index 881bc234..2eeca6b5 100644 --- a/desktop/src/desktop_system/navigation.rs +++ b/desktop/src/desktop_system/navigation.rs @@ -1,6 +1,6 @@ use std::cmp::Ordering; -use massive_geometry::{PixelCamera, Point, Rect}; +use massive_geometry::{PixelCamera, Point, Rect, RectPx}; use massive_scene::{ToCamera, Transform}; use super::{DesktopSystem, DesktopTarget}; @@ -32,19 +32,22 @@ impl Direction { impl DesktopSystem { pub(super) fn camera_for_focus(&self, focus: &DesktopTarget) -> Option { match focus { - DesktopTarget::Desktop => self - .rect(&DesktopTarget::Desktop) - .map(|rect| rect.to_camera()), - DesktopTarget::Group(group) => { - Some(self.aggregates.groups[group].rect.center().to_camera()) + DesktopTarget::Desktop => { + let placement = self.placement(&DesktopTarget::Desktop)?; + let rect: RectPx = placement.rect.into(); + let rect: Rect = rect.into(); + let size = rect.size(); + // The Desktop is the layout root — its transform is T::default() (IDENTITY), + // not center-based. Compute the center from the rect. + let center = rect.center(); + let center: Transform = (center.x, center.y, 0.0).into(); + Some(center.to_camera().with_size(size)) + } + DesktopTarget::Group(_) | DesktopTarget::Launcher(_) => { + let transform = self.layouter.placement(focus)?.transform; + let camera_transform: Transform = transform.translate.into(); + Some(camera_transform.to_camera()) } - DesktopTarget::Launcher(launcher) => Some( - self.aggregates.launchers[launcher] - .rect - .final_value() - .center() - .to_camera(), - ), DesktopTarget::Instance(instance_id) => { let instance = &self.aggregates.instances[instance_id]; let transform: Transform = instance @@ -72,7 +75,8 @@ impl DesktopSystem { return None; } - let from_rect = self.rect(from)?; + let from_transform = self.layouter.placement(from)?.transform; + let from_center = Point::new(from_transform.translate.x, from_transform.translate.y); let launcher_targets_without_instances = self .aggregates .launchers @@ -88,27 +92,29 @@ impl DesktopSystem { }); let navigation_candidates = launcher_targets_without_instances .chain(all_instances_or_views) - .filter_map(|target| self.rect(&target).map(|rect| (target, rect))); + .filter_map(|target| { + let t = self.layouter.placement(&target)?.transform; + let center = Point::new(t.translate.x, t.translate.y); + Some((target, center)) + }); - let ordered = - ordered_rects_in_direction(from_rect.center(), direction, navigation_candidates); - if let Some((nearest, _rect)) = ordered.first() { + let ordered = ordered_points_in_direction(from_center, direction, navigation_candidates); + if let Some((nearest, _distance)) = ordered.first() { return Some(nearest.clone()); } None } } -pub(super) fn ordered_rects_in_direction( +pub(super) fn ordered_points_in_direction( center: Point, direction: Direction, - rects: impl Iterator, + candidates: impl Iterator, ) -> Vec<(K, f64)> { - let mut results: Vec<(K, f64)> = rects - .filter_map(|(key, rect)| { - let rect_center = rect.center(); - direction.is_visible(center, rect_center).then(|| { - let distance = (rect_center - center).length(); + let mut results: Vec<(K, f64)> = candidates + .filter_map(|(key, candidate_center)| { + direction.is_visible(center, candidate_center).then(|| { + let distance = (candidate_center - center).length(); (key, distance) }) }) diff --git a/desktop/src/desktop_system/project_commands.rs b/desktop/src/desktop_system/project_commands.rs index 8e36ca83..126bb659 100644 --- a/desktop/src/desktop_system/project_commands.rs +++ b/desktop/src/desktop_system/project_commands.rs @@ -33,7 +33,7 @@ impl DesktopSystem { self.aggregates.project_presenter.location.clone(), id, profile, - massive_geometry::Rect::default(), + massive_geometry::Size::default(), scene, &mut self.fonts.lock(), ); diff --git a/desktop/src/event_router.rs b/desktop/src/event_router.rs index a6cad128..38265776 100644 --- a/desktop/src/event_router.rs +++ b/desktop/src/event_router.rs @@ -370,18 +370,21 @@ impl Default for EventTransitions { } impl EventTransitions { - pub fn keyboard_focus_change(&self) -> Option<(Option<&T>, Option<&T>)> { - self.0.iter().find_map(|transition| match transition { - EventTransition::ChangeKeyboardFocus { from, to } => Some((from.as_ref(), to.as_ref())), - _ => None, - }) - } + pub fn keyboard_focus_change(&self) -> Vec<&T> { + let mut touched = Vec::new(); + + for transition in &self.0 { + if let EventTransition::ChangeKeyboardFocus { from, to } = transition { + if let Some(from) = from.as_ref() { + touched.push(from); + } + if let Some(to) = to.as_ref() { + touched.push(to); + } + } + } - pub fn pointer_focus_target(&self) -> Option> { - self.0.iter().find_map(|transition| match transition { - EventTransition::ChangePointerFocus { to, .. } => Some(to.as_ref()), - _ => None, - }) + touched } fn send(&mut self, target: &T, event: ViewEvent) diff --git a/desktop/src/hit_tester.rs b/desktop/src/hit_tester.rs index c5acc336..13800b7b 100644 --- a/desktop/src/hit_tester.rs +++ b/desktop/src/hit_tester.rs @@ -1,18 +1,16 @@ -use massive_applications::InstanceId; -use massive_geometry::{Contains, Point, Rect, RectPx, Size, Vector3}; -use massive_layout::IncrementalLayouter; +use massive_geometry::{ + Contains, PerspectiveDivide, Point, Rect, RectPx, Size, Transform, Vector3, Vector4, +}; +use massive_layout::{IncrementalLayouter, Placement, Rect as LayoutRect}; use massive_renderer::RenderGeometry; -use massive_scene::Transform; -use crate::instance_presenter::InstancePresenter; use crate::projects::{LaunchProfileId, LauncherPresenter}; use crate::{DesktopTarget, HitTester, Map, OrderedHierarchy}; pub(crate) struct AggregateHitTester<'a> { hierarchy: &'a OrderedHierarchy, - layouter: &'a IncrementalLayouter, + layouter: &'a IncrementalLayouter, launchers: &'a Map, - instances: &'a Map, geometry: &'a RenderGeometry, } @@ -20,14 +18,13 @@ pub(crate) struct AggregateHitTester<'a> { struct HitSurface { transform: Transform, size: Size, - surface_z: f64, } #[derive(Debug)] pub struct HitTestResult { pub target: DesktopTarget, pub local_pos: Vector3, - pub surface_z: f64, + pub surface_depth: f64, } impl HitTester for AggregateHitTester<'_> { @@ -50,16 +47,14 @@ impl HitTester for AggregateHitTester<'_> { impl<'a> AggregateHitTester<'a> { pub fn new( hierarchy: &'a OrderedHierarchy, - layouter: &'a IncrementalLayouter, + layouter: &'a IncrementalLayouter, launchers: &'a Map, - instances: &'a Map, geometry: &'a RenderGeometry, ) -> Self { Self { hierarchy, layouter, launchers, - instances, geometry, } } @@ -74,11 +69,13 @@ impl<'a> AggregateHitTester<'a> { let overlay_hit = self.hit_test_overflow_overlays_with_depth(screen_pos); match (regular_hit, overlay_hit) { - (Some(regular), Some(overlay)) => Some(if overlay.surface_z > regular.surface_z { - overlay - } else { - regular - }), + (Some(regular), Some(overlay)) => { + Some(if overlay.surface_depth < regular.surface_depth { + overlay + } else { + regular + }) + } (Some(regular), None) => Some(regular), (None, Some(overlay)) => Some(overlay), (None, None) => None, @@ -103,7 +100,7 @@ impl<'a> AggregateHitTester<'a> { self.hit_test_hierarchy_with_depth(screen_pos, nested, true) && topmost_hit .as_ref() - .is_none_or(|topmost| target_hit.surface_z > topmost.surface_z) + .is_none_or(|topmost| target_hit.surface_depth < topmost.surface_depth) { topmost_hit = Some(target_hit); } @@ -121,6 +118,8 @@ impl<'a> AggregateHitTester<'a> { ) -> Option { let hit_surface = self.resolve_hit_surface(root)?; let local_pos = self.hit_test_surface(screen_pos, &hit_surface)?; + let hit_world_pos = hit_surface.transform.transform_point(local_pos); + let hit_depth = self.hit_depth(hit_world_pos); let is_inside_root = hit_surface .size .to_rect() @@ -133,7 +132,7 @@ impl<'a> AggregateHitTester<'a> { self.hit_test_hierarchy_with_depth(screen_pos, nested, allow_overflow_children) && nearest_nested_hit .as_ref() - .is_none_or(|nearest| target_hit.surface_z > nearest.surface_z) + .is_none_or(|nearest| target_hit.surface_depth < nearest.surface_depth) { nearest_nested_hit = Some(target_hit); } @@ -149,7 +148,7 @@ impl<'a> AggregateHitTester<'a> { return Some(HitTestResult { target: root.clone(), local_pos, - surface_z: hit_surface.surface_z, + surface_depth: hit_depth, }); } @@ -157,36 +156,12 @@ impl<'a> AggregateHitTester<'a> { } fn resolve_hit_surface(&self, target: &DesktopTarget) -> Option { - let rect = self.layouter.rect(target).map(|rect| { - let rect_px: RectPx = (*rect).into(); - Rect::from(rect_px) - })?; - - let model = self.hit_test_transform(target, rect); - let surface_z = self.hit_surface_z(target, &model); - - Some(HitSurface { - transform: model, - size: rect.size(), - surface_z, - }) - } - - fn hit_surface_z(&self, target: &DesktopTarget, model: &Transform) -> f64 { - if let Some(instance_id) = self.hit_target_instance_id(target) { - return self - .instances - .get(&instance_id) - .expect("Internal error: Missing instance presenter for hit test depth") - .layout_transform_animation - // Keep hit depth stable across short structural animations. - // We intentionally pick against the settled layout target for now. - .final_value() - .translate - .z; - } + let placement = *self.layouter.placement(target)?; + let rect_px: RectPx = placement.rect.into(); + let size = Rect::from(rect_px).size(); - model.translate.z + let transform = self.hit_test_transform(target, placement); + Some(HitSurface { transform, size }) } fn hit_test_surface(&self, screen_pos: Point, hit_surface: &HitSurface) -> Option { @@ -194,39 +169,70 @@ impl<'a> AggregateHitTester<'a> { .unproject_to_model_z0(screen_pos, &hit_surface.transform.to_matrix4()) } - fn hit_test_transform(&self, target: &DesktopTarget, rect: Rect) -> Transform { - if let Some(instance_id) = self.hit_target_instance_id(target) { - // InstancePresenter::transform expects a local center (panel-local coordinates), not - // the global layout center. - let local_center = rect.size().to_rect().center(); - return self - .instances - .get(&instance_id) - .expect("Internal error: Missing instance presenter for hit test") - .transform(local_center); + /// Returns a transform whose model space is the target's local coordinate system. + /// Unprojecting through this transform yields target-local coordinates. + fn hit_test_transform( + &self, + target: &DesktopTarget, + placement: Placement, + ) -> Transform { + let rect_px: RectPx = placement.rect.into(); + let local_center = Rect::from(rect_px).size().to_rect().center(); + + // The Desktop is the layout root — its transform is T::default() (IDENTITY), not + // center-based. Derive its origin from the rect offset directly. + if let DesktopTarget::Desktop = target { + let offset = placement.rect.offset; + return Transform::from_translation((offset[0] as f64, offset[1] as f64, 0.0)); + } + + // For View targets, resolve through the parent instance and apply the view's offset + // within the instance so that unprojection returns view-local coordinates. + if let DesktopTarget::View(_) = target { + let instance_id = match self.hierarchy.parent(target) { + Some(DesktopTarget::Instance(id)) => *id, + Some(_) => panic!("Internal error: View parent is not an instance in hit test"), + None => panic!("Internal error: View without parent in hit test"), + }; + let instance_target = DesktopTarget::Instance(instance_id); + let instance_placement = self + .layouter + .placement(&instance_target) + .expect("Internal error: Missing instance placement in hit test"); + + let instance_center = Self::layout_rect_center(instance_placement.rect); + let view_center = Self::layout_rect_center(placement.rect); + let view_offset = view_center - instance_center; + + let mut layout_transform = instance_placement.transform; + layout_transform.translate += layout_transform.rotate * view_offset; + return Self::transform_with_layout(layout_transform, local_center); } - let origin = rect.origin(); - Transform::from_translation((origin.x, origin.y, 0.0)) + Self::transform_with_layout(placement.transform, local_center) } - fn hit_target_instance_id(&self, target: &DesktopTarget) -> Option { - Some(match target { - DesktopTarget::Instance(instance_id) => *instance_id, - DesktopTarget::View(_) => { - let parent = self - .hierarchy - .parent(target) - .expect("Internal error: View without parent in hit test"); - - match parent { - DesktopTarget::Instance(instance_id) => *instance_id, - _ => panic!("Internal error: View parent is not an instance in hit test"), - } - } - DesktopTarget::Desktop | DesktopTarget::Group(_) | DesktopTarget::Launcher(_) => { - return None; - } - }) + fn transform_with_layout(layout_transform: Transform, local_center: Point) -> Transform { + let local_center = Vector3::new(local_center.x, local_center.y, 0.0); + let origin_translation = + layout_transform.translate - layout_transform.rotate * local_center; + Transform::new( + origin_translation, + layout_transform.rotate, + layout_transform.scale, + ) + } + + fn hit_depth(&self, world_pos: Vector3) -> f64 { + let vp = self.geometry.view_projection(); + let clip = vp * Vector4::new(world_pos.x, world_pos.y, world_pos.z, 1.0); + clip.perspective_divide().map_or(f64::INFINITY, |ndc| ndc.z) + } + + fn layout_rect_center(rect: LayoutRect<2>) -> Vector3 { + let rect_px: RectPx = rect.into(); + let rect: Rect = rect_px.into(); + let center = rect.center(); + Vector3::new(center.x, center.y, 0.0) } } diff --git a/desktop/src/instance_presenter.rs b/desktop/src/instance_presenter.rs index 946f2828..e2e263c1 100644 --- a/desktop/src/instance_presenter.rs +++ b/desktop/src/instance_presenter.rs @@ -2,7 +2,7 @@ use std::time::Duration; use massive_animation::{Animated, Interpolation}; use massive_applications::ViewCreationInfo; -use massive_geometry::{Color, Point, Rect, RectPx, Transform, Vector3}; +use massive_geometry::{Color, Point, Rect, SizePx, Transform, Vector3}; use massive_scene::{At, Handle, Location, Object, ToLocation, Visual}; use massive_shapes::{self as shapes, Shape}; use massive_shell::Scene; @@ -72,10 +72,9 @@ impl InstancePresenter { self.state.view().is_some() } - pub fn set_layout(&mut self, rect: RectPx, layout_transform: Transform, animate: bool) { + pub fn set_layout(&mut self, size: SizePx, layout_transform: Transform, animate: bool) { if let Some(background) = &mut self.background { - let rect: Rect = rect.into(); - background.local_rect = rect.size().to_rect(); + background.local_rect = Rect::from_size((size.width as f64, size.height as f64)); background.visual.update_with_if_changed(|visual| { let local_rect = background.local_rect; visual.shapes = [background_shape(local_rect)].into(); @@ -102,10 +101,7 @@ impl InstancePresenter { let local_center = background.local_rect.center(); background .transform - .update_if_changed(Self::transform_with_local_center( - layout_transform, - (local_center.x, local_center.y), - )); + .update_if_changed(Self::transform_with_layout(layout_transform, local_center)); } // Feature: Hiding animation. @@ -116,7 +112,8 @@ impl InstancePresenter { // Correct the view's position around its local center. // Since the centering uses i32, we preserve snapping behavior from the layouter. let center = view.creation_info.extents.center().to_f64(); - let transform = Self::transform_with_local_center(layout_transform, (center.x, center.y)); + let transform = + Self::transform_with_layout(layout_transform, Point::new(center.x, center.y)); view.creation_info .location @@ -125,16 +122,8 @@ impl InstancePresenter { .update_if_changed(transform); } - pub fn transform(&self, local_center: Point) -> Transform { - let layout_transform = self.layout_transform_animation.final_value(); - Self::transform_with_local_center(layout_transform, (local_center.x, local_center.y)) - } - - fn transform_with_local_center( - layout_transform: Transform, - local_center: (f64, f64), - ) -> Transform { - let local_center = Vector3::new(local_center.0, local_center.1, 0.0); + pub fn transform_with_layout(layout_transform: Transform, local_center: Point) -> Transform { + let local_center = Vector3::new(local_center.x, local_center.y, 0.0); let origin_translation = layout_transform.translate - layout_transform.rotate * local_center; Transform::new( diff --git a/desktop/src/projects/launcher_presenter.rs b/desktop/src/projects/launcher_presenter.rs index 74033787..3c347113 100644 --- a/desktop/src/projects/launcher_presenter.rs +++ b/desktop/src/projects/launcher_presenter.rs @@ -6,16 +6,17 @@ use winit::keyboard::{Key, NamedKey}; use massive_animation::{Animated, Interpolation}; use massive_applications::{InstanceId, ViewEvent}; -use massive_geometry::{Color, Quaternion, Rect, RectPx, SizePx, Vector3}; +use massive_geometry::{Color, Quaternion, Rect, RectPx, Size, SizePx, Vector3}; use massive_input::EventManager; -use massive_layout::{LayoutAxis, Offset, Size as LayoutSize}; +use massive_layout::{LayoutAxis, Offset, Size as LayoutSize, TransformOffset}; use massive_renderer::text::FontSystem; -use massive_scene::{At, Handle, Location, Object, ToLocation, ToTransform, Transform, Visual}; -use massive_shapes::{self as shapes, IntoShape, Shape, Size}; +use massive_scene::{At, Handle, Location, Object, ToLocation, Transform, Visual}; +use massive_shapes::{self as shapes, IntoShape, Shape, Size as SizeExt}; use massive_shell::Scene; use super::visor_layout; use crate::desktop_system::{Cmd, DesktopCommand, place_container_children}; +use crate::instance_presenter::InstancePresenter; use crate::projects::LaunchProfileId; use super::configuration::{LaunchProfile, LauncherMode}; @@ -40,7 +41,6 @@ pub struct LauncherInstanceLayoutInput { #[derive(Debug, Clone, Copy)] pub struct LauncherInstanceLayoutTarget { pub instance_id: InstanceId, - pub rect: RectPx, pub layout_transform: Transform, } @@ -50,9 +50,10 @@ pub struct LauncherPresenter { id: LaunchProfileId, profile: LaunchProfile, mode: LauncherMode, - transform: Handle, + layout_transform: Transform, + scene_transform: Handle, - pub rect: Animated, + pub size: Animated, background: Handle, // The text, either centered, or on top of the border. name: Handle, @@ -70,15 +71,15 @@ impl LauncherPresenter { parent_location: Handle, id: LaunchProfileId, profile: LaunchProfile, - rect: Rect, + size: Size, scene: &Scene, font_system: &mut FontSystem, ) -> Self { // Ergonomics: I want this to look like rect.as_shape().with_color(Color::WHITE); - let background_shape = background_shape(rect.size().to_rect(), BACKGROUND_COLOR); + let background_shape = background_shape(size.to_rect(), BACKGROUND_COLOR); let mode = profile.mode; - let our_transform = rect.origin().to_transform().enter(scene); + let our_transform = Transform::IDENTITY.enter(scene); let our_location = our_transform .to_location() @@ -109,8 +110,9 @@ impl LauncherPresenter { id, profile, mode, - transform: our_transform, - rect: scene.animated(rect), + layout_transform: Transform::IDENTITY, + scene_transform: our_transform, + size: scene.animated(size), background, name, fader: scene.animated(1.0), @@ -150,7 +152,7 @@ impl LauncherPresenter { parent_offset: Offset<2>, child_sizes: &[LayoutSize<2>], default_panel_size: SizePx, - ) -> Option>> { + ) -> Option>> { match self.mode { LauncherMode::Band => None, LauncherMode::Visor => Some(centered_horizontal_offsets( @@ -215,7 +217,6 @@ impl LauncherPresenter { LauncherInstanceLayoutTarget { instance_id: input.instance_id, - rect: input.rect, layout_transform, } }) @@ -234,7 +235,6 @@ impl LauncherPresenter { LauncherInstanceLayoutTarget { instance_id: input.instance_id, - rect: input.rect, layout_transform: Transform::from_translation(center_translation), } }) @@ -284,15 +284,17 @@ impl LauncherPresenter { self.fader.final_value() == 0.0 } - pub fn set_rect(&mut self, rect: Rect, animate: bool) { + pub fn set_layout(&mut self, size: SizePx, layout_transform: Transform, animate: bool) { + self.layout_transform = layout_transform; + let size = Size::new(size.width as f64, size.height as f64); if animate { - self.rect.animate_if_changed( - rect, + self.size.animate_if_changed( + size, STRUCTURAL_ANIMATION_DURATION, Interpolation::CubicOut, ); } else { - self.rect.set_immediately(rect); + self.size.set_immediately(size); self.apply_animations(); } } @@ -308,13 +310,16 @@ impl LauncherPresenter { } pub fn apply_animations(&mut self) { - let (origin, size) = self.rect.value().origin_and_size(); + let size = self.size.value(); + let local_center = size.to_rect().center(); - self.transform.update_if_changed(origin.with_z(0.0).into()); + let scene_transform = + InstancePresenter::transform_with_layout(self.layout_transform, local_center); + self.scene_transform.update_if_changed(scene_transform); let alpha = self.fader.value(); - // Performance: How can we not call this if self.rect and self.fader are both not animating. + // Performance: How can we not call this if self.size and self.fader are both not animating. // `is_animating()` is perhaps not reliable. self.background.update_with_if_changed(|visual| { visual.shapes = [background_shape( @@ -346,7 +351,7 @@ fn centered_horizontal_offsets( parent_offset: Offset<2>, child_sizes: &[LayoutSize<2>], panel_width: i32, -) -> Vec> { +) -> Vec> { let spacing = 0i32; let children_span: i32 = child_sizes.iter().map(|size| size[0] as i32).sum::() + spacing * (child_sizes.len().saturating_sub(1) as i32); diff --git a/desktop/src/projects/mod.rs b/desktop/src/projects/mod.rs index d4888a2b..ca619b61 100644 --- a/desktop/src/projects/mod.rs +++ b/desktop/src/projects/mod.rs @@ -10,9 +10,7 @@ mod project_presenter; mod visor_layout; pub use self::configuration::*; -pub use self::launcher_presenter::{ - LauncherInstanceLayoutInput, LauncherInstanceLayoutTarget, LauncherPresenter, -}; +pub use self::launcher_presenter::{LauncherInstanceLayoutInput, LauncherPresenter}; pub use self::project::*; pub use self::project_presenter::*; diff --git a/desktop/src/projects/project_presenter.rs b/desktop/src/projects/project_presenter.rs index a41d3b74..ab0422ca 100644 --- a/desktop/src/projects/project_presenter.rs +++ b/desktop/src/projects/project_presenter.rs @@ -1,12 +1,14 @@ use std::{sync::Arc, time::Duration}; use massive_animation::{Animated, Interpolation}; -use massive_geometry::{Color, Rect}; +use massive_geometry::{Color, Point, Rect, SizePx, Transform}; +use massive_layout::{Placement, Rect as LayoutRect}; use massive_scene::{Handle, IntoVisual, Location, Object, Visual}; use massive_shapes::{Shape, StrokeRect}; use massive_shell::Scene; use super::LaunchGroupProperties; +use crate::instance_presenter::InstancePresenter; #[derive(Debug)] pub struct ProjectPresenter { @@ -21,7 +23,9 @@ pub struct ProjectPresenter { // Idea: Use a type that combines Alpha with another Interpolatable type. // Robustness: Alpha should be a type. hover_alpha: Animated, - hover_rect: Rect, + hover_placement: Placement, + hover_scene_transform: Handle, + hover_location: Handle, // Idea: can't we just animate a visual / Handle? // Performance: This is a visual that _always_ lives inside the renderer, even though it does not contain a single shape when alpha = 0.0 hover_visual: Handle, @@ -31,29 +35,33 @@ impl ProjectPresenter { const HOVER_STROKE: (f64, f64) = (10.0, 10.0); pub fn new(location: Handle, scene: &Scene) -> Self { + let hover_scene_transform = Transform::IDENTITY.enter(scene); + let hover_location = Location::new(None, hover_scene_transform.clone()).enter(scene); + Self { location: location.clone(), hover_alpha: scene.animated(0.0), - hover_rect: Rect::ZERO, + hover_placement: Placement::new(Transform::IDENTITY, LayoutRect::EMPTY), + hover_scene_transform, + hover_location: hover_location.clone(), hover_visual: create_hover_shapes(None) .into_visual() - .at(location) + .at(hover_location) .enter(scene), } } const HOVER_ANIMATION_DURATION: Duration = Duration::from_millis(250); - pub fn set_hover_rect(&mut self, rect: Option) { - match rect { - Some(rect) => { + pub fn set_hover_placement(&mut self, placement: Option>) { + match placement { + Some(placement) => { self.hover_alpha.animate_if_changed( 1.0, Self::HOVER_ANIMATION_DURATION, Interpolation::CubicOut, ); - - self.hover_rect = rect; + self.hover_placement = placement; } None => { self.hover_alpha.animate_if_changed( @@ -67,14 +75,27 @@ impl ProjectPresenter { pub fn apply_animations(&mut self) { let alpha = self.hover_alpha.value(); - let rect_alpha = (alpha != 0.0).then_some((self.hover_rect, alpha)); + let hover_placement = self.hover_placement; + + let size = hover_placement.rect.size; + let local_rect = Rect::from_size((size[0] as f64, size[1] as f64)); + let rect_alpha = (alpha != 0.0).then_some((local_rect, alpha)); + + // Position the hover visual in world space using the placement's center-based transform. + let local_center = local_rect.center(); + let scene_transform = InstancePresenter::transform_with_layout( + hover_placement.transform, + Point::new(local_center.x, local_center.y), + ); + self.hover_scene_transform + .update_if_changed(scene_transform); // Ergonomics: What something like apply_to_if_changed(&mut self.hover_visual) or so? // // Performance: Can't be update just the shapes here with apply... let visual = create_hover_shapes(rect_alpha) .into_visual() - .at(&self.location) + .at(&self.hover_location) .with_decal_order(5); self.hover_visual.update_if_changed(visual); } @@ -98,14 +119,14 @@ fn create_hover_shapes(rect_alpha: Option<(Rect, f32)>) -> Arc<[Shape]> { #[derive(Debug)] pub struct GroupPresenter { pub properties: LaunchGroupProperties, - pub rect: Rect, + pub size: SizePx, } impl GroupPresenter { pub fn new(properties: LaunchGroupProperties) -> Self { Self { properties, - rect: Rect::default(), + size: SizePx::default(), } } } diff --git a/layout/src/incremental_layouter.rs b/layout/src/incremental_layouter.rs index 0940b01a..e91cec8b 100644 --- a/layout/src/incremental_layouter.rs +++ b/layout/src/incremental_layouter.rs @@ -3,31 +3,41 @@ //! Uses a caller-provided `LayoutTopology` and `LayoutAlgorithm` to produce absolute `Rect` //! values for all nodes keyed by stable `Id`s. Callers must provide a consistent, acyclic tree //! topology and reuse the same `Id`s across updates for incremental behavior to be valid. - -//! Developed together with Codex 5.3 and Claude Sonnet 4.6. use std::collections::hash_map::Entry; use std::collections::{HashMap, HashSet}; +use std::fmt::Debug; use std::hash::Hash; +use derive_more::Constructor; + use crate::dimensional_types::{Offset, Rect, Size}; -#[cfg(test)] -use crate::LayoutAxis; -#[cfg(test)] -use crate::dimensional_types::Thickness; +#[derive(Constructor, Debug, Clone, Copy, PartialEq)] +pub struct Placement { + pub transform: T, + pub rect: Rect, +} -pub struct IncrementalLayouter +#[derive(Constructor, Debug, Clone, Copy, PartialEq)] +pub struct TransformOffset { + pub transform: T, + pub offset: Offset, +} + +pub struct IncrementalLayouter where Id: Eq + Hash + Clone, + T: Debug + Copy + PartialEq + Default, { nodes: HashMap>, - rects: HashMap>, + placements: HashMap>, reflow_pending: HashSet, } -impl Default for IncrementalLayouter +impl Default for IncrementalLayouter where Id: Eq + Hash + Clone, + T: Debug + Copy + PartialEq + Default, { fn default() -> Self { Self::new() @@ -45,29 +55,34 @@ where fn parent_of(&self, id: &Id) -> Option; } -pub trait LayoutAlgorithm +pub trait LayoutAlgorithm where Id: Eq + Hash + Clone, + T: Debug + Copy + PartialEq + Default, { /// Returns the outer size of `id` given its children's already-measured outer sizes. /// Called in post-order (all children measured before parent). /// For leaf nodes `child_sizes` is empty. fn measure(&self, id: &Id, child_sizes: &[Size]) -> Size; - /// Returns one absolute child offset per entry in `child_sizes`, in the same order. + /// Returns one child transform+offset per entry in `child_sizes`, in the same order. /// `parent_offset` is the absolute position of `id`. /// Only called for non-leaf nodes (i.e. when the node has children). + /// + /// The transform positions each child in world space. For flat 2D layouts, return + /// `T::default()`. fn place_children( &self, id: &Id, parent_offset: Offset, child_sizes: &[Size], - ) -> Vec>; + ) -> Vec>; } -impl IncrementalLayouter +impl IncrementalLayouter where Id: Eq + Hash + Clone, + T: Debug + Copy + PartialEq + Default, { fn invariant_violation(message: &str) -> ! { panic!("Internal error: {message}") @@ -76,7 +91,7 @@ where pub fn new() -> Self { Self { nodes: HashMap::new(), - rects: HashMap::new(), + placements: HashMap::new(), reflow_pending: HashSet::new(), } } @@ -102,7 +117,7 @@ where self.reflow_pending.insert(id); } - /// Recomputes layout incrementally and returns changed rectangles. + /// Recomputes layout incrementally and returns changed placements. /// /// Reflow changes are typically sparse, so recompute first builds an affected closure /// (pending nodes + ancestors) and starts only from top-most affected nodes. @@ -124,9 +139,9 @@ where pub fn recompute( &mut self, topology: &impl LayoutTopology, - algorithm: &impl LayoutAlgorithm, + algorithm: &impl LayoutAlgorithm, absolute_offset: impl Into>, - ) -> RecomputeResult { + ) -> RecomputeResult { let mut changed = Vec::new(); let offset = absolute_offset.into(); @@ -145,7 +160,7 @@ where self.place_subtree_recursive( algorithm, &affected_root, - root_offset, + TransformOffset::new(T::default(), root_offset), &affected, &mut changed, ); @@ -154,8 +169,8 @@ where RecomputeResult { changed } } - pub fn rect(&self, id: &Id) -> Option<&Rect> { - self.rects.get(id) + pub fn placement(&self, id: &Id) -> Option<&Placement> { + self.placements.get(id) } /// Decides whether the current generation should traverse into `child`. @@ -164,7 +179,7 @@ where /// needed for affected children or missing cache entries. fn should_walk_child(&self, child: &Id, affected: &HashSet) -> bool { // Traverse only when data is stale/missing; otherwise placement can reuse cached branch. - affected.contains(child) || !self.rects.contains_key(child) + affected.contains(child) || !self.placements.contains_key(child) } /// Reads the authoritative outer size for `id`. @@ -186,7 +201,7 @@ where self.evict_cached_subtree(&child); } } - self.rects.remove(id); + self.placements.remove(id); } /// Recursive pass 1: measure affected subtree sizes bottom-up. @@ -196,7 +211,7 @@ where /// Sibling order is not semantically important for current size math (sum/max). fn measure_subtree_recursive( &mut self, - algorithm: &impl LayoutAlgorithm, + algorithm: &impl LayoutAlgorithm, id: &Id, affected: &HashSet, ) { @@ -234,14 +249,18 @@ where /// - clean cached children are offset-shifted when needed. fn place_subtree_recursive( &mut self, - algorithm: &impl LayoutAlgorithm, + algorithm: &impl LayoutAlgorithm, id: &Id, - absolute_offset: Offset, + transform_offset: TransformOffset, affected: &HashSet, - changed: &mut Vec<(Id, Rect)>, + changed: &mut Vec<(Id, Placement)>, ) { let outer_size = self.cached_outer_size(id); - self.update_rect(id, Rect::new(absolute_offset, outer_size), changed); + let next = Placement::new( + transform_offset.transform, + Rect::new(transform_offset.offset, outer_size), + ); + self.update_placement(id, next, changed); let cached_children = self .nodes @@ -257,25 +276,44 @@ where .iter() .map(|child| self.cached_outer_size(child)) .collect(); - let child_offsets = algorithm.place_children(id, absolute_offset, &child_sizes); - if child_offsets.len() != cached_children.len() { + let child_transform_offsets = + algorithm.place_children(id, transform_offset.offset, &child_sizes); + if child_transform_offsets.len() != cached_children.len() { Self::invariant_violation( "layout algorithm returned a different number of child offsets than children", ); } - for (child, child_offset) in cached_children.iter().zip(child_offsets.iter()) { + for (child, child_transform_offset) in + cached_children.iter().zip(child_transform_offsets.iter()) + { if self.should_walk_child(child, affected) { - self.place_subtree_recursive(algorithm, child, *child_offset, affected, changed); + self.place_subtree_recursive( + algorithm, + child, + *child_transform_offset, + affected, + changed, + ); } else { - // Clean child: translate cached subtree if parent offset changed. - let previous_rect = self.rects.get(child).copied().unwrap_or_else(|| { - Self::invariant_violation("clean child missing rect during placement") + // Clean child: apply offset shift and/or transform update. + let previous = self.placements.get(child).copied().unwrap_or_else(|| { + Self::invariant_violation("clean child missing placement during placement") }); - if previous_rect.offset != *child_offset { - let offset_delta = Self::offset_delta(*child_offset, previous_rect.offset); + let offset_changed = previous.rect.offset != child_transform_offset.offset; + let transform_changed = previous.transform != child_transform_offset.transform; + + if offset_changed { + let offset_delta = + Self::offset_delta(child_transform_offset.offset, previous.rect.offset); self.shift_subtree_recursive(child, offset_delta, changed); } + if transform_changed { + let current_rect = self.placements.get(child).map_or(previous.rect, |p| p.rect); + let next = Placement::new(child_transform_offset.transform, current_rect); + self.placements.insert(child.clone(), next); + changed.push((child.clone(), next)); + } } } } @@ -288,18 +326,20 @@ where &mut self, id: &Id, offset_delta: Offset, - changed: &mut Vec<(Id, Rect)>, + changed: &mut Vec<(Id, Placement)>, ) { if offset_delta == Offset::ZERO { // Common fast path when parent move does not change this branch position. return; } - let rect = self.rects.get(id).copied().unwrap_or_else(|| { - Self::invariant_violation("shift traversal encountered missing rect") + let current = self.placements.get(id).copied().unwrap_or_else(|| { + Self::invariant_violation("shift traversal encountered missing placement") }); - let shifted = Rect::new(rect.offset + offset_delta, rect.size); - self.update_rect(id, shifted, changed); + let shifted = Rect::new(current.rect.offset + offset_delta, current.rect.size); + let next = Placement::new(current.transform, shifted); + // Preserve existing transform; shift only affects 2D rect offset. + self.update_placement(id, next, changed); let cached_children = self .nodes @@ -311,18 +351,23 @@ where } } - /// Writes rect cache and emits into `changed` only on actual value change. + /// Writes rect and transform cache and emits into `changed` only on actual value change. /// /// Callers consume `changed` as a delta stream, so suppressing equal writes avoids redundant /// downstream work. - fn update_rect(&mut self, id: &Id, next_rect: Rect, changed: &mut Vec<(Id, Rect)>) { - let has_changed = self - .rects + fn update_placement( + &mut self, + id: &Id, + next: Placement, + changed: &mut Vec<(Id, Placement)>, + ) { + let is_changed = self + .placements .get(id) - .is_none_or(|current_rect| current_rect != &next_rect); - if has_changed { - self.rects.insert(id.clone(), next_rect); - changed.push((id.clone(), next_rect)); + .is_none_or(|current| current != &next); + if is_changed { + self.placements.insert(id.clone(), next); + changed.push((id.clone(), next)); } } @@ -480,16 +525,21 @@ where } #[derive(Debug)] -pub struct RecomputeResult { - pub changed: Vec<(Id, Rect)>, +pub struct RecomputeResult { + pub changed: Vec<(Id, Placement)>, } #[cfg(test)] mod tests { use super::*; + use crate::LayoutAxis; + use crate::dimensional_types::Thickness; + use massive_geometry::Transform; use std::cmp::max; use std::collections::{HashMap, HashSet}; + type TestLayouter = IncrementalLayouter; + #[derive(Default)] struct TestTopology { nodes: HashSet, @@ -537,7 +587,7 @@ mod tests { container_specs: HashMap, } - impl LayoutAlgorithm for TestAlgorithm { + impl LayoutAlgorithm for TestAlgorithm { fn measure(&self, id: &usize, child_sizes: &[Size<2>]) -> Size<2> { if let Some(&size) = self.leaf_sizes.get(id) { return size; @@ -570,7 +620,7 @@ mod tests { id: &usize, parent_offset: Offset<2>, child_sizes: &[Size<2>], - ) -> Vec> { + ) -> Vec> { let spec = self .container_specs .get(id) @@ -579,15 +629,18 @@ mod tests { let padding = spec.padding; let spacing = spec.spacing; let mut cursor: Offset<2> = padding.leading.into(); - let mut offsets = Vec::with_capacity(child_sizes.len()); + let mut placements = Vec::with_capacity(child_sizes.len()); for (index, &child_size) in child_sizes.iter().enumerate() { if index > 0 { cursor[axis] += spacing as i32; } - offsets.push(parent_offset + cursor); + placements.push(TransformOffset::new( + Transform::IDENTITY, + parent_offset + cursor, + )); cursor[axis] += child_size[axis] as i32; } - offsets + placements } } @@ -708,7 +761,7 @@ mod tests { fn initial_recompute_emits_changed_then_stabilizes() { let mut topology = TestTopology::default(); let mut algorithm = TestAlgorithm::default(); - let mut layouter = IncrementalLayouter::::new(); + let mut layouter = TestLayouter::new(); upsert_container( &mut layouter, &mut topology, @@ -730,7 +783,7 @@ mod tests { fn changing_leaf_size_updates_leaf_and_ancestor_rects() { let mut topology = TestTopology::default(); let mut algorithm = TestAlgorithm::default(); - let mut layouter = IncrementalLayouter::::new(); + let mut layouter = TestLayouter::new(); upsert_container( &mut layouter, &mut topology, @@ -748,11 +801,11 @@ mod tests { assert_eq!(update.changed.len(), 2); assert_eq!( - layouter.rect(&0).map(|rect| rect.size), + layouter.placement(&0).map(|placement| placement.rect.size), Some(Size::from([20, 10])) ); assert_eq!( - layouter.rect(&1).map(|rect| rect.size), + layouter.placement(&1).map(|placement| placement.rect.size), Some(Size::from([20, 10])) ); } @@ -761,7 +814,7 @@ mod tests { fn remove_node_clears_cached_rect_on_recompute() { let mut topology = TestTopology::default(); let mut algorithm = TestAlgorithm::default(); - let mut layouter = IncrementalLayouter::::new(); + let mut layouter = TestLayouter::new(); upsert_container( &mut layouter, &mut topology, @@ -777,9 +830,9 @@ mod tests { set_children(&mut layouter, &mut topology, 0, vec![]); let _ = layouter.recompute(&topology, &algorithm, [0, 0]); - assert!(layouter.rect(&1).is_none()); + assert!(layouter.placement(&1).is_none()); assert_eq!( - layouter.rect(&0).map(|rect| rect.size), + layouter.placement(&0).map(|placement| placement.rect.size), Some(Size::from([0, 0])) ); } @@ -788,7 +841,7 @@ mod tests { fn changing_one_branch_does_not_emit_unaffected_sibling_branch() { let mut topology = TestTopology::default(); let mut algorithm = TestAlgorithm::default(); - let mut layouter = IncrementalLayouter::::new(); + let mut layouter = TestLayouter::new(); upsert_container( &mut layouter, &mut topology, @@ -826,15 +879,15 @@ mod tests { changed_ids.sort_unstable(); assert_eq!(changed_ids, vec![0, 1, 10]); - assert!(layouter.rect(&20).is_some()); - assert!(layouter.rect(&2).is_some()); + assert!(layouter.placement(&20).is_some()); + assert!(layouter.placement(&2).is_some()); } #[test] fn reparenting_child_detaches_from_previous_parent() { let mut topology = TestTopology::default(); let mut algorithm = TestAlgorithm::default(); - let mut layouter = IncrementalLayouter::::new(); + let mut layouter = TestLayouter::new(); upsert_container( &mut layouter, &mut topology, @@ -866,15 +919,17 @@ mod tests { let _ = layouter.recompute(&topology, &algorithm, [0, 0]); assert_eq!( - layouter.rect(&10).map(|rect| rect.size), + layouter.placement(&10).map(|placement| placement.rect.size), Some(Size::from([0, 0])) ); assert_eq!( - layouter.rect(&20).map(|rect| rect.size), + layouter.placement(&20).map(|placement| placement.rect.size), Some(Size::from([7, 5])) ); assert_eq!( - layouter.rect(&1).map(|rect| rect.offset), + layouter + .placement(&1) + .map(|placement| placement.rect.offset), Some(Offset::from([0, 0])) ); } @@ -883,7 +938,7 @@ mod tests { fn root_offset_change_updates_offsets() { let mut topology = TestTopology::default(); let mut algorithm = TestAlgorithm::default(); - let mut layouter = IncrementalLayouter::::new(); + let mut layouter = TestLayouter::new(); upsert_container( &mut layouter, &mut topology, @@ -900,19 +955,23 @@ mod tests { assert_eq!(moved.changed.len(), 2); assert_eq!( - layouter.rect(&0).map(|rect| rect.offset), + layouter + .placement(&0) + .map(|placement| placement.rect.offset), Some([8, 13].into()) ); assert_eq!( - layouter.rect(&1).map(|rect| rect.offset), + layouter + .placement(&1) + .map(|placement| placement.rect.offset), Some([8, 13].into()) ); assert_eq!( - layouter.rect(&0).map(|rect| rect.size), + layouter.placement(&0).map(|placement| placement.rect.size), Some([10, 5].into()) ); assert_eq!( - layouter.rect(&1).map(|rect| rect.size), + layouter.placement(&1).map(|placement| placement.rect.size), Some([10, 5].into()) ); } @@ -921,7 +980,7 @@ mod tests { fn spacing_and_padding_affect_layout_geometry() { let mut topology = TestTopology::default(); let mut algorithm = TestAlgorithm::default(); - let mut layouter = IncrementalLayouter::::new(); + let mut layouter = TestLayouter::new(); upsert_container( &mut layouter, &mut topology, @@ -938,15 +997,19 @@ mod tests { let _ = layouter.recompute(&topology, &algorithm, [0, 0]); assert_eq!( - layouter.rect(&1).map(|rect| rect.offset), + layouter + .placement(&1) + .map(|placement| placement.rect.offset), Some([3, 2].into()) ); assert_eq!( - layouter.rect(&2).map(|rect| rect.offset), + layouter + .placement(&2) + .map(|placement| placement.rect.offset), Some([19, 2].into()) ); assert_eq!( - layouter.rect(&0).map(|rect| rect.size), + layouter.placement(&0).map(|placement| placement.rect.size), Some([30, 8].into()) ); } @@ -955,7 +1018,7 @@ mod tests { fn removing_root_clears_cached_root_rect_on_recompute() { let mut topology = TestTopology::default(); let mut algorithm = TestAlgorithm::default(); - let mut layouter = IncrementalLayouter::::new(); + let mut layouter = TestLayouter::new(); upsert_container( &mut layouter, &mut topology, @@ -970,7 +1033,7 @@ mod tests { remove_node(&mut layouter, &mut topology, &mut algorithm, 0); let update = layouter.recompute(&topology, &algorithm, [0, 0]); - assert!(layouter.rect(&0).is_none()); + assert!(layouter.placement(&0).is_none()); assert!(update.changed.is_empty()); } @@ -978,7 +1041,7 @@ mod tests { fn removing_pending_node_is_ignored_on_recompute() { let mut topology = TestTopology::default(); let mut algorithm = TestAlgorithm::default(); - let mut layouter = IncrementalLayouter::::new(); + let mut layouter = TestLayouter::new(); upsert_container( &mut layouter, &mut topology, @@ -996,15 +1059,15 @@ mod tests { let update = layouter.recompute(&topology, &algorithm, [0, 0]); - assert!(layouter.rect(&1).is_none()); - assert!(update.changed.iter().any(|(id, _)| id == &0)); + assert!(layouter.placement(&1).is_none()); + assert!(update.changed.iter().any(|(id, ..)| id == &0)); } #[test] fn removing_pending_node_does_not_query_missing_parent() { let mut topology = TestTopology::default(); let mut algorithm = TestAlgorithm::default(); - let mut layouter = IncrementalLayouter::::new(); + let mut layouter = TestLayouter::new(); upsert_container( &mut layouter, &mut topology, @@ -1023,15 +1086,15 @@ mod tests { let update = layouter.recompute(&topology, &algorithm, [0, 0]); - assert!(layouter.rect(&1).is_none()); - assert!(update.changed.iter().any(|(id, _)| id == &0)); + assert!(layouter.placement(&1).is_none()); + assert!(update.changed.iter().any(|(id, ..)| id == &0)); } #[test] fn removal_requires_parent_mark_for_relayout() { let mut topology = TestTopology::default(); let mut algorithm = TestAlgorithm::default(); - let mut layouter = IncrementalLayouter::::new(); + let mut layouter = TestLayouter::new(); upsert_container( &mut layouter, &mut topology, @@ -1049,16 +1112,16 @@ mod tests { algorithm.remove_node(1); let removed_only_update = layouter.recompute(&topology, &algorithm, [0, 0]); - assert!(layouter.rect(&1).is_none()); + assert!(layouter.placement(&1).is_none()); assert!(removed_only_update.changed.is_empty()); assert_eq!( - layouter.rect(&0).map(|rect| rect.size), + layouter.placement(&0).map(|placement| placement.rect.size), Some([17, 5].into()) ); let mut topology = TestTopology::default(); let mut algorithm = TestAlgorithm::default(); - let mut layouter = IncrementalLayouter::::new(); + let mut layouter = TestLayouter::new(); upsert_container( &mut layouter, &mut topology, @@ -1086,9 +1149,14 @@ mod tests { changed_ids.sort_unstable(); assert_eq!(changed_ids, vec![0, 2]); - assert_eq!(layouter.rect(&0).map(|rect| rect.size), Some([7, 5].into())); assert_eq!( - layouter.rect(&2).map(|rect| rect.offset), + layouter.placement(&0).map(|placement| placement.rect.size), + Some([7, 5].into()) + ); + assert_eq!( + layouter + .placement(&2) + .map(|placement| placement.rect.offset), Some([0, 0].into()) ); } @@ -1098,7 +1166,7 @@ mod tests { fn missing_child_node_panics_on_recompute() { let mut topology = TestTopology::default(); let mut algorithm = TestAlgorithm::default(); - let mut layouter = IncrementalLayouter::::new(); + let mut layouter = TestLayouter::new(); upsert_container( &mut layouter, &mut topology, @@ -1115,7 +1183,7 @@ mod tests { inner: &'a TestAlgorithm, } - impl LayoutAlgorithm for BrokenPlaceChildrenAlgorithm<'_> { + impl LayoutAlgorithm for BrokenPlaceChildrenAlgorithm<'_> { fn measure(&self, id: &usize, child_sizes: &[Size<2>]) -> Size<2> { self.inner.measure(id, child_sizes) } @@ -1125,7 +1193,7 @@ mod tests { _id: &usize, _parent_offset: Offset<2>, _child_sizes: &[Size<2>], - ) -> Vec> { + ) -> Vec> { Vec::new() } } @@ -1135,7 +1203,7 @@ mod tests { fn place_children_offset_count_mismatch_panics() { let mut topology = TestTopology::default(); let mut algorithm = TestAlgorithm::default(); - let mut layouter = IncrementalLayouter::::new(); + let mut layouter = TestLayouter::new(); upsert_container( &mut layouter, &mut topology, @@ -1152,7 +1220,7 @@ mod tests { } fn upsert_leaf( - layouter: &mut IncrementalLayouter, + layouter: &mut TestLayouter, topology: &mut TestTopology, algorithm: &mut TestAlgorithm, id: usize, @@ -1164,7 +1232,7 @@ mod tests { } fn upsert_container( - layouter: &mut IncrementalLayouter, + layouter: &mut TestLayouter, topology: &mut TestTopology, algorithm: &mut TestAlgorithm, id: usize, @@ -1176,7 +1244,7 @@ mod tests { } fn set_children( - layouter: &mut IncrementalLayouter, + layouter: &mut TestLayouter, topology: &mut TestTopology, id: usize, children: Vec, @@ -1187,7 +1255,7 @@ mod tests { } fn set_intrinsic_size( - layouter: &mut IncrementalLayouter, + layouter: &mut TestLayouter, algorithm: &mut TestAlgorithm, id: usize, size: impl Into>, @@ -1198,7 +1266,7 @@ mod tests { } fn set_padding( - layouter: &mut IncrementalLayouter, + layouter: &mut TestLayouter, algorithm: &mut TestAlgorithm, id: usize, padding: impl Into>, @@ -1209,7 +1277,7 @@ mod tests { } fn set_spacing( - layouter: &mut IncrementalLayouter, + layouter: &mut TestLayouter, algorithm: &mut TestAlgorithm, id: usize, spacing: u32, @@ -1220,7 +1288,7 @@ mod tests { } fn remove_node( - layouter: &mut IncrementalLayouter, + layouter: &mut TestLayouter, topology: &mut TestTopology, algorithm: &mut TestAlgorithm, id: usize,