From 74d1614dcaf1c2b3304b06712b0712e70112d474 Mon Sep 17 00:00:00 2001 From: Armin Sander Date: Tue, 21 Apr 2026 12:21:38 +0200 Subject: [PATCH 01/13] Fix local view transforms --- .../src/desktop_system/event_forwarding.rs | 8 +---- desktop/src/hit_tester.rs | 36 +++++++++++++++---- desktop/src/instance_presenter.rs | 19 ++++------ 3 files changed, 37 insertions(+), 26 deletions(-) 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/hit_tester.rs b/desktop/src/hit_tester.rs index c5acc336..99ab24db 100644 --- a/desktop/src/hit_tester.rs +++ b/desktop/src/hit_tester.rs @@ -194,16 +194,40 @@ impl<'a> AggregateHitTester<'a> { .unproject_to_model_z0(screen_pos, &hit_surface.transform.to_matrix4()) } + /// 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, 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 + let instance_presenter = self .instances .get(&instance_id) - .expect("Internal error: Missing instance presenter for hit test") - .transform(local_center); + .expect("Internal error: Missing instance presenter for hit test"); + + // For View targets, offset the instance transform by the view's position within the + // instance so that unprojection returns view-local coordinates. + let view_offset = match target { + DesktopTarget::View(_) => { + let instance_target = DesktopTarget::Instance(instance_id); + let instance_rect = self.layouter.rect(&instance_target).map(|r| { + let r_px: RectPx = (*r).into(); + Rect::from(r_px) + }); + instance_rect.map_or(Vector3::ZERO, |ir| { + let offset = rect.origin() - ir.origin(); + Vector3::new(offset.x, offset.y, 0.0) + }) + } + _ => Vector3::ZERO, + }; + + let local_center = rect.size().to_rect().center(); + let mut layout_transform = instance_presenter.layout_transform_animation.final_value(); + layout_transform.translate = + layout_transform.translate + layout_transform.rotate * view_offset; + return InstancePresenter::transform_with_layout( + layout_transform, + local_center, + ); } let origin = rect.origin(); diff --git a/desktop/src/instance_presenter.rs b/desktop/src/instance_presenter.rs index 946f2828..eb34f7df 100644 --- a/desktop/src/instance_presenter.rs +++ b/desktop/src/instance_presenter.rs @@ -102,9 +102,9 @@ impl InstancePresenter { let local_center = background.local_rect.center(); background .transform - .update_if_changed(Self::transform_with_local_center( + .update_if_changed(Self::transform_with_layout( layout_transform, - (local_center.x, local_center.y), + local_center, )); } @@ -116,7 +116,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 +126,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( From 402492a9fa40ef49a0277a05f30aa4c27b19771b Mon Sep 17 00:00:00 2001 From: Armin Sander Date: Tue, 21 Apr 2026 13:57:58 +0200 Subject: [PATCH 02/13] First attempt at using Transforms in the desktop system --- desktop/src/desktop_system.rs | 15 +- desktop/src/desktop_system/focus_input.rs | 7 + .../src/desktop_system/layout_algorithm.rs | 87 ++++++++-- desktop/src/desktop_system/layout_effects.rs | 152 +++++------------- desktop/src/hit_tester.rs | 38 ++--- desktop/src/projects/launcher_presenter.rs | 7 +- desktop/src/projects/mod.rs | 4 +- desktop/src/projects/project_presenter.rs | 33 +++- layout/src/incremental_layouter.rs | 108 +++++++++---- 9 files changed, 263 insertions(+), 188 deletions(-) diff --git a/desktop/src/desktop_system.rs b/desktop/src/desktop_system.rs index bdb6fd0a..16f83f89 100644 --- a/desktop/src/desktop_system.rs +++ b/desktop/src/desktop_system.rs @@ -78,7 +78,6 @@ pub struct DesktopSystem { event_router: EventRouter, camera: Animated, pointer_feedback_enabled: bool, - last_effects_focus: Option, #[debug(skip)] layouter: IncrementalLayouter, @@ -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), @@ -225,6 +223,19 @@ impl DesktopSystem { rect_px.into() }) } + + fn rect_and_transform(&self, target: &DesktopTarget) -> Option<(Rect, Transform)> { + let rect = self.layouter.rect(target).map(|rect| { + let rect_px: RectPx = (*rect).into(); + Rect::from(rect_px) + })?; + let transform = self + .layouter + .transform(target) + .copied() + .unwrap_or(Transform::IDENTITY); + Some((rect, transform)) + } } impl Aggregates { diff --git a/desktop/src/desktop_system/focus_input.rs b/desktop/src/desktop_system/focus_input.rs index ca47144e..d13393af 100644 --- a/desktop/src/desktop_system/focus_input.rs +++ b/desktop/src/desktop_system/focus_input.rs @@ -34,7 +34,11 @@ impl DesktopSystem { render_geometry, ); + let from = self.event_router.focused().cloned(); let transitions = self.event_router.process(event, &hit_tester)?; + let to = self.event_router.focused().cloned(); + self.invalidate_layout_for_focus_change(from.as_ref(), to.as_ref()); + self.forward_event_transitions(transitions, instance_manager)? }; @@ -73,7 +77,10 @@ impl DesktopSystem { target: &DesktopTarget, instance_manager: &InstanceManager, ) -> Result<()> { + let from = self.event_router.focused().cloned(); let transitions = self.event_router.focus(target); + let to = self.event_router.focused().cloned(); + self.invalidate_layout_for_focus_change(from.as_ref(), to.as_ref()); // Invariant: Programmatic focus changes must not trigger commands. assert!( diff --git a/desktop/src/desktop_system/layout_algorithm.rs b/desktop/src/desktop_system/layout_algorithm.rs index b7e93e6a..5ec553a3 100644 --- a/desktop/src/desktop_system/layout_algorithm.rs +++ b/desktop/src/desktop_system/layout_algorithm.rs @@ -1,16 +1,20 @@ use std::cmp::max; -use massive_geometry::SizePx; -use massive_layout::{LayoutAlgorithm, LayoutAxis, Offset, Size}; +use massive_geometry::{RectPx, SizePx, Transform}; +use massive_layout::{LayoutAlgorithm, LayoutAxis, Offset, Rect as LayoutRect, Size}; + +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<'_> { @@ -53,19 +57,19 @@ pub(crate) fn place_container_children( spacing: i32, mut offset: Offset<2>, child_sizes: &[Size<2>], -) -> Vec> { +) -> Vec<(Offset<2>, Transform)> { let axis_index: usize = axis.into(); - let mut child_offsets = Vec::with_capacity(child_sizes.len()); + 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; } - child_offsets.push(offset); + child_placements.push((offset, Transform::IDENTITY)); offset[axis_index] += child_size[axis_index] as i32; } - child_offsets + child_placements } impl LayoutAlgorithm for DesktopLayoutAlgorithm<'_> { @@ -110,15 +114,74 @@ impl LayoutAlgorithm for DesktopLayoutAlgorithm<'_> { 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( + ) -> Vec<(Offset<2>, Transform)> { + if let DesktopTarget::Launcher(launcher_id) = id { + let launcher = &self.aggregates.launchers[launcher_id]; + + // Compute child offsets: custom (Visor) or default container layout (Band). + let child_placements = if let Some(placements) = launcher.panel_child_offsets( parent_offset, child_sizes, self.default_panel_size, - ) - { - return offsets; + ) { + placements + } else { + match self.resolve_layout_spec(id) { + LayoutSpec::Container { + axis, + padding, + spacing, + } => { + let offset = parent_offset + Offset::from(padding.leading); + place_container_children(axis, spacing as i32, offset, child_sizes) + } + _ => Vec::new(), + } + }; + + // Compute 3D transforms for instance children. + let children = self.aggregates.hierarchy.get_nested(id); + let instance_inputs: Vec = children + .iter() + .zip(child_placements.iter().zip(child_sizes.iter())) + .filter_map(|(target, ((offset, _), size))| match target { + DesktopTarget::Instance(instance_id) => { + let layout_rect = LayoutRect::new(*offset, *size); + let rect_px: RectPx = layout_rect.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, + ); + + // Rebuild placements: match each child with its computed transform. + let mut transform_by_instance: std::collections::HashMap = + layout_targets + .into_iter() + .map(|lt| (lt.instance_id, lt.layout_transform)) + .collect(); + + return children + .iter() + .zip(child_placements) + .map(|(target, (offset, default_transform))| { + let transform = match target { + DesktopTarget::Instance(instance_id) => transform_by_instance + .remove(instance_id) + .unwrap_or(default_transform), + _ => default_transform, + }; + (offset, transform) + }) + .collect(); } match self.resolve_layout_spec(id) { diff --git a/desktop/src/desktop_system/layout_effects.rs b/desktop/src/desktop_system/layout_effects.rs index 5ffc23ae..57f25627 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_geometry::{PointPx, Rect, RectPx, Transform}; use massive_layout::Rect as LayoutRect; 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,21 +67,21 @@ impl DesktopSystem { fn apply_layout_changes( &mut self, - changed: Vec<(DesktopTarget, LayoutRect<2>)>, + changed: Vec<(DesktopTarget, LayoutRect<2>, Transform)>, animate: bool, ) { - let mut launchers_to_relayout: HashSet = HashSet::new(); - - for (id, layout_rect) in changed { + for (id, layout_rect, transform) in changed { let rect_px: RectPx = layout_rect.into(); let rect: Rect = rect_px.into(); 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(rect_px, transform, animate); } DesktopTarget::Group(group_id) => { self.aggregates @@ -92,8 +91,6 @@ impl DesktopSystem { .rect = rect; } DesktopTarget::Launcher(launcher_id) => { - launchers_to_relayout.insert(launcher_id); - self.aggregates .launchers .get_mut(&launcher_id) @@ -105,10 +102,6 @@ impl DesktopSystem { } } } - - for launcher_id in launchers_to_relayout { - self.apply_launcher_instance_layout(launcher_id, animate); - } } fn instance_launcher(&self, instance_id: InstanceId) -> Option { @@ -119,95 +112,12 @@ 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( - &mut self, - from: Option, - to: Option, - animate: bool, - ) { - // 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()] { - if let Some(launcher_id) = self.focus_target_launcher_for_layout(target) { - launchers_to_update.insert(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); - } - } - + /// 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: Option<&DesktopTarget>, ) -> Option { - // Resolve from any focus target (instance/view/etc.) to its owning instance. let target = target?; let focused_path = self.aggregates.hierarchy.resolve_path(Some(target)); let focused_instance = focused_path.instance()?; @@ -225,13 +135,27 @@ impl DesktopSystem { .map(|_| launcher_id) } + /// Marks launchers that need relayout due to a keyboard focus change as reflow-pending. + pub(super) fn invalidate_layout_for_focus_change( + &mut self, + from: Option<&DesktopTarget>, + to: Option<&DesktopTarget>, + ) { + for target in [from, to] { + if let Some(launcher_id) = self.focus_target_launcher_for_layout(target) { + self.layouter + .mark_reflow_pending(DesktopTarget::Launcher(launcher_id)); + } + } + } + fn sync_hover_rect_to_pointer_path( &mut self, pointer_focus: Option<&DesktopTarget>, ) { - let hover_rect = match pointer_focus { + let hover_placement = match pointer_focus { Some(DesktopTarget::Instance(instance_id)) => { - self.rect(&DesktopTarget::Instance(*instance_id)) + self.rect_and_transform(&DesktopTarget::Instance(*instance_id)) } Some(DesktopTarget::View(view_id)) => match self .aggregates @@ -239,17 +163,19 @@ impl DesktopSystem { .parent(&DesktopTarget::View(*view_id)) { Some(DesktopTarget::Instance(instance_id)) => { - self.rect(&DesktopTarget::Instance(*instance_id)) + self.rect_and_transform(&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.rect_and_transform(&DesktopTarget::Launcher(*launcher_id)) } _ => None, }; - self.aggregates.project_presenter.set_hover_rect(hover_rect); + self.aggregates + .project_presenter + .set_hover_rect(hover_placement); } } diff --git a/desktop/src/hit_tester.rs b/desktop/src/hit_tester.rs index 99ab24db..d646475c 100644 --- a/desktop/src/hit_tester.rs +++ b/desktop/src/hit_tester.rs @@ -1,8 +1,7 @@ use massive_applications::InstanceId; -use massive_geometry::{Contains, Point, Rect, RectPx, Size, Vector3}; +use massive_geometry::{Contains, Point, Rect, RectPx, Size, Transform, Vector3}; use massive_layout::IncrementalLayouter; use massive_renderer::RenderGeometry; -use massive_scene::Transform; use crate::instance_presenter::InstancePresenter; use crate::projects::{LaunchProfileId, LauncherPresenter}; @@ -162,31 +161,34 @@ impl<'a> AggregateHitTester<'a> { Rect::from(rect_px) })?; - let model = self.hit_test_transform(target, rect); - let surface_z = self.hit_surface_z(target, &model); + let transform = self.hit_test_transform(target, rect); + let surface_z = self.hit_surface_z(target); Some(HitSurface { - transform: model, + transform, size: rect.size(), surface_z, }) } - fn hit_surface_z(&self, target: &DesktopTarget, model: &Transform) -> f64 { + fn hit_surface_z(&self, target: &DesktopTarget) -> f64 { + // For instances and views, use the instance's layout transform z from the layouter. + // We use the instance presenter's final_value() for hit depth stability during animations. 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; } - model.translate.z + // For non-instance targets, use the layouter's stored transform. + self.layouter + .transform(target) + .map_or(0.0, |t| t.translate.z) } fn hit_test_surface(&self, screen_pos: Point, hit_surface: &HitSurface) -> Option { @@ -198,16 +200,17 @@ impl<'a> AggregateHitTester<'a> { /// Unprojecting through this transform yields target-local coordinates. fn hit_test_transform(&self, target: &DesktopTarget, rect: Rect) -> Transform { if let Some(instance_id) = self.hit_target_instance_id(target) { - let instance_presenter = self - .instances - .get(&instance_id) - .expect("Internal error: Missing instance presenter for hit test"); + let instance_target = DesktopTarget::Instance(instance_id); + let instance_transform = self + .layouter + .transform(&instance_target) + .copied() + .unwrap_or(Transform::IDENTITY); // For View targets, offset the instance transform by the view's position within the // instance so that unprojection returns view-local coordinates. let view_offset = match target { DesktopTarget::View(_) => { - let instance_target = DesktopTarget::Instance(instance_id); let instance_rect = self.layouter.rect(&instance_target).map(|r| { let r_px: RectPx = (*r).into(); Rect::from(r_px) @@ -221,13 +224,10 @@ impl<'a> AggregateHitTester<'a> { }; let local_center = rect.size().to_rect().center(); - let mut layout_transform = instance_presenter.layout_transform_animation.final_value(); + let mut layout_transform = instance_transform; layout_transform.translate = layout_transform.translate + layout_transform.rotate * view_offset; - return InstancePresenter::transform_with_layout( - layout_transform, - local_center, - ); + return InstancePresenter::transform_with_layout(layout_transform, local_center); } let origin = rect.origin(); diff --git a/desktop/src/projects/launcher_presenter.rs b/desktop/src/projects/launcher_presenter.rs index 74033787..abd91e31 100644 --- a/desktop/src/projects/launcher_presenter.rs +++ b/desktop/src/projects/launcher_presenter.rs @@ -40,7 +40,6 @@ pub struct LauncherInstanceLayoutInput { #[derive(Debug, Clone, Copy)] pub struct LauncherInstanceLayoutTarget { pub instance_id: InstanceId, - pub rect: RectPx, pub layout_transform: Transform, } @@ -150,7 +149,7 @@ impl LauncherPresenter { parent_offset: Offset<2>, child_sizes: &[LayoutSize<2>], default_panel_size: SizePx, - ) -> Option>> { + ) -> Option, Transform)>> { match self.mode { LauncherMode::Band => None, LauncherMode::Visor => Some(centered_horizontal_offsets( @@ -215,7 +214,6 @@ impl LauncherPresenter { LauncherInstanceLayoutTarget { instance_id: input.instance_id, - rect: input.rect, layout_transform, } }) @@ -234,7 +232,6 @@ impl LauncherPresenter { LauncherInstanceLayoutTarget { instance_id: input.instance_id, - rect: input.rect, layout_transform: Transform::from_translation(center_translation), } }) @@ -346,7 +343,7 @@ fn centered_horizontal_offsets( parent_offset: Offset<2>, child_sizes: &[LayoutSize<2>], panel_width: i32, -) -> Vec> { +) -> Vec<(Offset<2>, Transform)> { 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..e0d9168a 100644 --- a/desktop/src/projects/project_presenter.rs +++ b/desktop/src/projects/project_presenter.rs @@ -1,12 +1,13 @@ use std::{sync::Arc, time::Duration}; use massive_animation::{Animated, Interpolation}; -use massive_geometry::{Color, Rect}; +use massive_geometry::{Color, Point, Rect, Transform}; 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 { @@ -22,6 +23,9 @@ pub struct ProjectPresenter { // Robustness: Alpha should be a type. hover_alpha: Animated, hover_rect: Rect, + hover_transform: Transform, + 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,22 +35,29 @@ 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_transform: Transform::IDENTITY, + 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_rect(&mut self, placement: Option<(Rect, Transform)>) { + match placement { + Some((rect, transform)) => { self.hover_alpha.animate_if_changed( 1.0, Self::HOVER_ANIMATION_DURATION, @@ -54,6 +65,7 @@ impl ProjectPresenter { ); self.hover_rect = rect; + self.hover_transform = transform; } None => { self.hover_alpha.animate_if_changed( @@ -69,12 +81,21 @@ impl ProjectPresenter { let alpha = self.hover_alpha.value(); let rect_alpha = (alpha != 0.0).then_some((self.hover_rect, alpha)); + // Update the hover visual's scene transform to position the rect in 3D. + let center = self.hover_rect.center(); + let scene_transform = InstancePresenter::transform_with_layout( + self.hover_transform, + Point::new(center.x, 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); } diff --git a/layout/src/incremental_layouter.rs b/layout/src/incremental_layouter.rs index 0940b01a..12b2631e 100644 --- a/layout/src/incremental_layouter.rs +++ b/layout/src/incremental_layouter.rs @@ -9,6 +9,8 @@ use std::collections::hash_map::Entry; use std::collections::{HashMap, HashSet}; use std::hash::Hash; +use massive_geometry::Transform; + use crate::dimensional_types::{Offset, Rect, Size}; #[cfg(test)] @@ -22,6 +24,7 @@ where { nodes: HashMap>, rects: HashMap>, + transforms: HashMap, reflow_pending: HashSet, } @@ -54,15 +57,18 @@ where /// 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. - /// `parent_offset` is the absolute position of `id`. + /// Returns one `(absolute_child_offset, transform)` pair 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 3D world space. For flat 2D layouts, return + /// `Transform::IDENTITY` (the default implementation does this). fn place_children( &self, id: &Id, parent_offset: Offset, child_sizes: &[Size], - ) -> Vec>; + ) -> Vec<(Offset, Transform)>; } impl IncrementalLayouter @@ -77,6 +83,7 @@ where Self { nodes: HashMap::new(), rects: HashMap::new(), + transforms: HashMap::new(), reflow_pending: HashSet::new(), } } @@ -146,6 +153,7 @@ where algorithm, &affected_root, root_offset, + Transform::IDENTITY, &affected, &mut changed, ); @@ -158,6 +166,10 @@ where self.rects.get(id) } + pub fn transform(&self, id: &Id) -> Option<&Transform> { + self.transforms.get(id) + } + /// Decides whether the current generation should traverse into `child`. /// /// Clean children with valid cached rects are reusable during placement, so traversal is only @@ -187,6 +199,7 @@ where } } self.rects.remove(id); + self.transforms.remove(id); } /// Recursive pass 1: measure affected subtree sizes bottom-up. @@ -237,11 +250,12 @@ where algorithm: &impl LayoutAlgorithm, id: &Id, absolute_offset: Offset, + transform: Transform, affected: &HashSet, - changed: &mut Vec<(Id, Rect)>, + changed: &mut Vec<(Id, Rect, Transform)>, ) { let outer_size = self.cached_outer_size(id); - self.update_rect(id, Rect::new(absolute_offset, outer_size), changed); + self.update_placement(id, Rect::new(absolute_offset, outer_size), transform, changed); let cached_children = self .nodes @@ -257,25 +271,46 @@ 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_placements = algorithm.place_children(id, absolute_offset, &child_sizes); + if child_placements.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_offset, child_transform)) in + cached_children.iter().zip(child_placements.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_offset, + *child_transform, + affected, + changed, + ); } else { - // Clean child: translate cached subtree if parent offset changed. + // Clean child: apply offset shift and/or transform update. let previous_rect = self.rects.get(child).copied().unwrap_or_else(|| { Self::invariant_violation("clean child missing rect during placement") }); - if previous_rect.offset != *child_offset { + let offset_changed = previous_rect.offset != *child_offset; + let transform_changed = self + .transforms + .get(child) + .is_none_or(|t| t != child_transform); + + if offset_changed { let offset_delta = Self::offset_delta(*child_offset, previous_rect.offset); self.shift_subtree_recursive(child, offset_delta, changed); } + if transform_changed { + self.transforms.insert(child.clone(), *child_transform); + // Re-emit the (possibly shifted) rect with the new transform. + let current_rect = self.rects.get(child).copied().unwrap_or(previous_rect); + changed.push((child.clone(), current_rect, *child_transform)); + } } } } @@ -288,7 +323,7 @@ where &mut self, id: &Id, offset_delta: Offset, - changed: &mut Vec<(Id, Rect)>, + changed: &mut Vec<(Id, Rect, Transform)>, ) { if offset_delta == Offset::ZERO { // Common fast path when parent move does not change this branch position. @@ -299,7 +334,13 @@ where Self::invariant_violation("shift traversal encountered missing rect") }); let shifted = Rect::new(rect.offset + offset_delta, rect.size); - self.update_rect(id, shifted, changed); + // Preserve existing transform; shift only affects 2D rect offset. + let transform = self + .transforms + .get(id) + .copied() + .unwrap_or(Transform::IDENTITY); + self.update_placement(id, shifted, transform, changed); let cached_children = self .nodes @@ -311,18 +352,29 @@ 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 + fn update_placement( + &mut self, + id: &Id, + next_rect: Rect, + next_transform: Transform, + changed: &mut Vec<(Id, Rect, Transform)>, + ) { + let rect_changed = self .rects .get(id) .is_none_or(|current_rect| current_rect != &next_rect); - if has_changed { + let transform_changed = self + .transforms + .get(id) + .is_none_or(|current_transform| current_transform != &next_transform); + if rect_changed || transform_changed { self.rects.insert(id.clone(), next_rect); - changed.push((id.clone(), next_rect)); + self.transforms.insert(id.clone(), next_transform); + changed.push((id.clone(), next_rect, next_transform)); } } @@ -481,7 +533,7 @@ where #[derive(Debug)] pub struct RecomputeResult { - pub changed: Vec<(Id, Rect)>, + pub changed: Vec<(Id, Rect, Transform)>, } #[cfg(test)] @@ -570,7 +622,7 @@ mod tests { id: &usize, parent_offset: Offset<2>, child_sizes: &[Size<2>], - ) -> Vec> { + ) -> Vec<(Offset<2>, Transform)> { let spec = self .container_specs .get(id) @@ -579,15 +631,15 @@ 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((parent_offset + cursor, Transform::IDENTITY)); cursor[axis] += child_size[axis] as i32; } - offsets + placements } } @@ -822,7 +874,7 @@ mod tests { set_intrinsic_size(&mut layouter, &mut algorithm, 1, [12, 10]); let update = layouter.recompute(&topology, &algorithm, [0, 0]); - let mut changed_ids: Vec = update.changed.into_iter().map(|(id, _)| id).collect(); + let mut changed_ids: Vec = update.changed.into_iter().map(|(id, ..)| id).collect(); changed_ids.sort_unstable(); assert_eq!(changed_ids, vec![0, 1, 10]); @@ -997,7 +1049,7 @@ 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!(update.changed.iter().any(|(id, ..)| id == &0)); } #[test] @@ -1024,7 +1076,7 @@ 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!(update.changed.iter().any(|(id, ..)| id == &0)); } #[test] @@ -1081,7 +1133,7 @@ mod tests { let mut changed_ids: Vec = parent_marked_update .changed .into_iter() - .map(|(id, _)| id) + .map(|(id, ..)| id) .collect(); changed_ids.sort_unstable(); @@ -1125,7 +1177,7 @@ mod tests { _id: &usize, _parent_offset: Offset<2>, _child_sizes: &[Size<2>], - ) -> Vec> { + ) -> Vec<(Offset<2>, Transform)> { Vec::new() } } From dde96956fdd74219db1171e68dc3d5bb5fd33bc4 Mon Sep 17 00:00:00 2001 From: Armin Sander Date: Tue, 21 Apr 2026 14:03:58 +0200 Subject: [PATCH 03/13] Combine Rects with Transform in the incremental layouter --- layout/src/incremental_layouter.rs | 73 ++++++++++++++---------------- 1 file changed, 34 insertions(+), 39 deletions(-) diff --git a/layout/src/incremental_layouter.rs b/layout/src/incremental_layouter.rs index 12b2631e..7545da96 100644 --- a/layout/src/incremental_layouter.rs +++ b/layout/src/incremental_layouter.rs @@ -13,6 +13,12 @@ use massive_geometry::Transform; use crate::dimensional_types::{Offset, Rect, Size}; +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Placement { + pub rect: Rect, + pub transform: Transform, +} + #[cfg(test)] use crate::LayoutAxis; #[cfg(test)] @@ -23,8 +29,7 @@ where Id: Eq + Hash + Clone, { nodes: HashMap>, - rects: HashMap>, - transforms: HashMap, + placements: HashMap>, reflow_pending: HashSet, } @@ -82,8 +87,7 @@ where pub fn new() -> Self { Self { nodes: HashMap::new(), - rects: HashMap::new(), - transforms: HashMap::new(), + placements: HashMap::new(), reflow_pending: HashSet::new(), } } @@ -162,12 +166,16 @@ where RecomputeResult { changed } } + pub fn placement(&self, id: &Id) -> Option<&Placement> { + self.placements.get(id) + } + pub fn rect(&self, id: &Id) -> Option<&Rect> { - self.rects.get(id) + self.placements.get(id).map(|p| &p.rect) } pub fn transform(&self, id: &Id) -> Option<&Transform> { - self.transforms.get(id) + self.placements.get(id).map(|p| &p.transform) } /// Decides whether the current generation should traverse into `child`. @@ -176,7 +184,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`. @@ -198,8 +206,7 @@ where self.evict_cached_subtree(&child); } } - self.rects.remove(id); - self.transforms.remove(id); + self.placements.remove(id); } /// Recursive pass 1: measure affected subtree sizes bottom-up. @@ -292,23 +299,20 @@ where ); } else { // Clean child: apply offset shift and/or transform update. - let previous_rect = self.rects.get(child).copied().unwrap_or_else(|| { - Self::invariant_violation("clean child missing rect during placement") + let previous = self.placements.get(child).copied().unwrap_or_else(|| { + Self::invariant_violation("clean child missing placement during placement") }); - let offset_changed = previous_rect.offset != *child_offset; - let transform_changed = self - .transforms - .get(child) - .is_none_or(|t| t != child_transform); + let offset_changed = previous.rect.offset != *child_offset; + let transform_changed = previous.transform != *child_transform; if offset_changed { - let offset_delta = Self::offset_delta(*child_offset, previous_rect.offset); + let offset_delta = Self::offset_delta(*child_offset, previous.rect.offset); self.shift_subtree_recursive(child, offset_delta, changed); } if transform_changed { - self.transforms.insert(child.clone(), *child_transform); - // Re-emit the (possibly shifted) rect with the new transform. - let current_rect = self.rects.get(child).copied().unwrap_or(previous_rect); + let current_rect = self.placements.get(child).map_or(previous.rect, |p| p.rect); + let next = Placement { rect: current_rect, transform: *child_transform }; + self.placements.insert(child.clone(), next); changed.push((child.clone(), current_rect, *child_transform)); } } @@ -330,17 +334,12 @@ where 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); + let shifted = Rect::new(current.rect.offset + offset_delta, current.rect.size); // Preserve existing transform; shift only affects 2D rect offset. - let transform = self - .transforms - .get(id) - .copied() - .unwrap_or(Transform::IDENTITY); - self.update_placement(id, shifted, transform, changed); + self.update_placement(id, shifted, current.transform, changed); let cached_children = self .nodes @@ -363,17 +362,13 @@ where next_transform: Transform, changed: &mut Vec<(Id, Rect, Transform)>, ) { - let rect_changed = self - .rects - .get(id) - .is_none_or(|current_rect| current_rect != &next_rect); - let transform_changed = self - .transforms + let next = Placement { rect: next_rect, transform: next_transform }; + let is_changed = self + .placements .get(id) - .is_none_or(|current_transform| current_transform != &next_transform); - if rect_changed || transform_changed { - self.rects.insert(id.clone(), next_rect); - self.transforms.insert(id.clone(), next_transform); + .is_none_or(|current| current != &next); + if is_changed { + self.placements.insert(id.clone(), next); changed.push((id.clone(), next_rect, next_transform)); } } From cc86976568fd6098631d9f152b259f524c402ef6 Mon Sep 17 00:00:00 2001 From: Armin Sander Date: Tue, 21 Apr 2026 14:34:00 +0200 Subject: [PATCH 04/13] Make Transform generic inside the layouter and fix hover positioning problems --- desktop/src/desktop_system.rs | 2 +- desktop/src/desktop_system/focus_input.rs | 13 +-- .../src/desktop_system/layout_algorithm.rs | 2 +- desktop/src/hit_tester.rs | 4 +- desktop/src/projects/project_presenter.rs | 29 +++-- layout/src/incremental_layouter.rs | 105 ++++++++++-------- 6 files changed, 88 insertions(+), 67 deletions(-) diff --git a/desktop/src/desktop_system.rs b/desktop/src/desktop_system.rs index 16f83f89..e91d4d1b 100644 --- a/desktop/src/desktop_system.rs +++ b/desktop/src/desktop_system.rs @@ -80,7 +80,7 @@ pub struct DesktopSystem { pointer_feedback_enabled: bool, #[debug(skip)] - layouter: IncrementalLayouter, + layouter: IncrementalLayouter, aggregates: Aggregates, } diff --git a/desktop/src/desktop_system/focus_input.rs b/desktop/src/desktop_system/focus_input.rs index d13393af..014667c2 100644 --- a/desktop/src/desktop_system/focus_input.rs +++ b/desktop/src/desktop_system/focus_input.rs @@ -34,11 +34,10 @@ impl DesktopSystem { render_geometry, ); - let from = self.event_router.focused().cloned(); let transitions = self.event_router.process(event, &hit_tester)?; - let to = self.event_router.focused().cloned(); - self.invalidate_layout_for_focus_change(from.as_ref(), to.as_ref()); - + if let Some((from, to)) = transitions.keyboard_focus_change() { + self.invalidate_layout_for_focus_change(from, to); + } self.forward_event_transitions(transitions, instance_manager)? }; @@ -77,10 +76,10 @@ impl DesktopSystem { target: &DesktopTarget, instance_manager: &InstanceManager, ) -> Result<()> { - let from = self.event_router.focused().cloned(); let transitions = self.event_router.focus(target); - let to = self.event_router.focused().cloned(); - self.invalidate_layout_for_focus_change(from.as_ref(), to.as_ref()); + if let Some((from, to)) = transitions.keyboard_focus_change() { + self.invalidate_layout_for_focus_change(from, to); + } // Invariant: Programmatic focus changes must not trigger commands. assert!( diff --git a/desktop/src/desktop_system/layout_algorithm.rs b/desktop/src/desktop_system/layout_algorithm.rs index 5ec553a3..7c3f0ac8 100644 --- a/desktop/src/desktop_system/layout_algorithm.rs +++ b/desktop/src/desktop_system/layout_algorithm.rs @@ -72,7 +72,7 @@ pub(crate) fn place_container_children( child_placements } -impl LayoutAlgorithm for DesktopLayoutAlgorithm<'_> { +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) = diff --git a/desktop/src/hit_tester.rs b/desktop/src/hit_tester.rs index d646475c..f8580af9 100644 --- a/desktop/src/hit_tester.rs +++ b/desktop/src/hit_tester.rs @@ -9,7 +9,7 @@ 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, @@ -49,7 +49,7 @@ 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, diff --git a/desktop/src/projects/project_presenter.rs b/desktop/src/projects/project_presenter.rs index e0d9168a..961829c3 100644 --- a/desktop/src/projects/project_presenter.rs +++ b/desktop/src/projects/project_presenter.rs @@ -1,7 +1,7 @@ use std::{sync::Arc, time::Duration}; use massive_animation::{Animated, Interpolation}; -use massive_geometry::{Color, Point, Rect, Transform}; +use massive_geometry::{Color, Point, Rect, Transform, Vector3}; use massive_scene::{Handle, IntoVisual, Location, Object, Visual}; use massive_shapes::{Shape, StrokeRect}; use massive_shell::Scene; @@ -79,13 +79,28 @@ 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)); - - // Update the hover visual's scene transform to position the rect in 3D. - let center = self.hover_rect.center(); + // Hover shapes are drawn in local coordinates (origin-based rect). + let local_rect = self.hover_rect.size().to_rect(); + let rect_alpha = (alpha != 0.0).then_some((local_rect, alpha)); + + // Position the hover visual in world space. For instances, the layout transform's + // translate IS the center position (possibly offset for visor layout). For launchers, + // the transform is IDENTITY so we derive position from the rect's center. + let local_center = local_rect.center(); + let has_translate = self.hover_transform.translate != Vector3::ZERO; + let center_transform = if has_translate { + self.hover_transform + } else { + let center = self.hover_rect.center(); + Transform::new( + Vector3::new(center.x, center.y, 0.0), + self.hover_transform.rotate, + self.hover_transform.scale, + ) + }; let scene_transform = InstancePresenter::transform_with_layout( - self.hover_transform, - Point::new(center.x, center.y), + center_transform, + Point::new(local_center.x, local_center.y), ); self.hover_scene_transform .update_if_changed(scene_transform); diff --git a/layout/src/incremental_layouter.rs b/layout/src/incremental_layouter.rs index 7545da96..e570cd9b 100644 --- a/layout/src/incremental_layouter.rs +++ b/layout/src/incremental_layouter.rs @@ -7,35 +7,38 @@ //! 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 massive_geometry::Transform; - use crate::dimensional_types::{Offset, Rect, Size}; #[derive(Debug, Clone, Copy, PartialEq)] -pub struct Placement { +pub struct Placement { pub rect: Rect, - pub transform: Transform, + pub transform: T, } #[cfg(test)] use crate::LayoutAxis; #[cfg(test)] use crate::dimensional_types::Thickness; +#[cfg(test)] +use massive_geometry::Transform; -pub struct IncrementalLayouter +pub struct IncrementalLayouter where Id: Eq + Hash + Clone, + T: Debug + Copy + PartialEq + Default, { nodes: HashMap>, - placements: 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() @@ -53,9 +56,10 @@ 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). @@ -66,19 +70,20 @@ where /// 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 3D world space. For flat 2D layouts, return - /// `Transform::IDENTITY` (the default implementation does this). + /// 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<(Offset, Transform)>; + ) -> Vec<(Offset, T)>; } -impl IncrementalLayouter +impl IncrementalLayouter where Id: Eq + Hash + Clone, + T: Debug + Copy + PartialEq + Default, { fn invariant_violation(message: &str) -> ! { panic!("Internal error: {message}") @@ -135,9 +140,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(); @@ -157,7 +162,7 @@ where algorithm, &affected_root, root_offset, - Transform::IDENTITY, + T::default(), &affected, &mut changed, ); @@ -166,7 +171,7 @@ where RecomputeResult { changed } } - pub fn placement(&self, id: &Id) -> Option<&Placement> { + pub fn placement(&self, id: &Id) -> Option<&Placement> { self.placements.get(id) } @@ -174,7 +179,7 @@ where self.placements.get(id).map(|p| &p.rect) } - pub fn transform(&self, id: &Id) -> Option<&Transform> { + pub fn transform(&self, id: &Id) -> Option<&T> { self.placements.get(id).map(|p| &p.transform) } @@ -216,7 +221,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, ) { @@ -254,12 +259,12 @@ 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: Transform, + transform: T, affected: &HashSet, - changed: &mut Vec<(Id, Rect, Transform)>, + changed: &mut Vec<(Id, Rect, T)>, ) { let outer_size = self.cached_outer_size(id); self.update_placement(id, Rect::new(absolute_offset, outer_size), transform, changed); @@ -327,7 +332,7 @@ where &mut self, id: &Id, offset_delta: Offset, - changed: &mut Vec<(Id, Rect, Transform)>, + changed: &mut Vec<(Id, Rect, T)>, ) { if offset_delta == Offset::ZERO { // Common fast path when parent move does not change this branch position. @@ -359,8 +364,8 @@ where &mut self, id: &Id, next_rect: Rect, - next_transform: Transform, - changed: &mut Vec<(Id, Rect, Transform)>, + next_transform: T, + changed: &mut Vec<(Id, Rect, T)>, ) { let next = Placement { rect: next_rect, transform: next_transform }; let is_changed = self @@ -527,8 +532,8 @@ where } #[derive(Debug)] -pub struct RecomputeResult { - pub changed: Vec<(Id, Rect, Transform)>, +pub struct RecomputeResult { + pub changed: Vec<(Id, Rect, T)>, } #[cfg(test)] @@ -537,6 +542,8 @@ mod tests { use std::cmp::max; use std::collections::{HashMap, HashSet}; + type TestLayouter = IncrementalLayouter; + #[derive(Default)] struct TestTopology { nodes: HashSet, @@ -584,7 +591,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; @@ -755,7 +762,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, @@ -777,7 +784,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, @@ -808,7 +815,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, @@ -835,7 +842,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, @@ -881,7 +888,7 @@ mod tests { 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, @@ -930,7 +937,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, @@ -968,7 +975,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, @@ -1002,7 +1009,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, @@ -1025,7 +1032,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, @@ -1051,7 +1058,7 @@ mod tests { 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, @@ -1078,7 +1085,7 @@ mod tests { 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, @@ -1105,7 +1112,7 @@ mod tests { 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, @@ -1145,7 +1152,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, @@ -1162,7 +1169,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) } @@ -1182,7 +1189,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, @@ -1199,7 +1206,7 @@ mod tests { } fn upsert_leaf( - layouter: &mut IncrementalLayouter, + layouter: &mut TestLayouter, topology: &mut TestTopology, algorithm: &mut TestAlgorithm, id: usize, @@ -1211,7 +1218,7 @@ mod tests { } fn upsert_container( - layouter: &mut IncrementalLayouter, + layouter: &mut TestLayouter, topology: &mut TestTopology, algorithm: &mut TestAlgorithm, id: usize, @@ -1223,7 +1230,7 @@ mod tests { } fn set_children( - layouter: &mut IncrementalLayouter, + layouter: &mut TestLayouter, topology: &mut TestTopology, id: usize, children: Vec, @@ -1234,7 +1241,7 @@ mod tests { } fn set_intrinsic_size( - layouter: &mut IncrementalLayouter, + layouter: &mut TestLayouter, algorithm: &mut TestAlgorithm, id: usize, size: impl Into>, @@ -1245,7 +1252,7 @@ mod tests { } fn set_padding( - layouter: &mut IncrementalLayouter, + layouter: &mut TestLayouter, algorithm: &mut TestAlgorithm, id: usize, padding: impl Into>, @@ -1256,7 +1263,7 @@ mod tests { } fn set_spacing( - layouter: &mut IncrementalLayouter, + layouter: &mut TestLayouter, algorithm: &mut TestAlgorithm, id: usize, spacing: u32, @@ -1267,7 +1274,7 @@ mod tests { } fn remove_node( - layouter: &mut IncrementalLayouter, + layouter: &mut TestLayouter, topology: &mut TestTopology, algorithm: &mut TestAlgorithm, id: usize, From 93e67640d3e171a9c0366a290368d3a3bd671fd6 Mon Sep 17 00:00:00 2001 From: Armin Sander Date: Tue, 21 Apr 2026 14:54:18 +0200 Subject: [PATCH 05/13] Refactor keyboard focus propagation and placement --- .github/copilot-instructions.md | 2 + desktop/src/desktop_system.rs | 15 ++---- desktop/src/desktop_system/focus_input.rs | 8 +-- .../src/desktop_system/layout_algorithm.rs | 14 ++--- desktop/src/desktop_system/layout_effects.rs | 53 ++++++++----------- desktop/src/event_router.rs | 25 +++++---- desktop/src/instance_presenter.rs | 5 +- desktop/src/projects/project_presenter.rs | 15 +++--- layout/src/incremental_layouter.rs | 47 ++++++++-------- 9 files changed, 85 insertions(+), 99 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4af399c4..3dc6328f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -23,12 +23,14 @@ 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. - 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 e91d4d1b..f04d046e 100644 --- a/desktop/src/desktop_system.rs +++ b/desktop/src/desktop_system.rs @@ -27,7 +27,7 @@ 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_layout::{IncrementalLayouter, LayoutTopology, Placement}; use massive_scene::{Location, Object, Transform}; use massive_shell::{FontManager, Scene}; @@ -224,17 +224,8 @@ impl DesktopSystem { }) } - fn rect_and_transform(&self, target: &DesktopTarget) -> Option<(Rect, Transform)> { - let rect = self.layouter.rect(target).map(|rect| { - let rect_px: RectPx = (*rect).into(); - Rect::from(rect_px) - })?; - let transform = self - .layouter - .transform(target) - .copied() - .unwrap_or(Transform::IDENTITY); - Some((rect, transform)) + fn placement(&self, target: &DesktopTarget) -> Option> { + self.layouter.placement(target).copied() } } diff --git a/desktop/src/desktop_system/focus_input.rs b/desktop/src/desktop_system/focus_input.rs index 014667c2..921db5a6 100644 --- a/desktop/src/desktop_system/focus_input.rs +++ b/desktop/src/desktop_system/focus_input.rs @@ -35,9 +35,7 @@ impl DesktopSystem { ); let transitions = self.event_router.process(event, &hit_tester)?; - if let Some((from, to)) = transitions.keyboard_focus_change() { - self.invalidate_layout_for_focus_change(from, to); - } + self.invalidate_layout_for_focus_change(transitions.keyboard_focus_change()); self.forward_event_transitions(transitions, instance_manager)? }; @@ -77,9 +75,7 @@ impl DesktopSystem { instance_manager: &InstanceManager, ) -> Result<()> { let transitions = self.event_router.focus(target); - if let Some((from, to)) = transitions.keyboard_focus_change() { - self.invalidate_layout_for_focus_change(from, to); - } + self.invalidate_layout_for_focus_change(transitions.keyboard_focus_change()); // Invariant: Programmatic focus changes must not trigger commands. assert!( diff --git a/desktop/src/desktop_system/layout_algorithm.rs b/desktop/src/desktop_system/layout_algorithm.rs index 7c3f0ac8..500893ea 100644 --- a/desktop/src/desktop_system/layout_algorithm.rs +++ b/desktop/src/desktop_system/layout_algorithm.rs @@ -119,11 +119,9 @@ impl LayoutAlgorithm for DesktopLayoutAlgorithm<'_> let launcher = &self.aggregates.launchers[launcher_id]; // Compute child offsets: custom (Visor) or default container layout (Band). - let child_placements = if let Some(placements) = launcher.panel_child_offsets( - parent_offset, - child_sizes, - self.default_panel_size, - ) { + let child_placements = if let Some(placements) = + launcher.panel_child_offsets(parent_offset, child_sizes, self.default_panel_size) + { placements } else { match self.resolve_layout_spec(id) { @@ -157,10 +155,8 @@ impl LayoutAlgorithm for DesktopLayoutAlgorithm<'_> }) .collect(); - let layout_targets = launcher.compute_instance_layout_targets( - &instance_inputs, - self.focused_instance, - ); + let layout_targets = + launcher.compute_instance_layout_targets(&instance_inputs, self.focused_instance); // Rebuild placements: match each child with its computed transform. let mut transform_by_instance: std::collections::HashMap = diff --git a/desktop/src/desktop_system/layout_effects.rs b/desktop/src/desktop_system/layout_effects.rs index 57f25627..215137d6 100644 --- a/desktop/src/desktop_system/layout_effects.rs +++ b/desktop/src/desktop_system/layout_effects.rs @@ -3,7 +3,7 @@ use anyhow::Result; use massive_animation::Interpolation; use massive_applications::InstanceId; use massive_geometry::{PointPx, Rect, RectPx, Transform}; -use massive_layout::Rect as LayoutRect; +use massive_layout::Placement; use super::{DesktopLayoutAlgorithm, DesktopSystem, DesktopTarget, TransactionEffectsMode}; use crate::focus_path::PathResolver; @@ -67,12 +67,13 @@ impl DesktopSystem { fn apply_layout_changes( &mut self, - changed: Vec<(DesktopTarget, LayoutRect<2>, Transform)>, + changed: Vec<(DesktopTarget, Placement<2, Transform>)>, animate: bool, ) { - for (id, layout_rect, transform) in changed { - let rect_px: RectPx = layout_rect.into(); + for (id, placement) in changed { + let rect_px: RectPx = placement.rect.into(); let rect: Rect = rect_px.into(); + let transform = placement.transform; match id { DesktopTarget::Desktop => {} @@ -112,13 +113,22 @@ impl DesktopSystem { } } + /// 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, + targets: impl IntoIterator, + ) { + for target in targets { + if let Some(launcher_id) = self.focus_target_launcher_for_layout(target) { + self.layouter + .mark_reflow_pending(DesktopTarget::Launcher(launcher_id)); + } + } + } + /// 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: Option<&DesktopTarget>, - ) -> Option { - let target = target?; + 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)?; @@ -135,27 +145,10 @@ impl DesktopSystem { .map(|_| launcher_id) } - /// Marks launchers that need relayout due to a keyboard focus change as reflow-pending. - pub(super) fn invalidate_layout_for_focus_change( - &mut self, - from: Option<&DesktopTarget>, - to: Option<&DesktopTarget>, - ) { - for target in [from, to] { - if let Some(launcher_id) = self.focus_target_launcher_for_layout(target) { - self.layouter - .mark_reflow_pending(DesktopTarget::Launcher(launcher_id)); - } - } - } - - fn sync_hover_rect_to_pointer_path( - &mut self, - pointer_focus: Option<&DesktopTarget>, - ) { + 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_and_transform(&DesktopTarget::Instance(*instance_id)) + self.placement(&DesktopTarget::Instance(*instance_id)) } Some(DesktopTarget::View(view_id)) => match self .aggregates @@ -163,13 +156,13 @@ impl DesktopSystem { .parent(&DesktopTarget::View(*view_id)) { Some(DesktopTarget::Instance(instance_id)) => { - self.rect_and_transform(&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_and_transform(&DesktopTarget::Launcher(*launcher_id)) + self.placement(&DesktopTarget::Launcher(*launcher_id)) } _ => None, }; 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/instance_presenter.rs b/desktop/src/instance_presenter.rs index eb34f7df..c053aa61 100644 --- a/desktop/src/instance_presenter.rs +++ b/desktop/src/instance_presenter.rs @@ -102,10 +102,7 @@ impl InstancePresenter { let local_center = background.local_rect.center(); background .transform - .update_if_changed(Self::transform_with_layout( - layout_transform, - local_center, - )); + .update_if_changed(Self::transform_with_layout(layout_transform, local_center)); } // Feature: Hiding animation. diff --git a/desktop/src/projects/project_presenter.rs b/desktop/src/projects/project_presenter.rs index 961829c3..b965e175 100644 --- a/desktop/src/projects/project_presenter.rs +++ b/desktop/src/projects/project_presenter.rs @@ -1,7 +1,8 @@ use std::{sync::Arc, time::Duration}; use massive_animation::{Animated, Interpolation}; -use massive_geometry::{Color, Point, Rect, Transform, Vector3}; +use massive_geometry::{Color, Point, Rect, RectPx, Transform, Vector3}; +use massive_layout::Placement; use massive_scene::{Handle, IntoVisual, Location, Object, Visual}; use massive_shapes::{Shape, StrokeRect}; use massive_shell::Scene; @@ -36,8 +37,7 @@ impl ProjectPresenter { 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); + let hover_location = Location::new(None, hover_scene_transform.clone()).enter(scene); Self { location: location.clone(), @@ -55,17 +55,20 @@ impl ProjectPresenter { const HOVER_ANIMATION_DURATION: Duration = Duration::from_millis(250); - pub fn set_hover_rect(&mut self, placement: Option<(Rect, Transform)>) { + pub fn set_hover_rect(&mut self, placement: Option>) { match placement { - Some((rect, transform)) => { + Some(placement) => { self.hover_alpha.animate_if_changed( 1.0, Self::HOVER_ANIMATION_DURATION, Interpolation::CubicOut, ); + let rect_px: RectPx = placement.rect.into(); + let rect: Rect = rect_px.into(); + self.hover_rect = rect; - self.hover_transform = transform; + self.hover_transform = placement.transform; } None => { self.hover_alpha.animate_if_changed( diff --git a/layout/src/incremental_layouter.rs b/layout/src/incremental_layouter.rs index e570cd9b..3f26a9f8 100644 --- a/layout/src/incremental_layouter.rs +++ b/layout/src/incremental_layouter.rs @@ -3,8 +3,6 @@ //! 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; @@ -18,13 +16,6 @@ pub struct Placement { pub transform: T, } -#[cfg(test)] -use crate::LayoutAxis; -#[cfg(test)] -use crate::dimensional_types::Thickness; -#[cfg(test)] -use massive_geometry::Transform; - pub struct IncrementalLayouter where Id: Eq + Hash + Clone, @@ -118,7 +109,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. @@ -264,10 +255,15 @@ where absolute_offset: Offset, transform: T, affected: &HashSet, - changed: &mut Vec<(Id, Rect, T)>, + changed: &mut Vec<(Id, Placement)>, ) { let outer_size = self.cached_outer_size(id); - self.update_placement(id, Rect::new(absolute_offset, outer_size), transform, changed); + self.update_placement( + id, + Rect::new(absolute_offset, outer_size), + transform, + changed, + ); let cached_children = self .nodes @@ -316,9 +312,12 @@ where } if transform_changed { let current_rect = self.placements.get(child).map_or(previous.rect, |p| p.rect); - let next = Placement { rect: current_rect, transform: *child_transform }; + let next = Placement { + rect: current_rect, + transform: *child_transform, + }; self.placements.insert(child.clone(), next); - changed.push((child.clone(), current_rect, *child_transform)); + changed.push((child.clone(), next)); } } } @@ -332,7 +331,7 @@ where &mut self, id: &Id, offset_delta: Offset, - changed: &mut Vec<(Id, Rect, T)>, + changed: &mut Vec<(Id, Placement)>, ) { if offset_delta == Offset::ZERO { // Common fast path when parent move does not change this branch position. @@ -365,16 +364,19 @@ where id: &Id, next_rect: Rect, next_transform: T, - changed: &mut Vec<(Id, Rect, T)>, + changed: &mut Vec<(Id, Placement)>, ) { - let next = Placement { rect: next_rect, transform: next_transform }; + let next = Placement { + rect: next_rect, + transform: next_transform, + }; let is_changed = self .placements .get(id) .is_none_or(|current| current != &next); if is_changed { self.placements.insert(id.clone(), next); - changed.push((id.clone(), next_rect, next_transform)); + changed.push((id.clone(), next)); } } @@ -533,12 +535,15 @@ where #[derive(Debug)] pub struct RecomputeResult { - pub changed: Vec<(Id, Rect, T)>, + 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}; @@ -876,7 +881,7 @@ mod tests { set_intrinsic_size(&mut layouter, &mut algorithm, 1, [12, 10]); let update = layouter.recompute(&topology, &algorithm, [0, 0]); - let mut changed_ids: Vec = update.changed.into_iter().map(|(id, ..)| id).collect(); + let mut changed_ids: Vec = update.changed.into_iter().map(|(id, _)| id).collect(); changed_ids.sort_unstable(); assert_eq!(changed_ids, vec![0, 1, 10]); @@ -1135,7 +1140,7 @@ mod tests { let mut changed_ids: Vec = parent_marked_update .changed .into_iter() - .map(|(id, ..)| id) + .map(|(id, _)| id) .collect(); changed_ids.sort_unstable(); From eed3868612b1ee2b9b1b268cc85ab4e197066c7e Mon Sep 17 00:00:00 2001 From: Armin Sander Date: Thu, 23 Apr 2026 10:41:26 +0200 Subject: [PATCH 06/13] More refactoring of the layout engine --- .github/copilot-instructions.md | 2 + desktop/src/desktop_system.rs | 4 +- .../src/desktop_system/layout_algorithm.rs | 26 ++-- desktop/src/desktop_system/layout_effects.rs | 2 +- desktop/src/hit_tester.rs | 4 +- desktop/src/projects/launcher_presenter.rs | 6 +- desktop/src/projects/project_presenter.rs | 6 +- layout/src/incremental_layouter.rs | 112 +++++++++--------- 8 files changed, 84 insertions(+), 78 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3dc6328f..739bed9c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -24,6 +24,8 @@ Update it whenever you learn something new about the project's patterns, convent - 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. +- 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 diff --git a/desktop/src/desktop_system.rs b/desktop/src/desktop_system.rs index f04d046e..e4b0729b 100644 --- a/desktop/src/desktop_system.rs +++ b/desktop/src/desktop_system.rs @@ -80,7 +80,7 @@ pub struct DesktopSystem { pointer_feedback_enabled: bool, #[debug(skip)] - layouter: IncrementalLayouter, + layouter: IncrementalLayouter, aggregates: Aggregates, } @@ -224,7 +224,7 @@ impl DesktopSystem { }) } - fn placement(&self, target: &DesktopTarget) -> Option> { + fn placement(&self, target: &DesktopTarget) -> Option> { self.layouter.placement(target).copied() } } diff --git a/desktop/src/desktop_system/layout_algorithm.rs b/desktop/src/desktop_system/layout_algorithm.rs index 500893ea..63d74fb4 100644 --- a/desktop/src/desktop_system/layout_algorithm.rs +++ b/desktop/src/desktop_system/layout_algorithm.rs @@ -1,7 +1,9 @@ use std::cmp::max; use massive_geometry::{RectPx, SizePx, Transform}; -use massive_layout::{LayoutAlgorithm, LayoutAxis, Offset, Rect as LayoutRect, Size}; +use massive_layout::{ + LayoutAlgorithm, LayoutAxis, Offset, Rect as LayoutRect, Size, TransformOffset, +}; use massive_applications::InstanceId; @@ -57,7 +59,7 @@ pub(crate) fn place_container_children( spacing: i32, mut offset: Offset<2>, child_sizes: &[Size<2>], -) -> Vec<(Offset<2>, Transform)> { +) -> Vec> { let axis_index: usize = axis.into(); let mut child_placements = Vec::with_capacity(child_sizes.len()); @@ -65,14 +67,14 @@ pub(crate) fn place_container_children( if index > 0 { offset[axis_index] += spacing; } - child_placements.push((offset, Transform::IDENTITY)); + child_placements.push(TransformOffset::new(Transform::IDENTITY, offset)); offset[axis_index] += child_size[axis_index] as i32; } child_placements } -impl LayoutAlgorithm for DesktopLayoutAlgorithm<'_> { +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) = @@ -114,7 +116,7 @@ impl LayoutAlgorithm for DesktopLayoutAlgorithm<'_> id: &DesktopTarget, parent_offset: Offset<2>, child_sizes: &[Size<2>], - ) -> Vec<(Offset<2>, Transform)> { + ) -> Vec> { if let DesktopTarget::Launcher(launcher_id) = id { let launcher = &self.aggregates.launchers[launcher_id]; @@ -142,10 +144,10 @@ impl LayoutAlgorithm for DesktopLayoutAlgorithm<'_> let instance_inputs: Vec = children .iter() .zip(child_placements.iter().zip(child_sizes.iter())) - .filter_map(|(target, ((offset, _), size))| match target { + .filter_map(|(target, (child_transform_offset, size))| match target { DesktopTarget::Instance(instance_id) => { - let layout_rect = LayoutRect::new(*offset, *size); - let rect_px: RectPx = layout_rect.into(); + let rect_px: RectPx = + LayoutRect::new(child_transform_offset.offset, *size).into(); Some(LauncherInstanceLayoutInput { instance_id: *instance_id, rect: rect_px, @@ -168,14 +170,14 @@ impl LayoutAlgorithm for DesktopLayoutAlgorithm<'_> return children .iter() .zip(child_placements) - .map(|(target, (offset, default_transform))| { + .map(|(target, child_transform_offset)| { let transform = match target { DesktopTarget::Instance(instance_id) => transform_by_instance .remove(instance_id) - .unwrap_or(default_transform), - _ => default_transform, + .unwrap_or(child_transform_offset.transform), + _ => child_transform_offset.transform, }; - (offset, transform) + TransformOffset::new(transform, child_transform_offset.offset) }) .collect(); } diff --git a/desktop/src/desktop_system/layout_effects.rs b/desktop/src/desktop_system/layout_effects.rs index 215137d6..f89fb3de 100644 --- a/desktop/src/desktop_system/layout_effects.rs +++ b/desktop/src/desktop_system/layout_effects.rs @@ -67,7 +67,7 @@ impl DesktopSystem { fn apply_layout_changes( &mut self, - changed: Vec<(DesktopTarget, Placement<2, Transform>)>, + changed: Vec<(DesktopTarget, Placement)>, animate: bool, ) { for (id, placement) in changed { diff --git a/desktop/src/hit_tester.rs b/desktop/src/hit_tester.rs index f8580af9..309596d9 100644 --- a/desktop/src/hit_tester.rs +++ b/desktop/src/hit_tester.rs @@ -9,7 +9,7 @@ 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, @@ -49,7 +49,7 @@ 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, diff --git a/desktop/src/projects/launcher_presenter.rs b/desktop/src/projects/launcher_presenter.rs index abd91e31..247d91a2 100644 --- a/desktop/src/projects/launcher_presenter.rs +++ b/desktop/src/projects/launcher_presenter.rs @@ -8,7 +8,7 @@ use massive_animation::{Animated, Interpolation}; use massive_applications::{InstanceId, ViewEvent}; use massive_geometry::{Color, Quaternion, Rect, RectPx, 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}; @@ -149,7 +149,7 @@ impl LauncherPresenter { parent_offset: Offset<2>, child_sizes: &[LayoutSize<2>], default_panel_size: SizePx, - ) -> Option, Transform)>> { + ) -> Option>> { match self.mode { LauncherMode::Band => None, LauncherMode::Visor => Some(centered_horizontal_offsets( @@ -343,7 +343,7 @@ fn centered_horizontal_offsets( parent_offset: Offset<2>, child_sizes: &[LayoutSize<2>], panel_width: i32, -) -> Vec<(Offset<2>, Transform)> { +) -> 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/project_presenter.rs b/desktop/src/projects/project_presenter.rs index b965e175..5bb226f2 100644 --- a/desktop/src/projects/project_presenter.rs +++ b/desktop/src/projects/project_presenter.rs @@ -23,8 +23,8 @@ 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_transform: Transform, + hover_rect: Rect, hover_scene_transform: Handle, hover_location: Handle, // Idea: can't we just animate a visual / Handle? @@ -42,8 +42,8 @@ impl ProjectPresenter { Self { location: location.clone(), hover_alpha: scene.animated(0.0), - hover_rect: Rect::ZERO, hover_transform: Transform::IDENTITY, + hover_rect: Rect::ZERO, hover_scene_transform, hover_location: hover_location.clone(), hover_visual: create_hover_shapes(None) @@ -55,7 +55,7 @@ impl ProjectPresenter { const HOVER_ANIMATION_DURATION: Duration = Duration::from_millis(250); - pub fn set_hover_rect(&mut self, placement: Option>) { + pub fn set_hover_rect(&mut self, placement: Option>) { match placement { Some(placement) => { self.hover_alpha.animate_if_changed( diff --git a/layout/src/incremental_layouter.rs b/layout/src/incremental_layouter.rs index 3f26a9f8..e0d70dcf 100644 --- a/layout/src/incremental_layouter.rs +++ b/layout/src/incremental_layouter.rs @@ -8,25 +8,33 @@ use std::collections::{HashMap, HashSet}; use std::fmt::Debug; use std::hash::Hash; +use derive_more::Constructor; + use crate::dimensional_types::{Offset, Rect, Size}; -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Placement { +#[derive(Constructor, Debug, Clone, Copy, PartialEq)] +pub struct Placement { + pub transform: T, pub rect: Rect, +} + +#[derive(Constructor, Debug, Clone, Copy, PartialEq)] +pub struct TransformOffset { pub transform: T, + pub offset: Offset, } -pub struct IncrementalLayouter +pub struct IncrementalLayouter where Id: Eq + Hash + Clone, T: Debug + Copy + PartialEq + Default, { nodes: HashMap>, - placements: HashMap>, + placements: HashMap>, reflow_pending: HashSet, } -impl Default for IncrementalLayouter +impl Default for IncrementalLayouter where Id: Eq + Hash + Clone, T: Debug + Copy + PartialEq + Default, @@ -47,7 +55,7 @@ where fn parent_of(&self, id: &Id) -> Option; } -pub trait LayoutAlgorithm +pub trait LayoutAlgorithm where Id: Eq + Hash + Clone, T: Debug + Copy + PartialEq + Default, @@ -57,8 +65,8 @@ where /// For leaf nodes `child_sizes` is empty. fn measure(&self, id: &Id, child_sizes: &[Size]) -> Size; - /// Returns one `(absolute_child_offset, transform)` pair per entry in `child_sizes`, in the - /// same order. `parent_offset` is the absolute position of `id`. + /// 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 @@ -68,10 +76,10 @@ where id: &Id, parent_offset: Offset, child_sizes: &[Size], - ) -> Vec<(Offset, T)>; + ) -> Vec>; } -impl IncrementalLayouter +impl IncrementalLayouter where Id: Eq + Hash + Clone, T: Debug + Copy + PartialEq + Default, @@ -131,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(); @@ -152,8 +160,7 @@ where self.place_subtree_recursive( algorithm, &affected_root, - root_offset, - T::default(), + TransformOffset::new(T::default(), root_offset), &affected, &mut changed, ); @@ -162,7 +169,7 @@ where RecomputeResult { changed } } - pub fn placement(&self, id: &Id) -> Option<&Placement> { + pub fn placement(&self, id: &Id) -> Option<&Placement> { self.placements.get(id) } @@ -212,7 +219,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, ) { @@ -250,20 +257,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: T, + transform_offset: TransformOffset, affected: &HashSet, - changed: &mut Vec<(Id, Placement)>, + changed: &mut Vec<(Id, Placement)>, ) { let outer_size = self.cached_outer_size(id); - self.update_placement( - id, - Rect::new(absolute_offset, outer_size), - transform, - 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 @@ -279,22 +284,22 @@ where .iter() .map(|child| self.cached_outer_size(child)) .collect(); - let child_placements = algorithm.place_children(id, absolute_offset, &child_sizes); - if child_placements.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, child_transform)) in - cached_children.iter().zip(child_placements.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, - *child_transform, + *child_transform_offset, affected, changed, ); @@ -303,19 +308,17 @@ where let previous = self.placements.get(child).copied().unwrap_or_else(|| { Self::invariant_violation("clean child missing placement during placement") }); - let offset_changed = previous.rect.offset != *child_offset; - let transform_changed = previous.transform != *child_transform; + 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_offset, previous.rect.offset); + 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 { - rect: current_rect, - transform: *child_transform, - }; + let next = Placement::new(child_transform_offset.transform, current_rect); self.placements.insert(child.clone(), next); changed.push((child.clone(), next)); } @@ -331,7 +334,7 @@ where &mut self, id: &Id, offset_delta: Offset, - changed: &mut Vec<(Id, Placement)>, + changed: &mut Vec<(Id, Placement)>, ) { if offset_delta == Offset::ZERO { // Common fast path when parent move does not change this branch position. @@ -342,8 +345,9 @@ where Self::invariant_violation("shift traversal encountered missing placement") }); 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, shifted, current.transform, changed); + self.update_placement(id, next, changed); let cached_children = self .nodes @@ -362,14 +366,9 @@ where fn update_placement( &mut self, id: &Id, - next_rect: Rect, - next_transform: T, - changed: &mut Vec<(Id, Placement)>, + next: Placement, + changed: &mut Vec<(Id, Placement)>, ) { - let next = Placement { - rect: next_rect, - transform: next_transform, - }; let is_changed = self .placements .get(id) @@ -534,8 +533,8 @@ where } #[derive(Debug)] -pub struct RecomputeResult { - pub changed: Vec<(Id, Placement)>, +pub struct RecomputeResult { + pub changed: Vec<(Id, Placement)>, } #[cfg(test)] @@ -547,7 +546,7 @@ mod tests { use std::cmp::max; use std::collections::{HashMap, HashSet}; - type TestLayouter = IncrementalLayouter; + type TestLayouter = IncrementalLayouter; #[derive(Default)] struct TestTopology { @@ -596,7 +595,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; @@ -629,7 +628,7 @@ mod tests { id: &usize, parent_offset: Offset<2>, child_sizes: &[Size<2>], - ) -> Vec<(Offset<2>, Transform)> { + ) -> Vec> { let spec = self .container_specs .get(id) @@ -643,7 +642,10 @@ mod tests { if index > 0 { cursor[axis] += spacing as i32; } - placements.push((parent_offset + cursor, Transform::IDENTITY)); + placements.push(TransformOffset::new( + Transform::IDENTITY, + parent_offset + cursor, + )); cursor[axis] += child_size[axis] as i32; } placements @@ -1174,7 +1176,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) } @@ -1184,7 +1186,7 @@ mod tests { _id: &usize, _parent_offset: Offset<2>, _child_sizes: &[Size<2>], - ) -> Vec<(Offset<2>, Transform)> { + ) -> Vec> { Vec::new() } } From 8498411b12a2a0f4a4039ef5541a94631a7bb953 Mon Sep 17 00:00:00 2001 From: Armin Sander Date: Thu, 23 Apr 2026 10:59:51 +0200 Subject: [PATCH 07/13] Refactor the layout algorithm to improve clarity and add some comments --- .../src/desktop_system/layout_algorithm.rs | 245 ++++++++++-------- 1 file changed, 130 insertions(+), 115 deletions(-) diff --git a/desktop/src/desktop_system/layout_algorithm.rs b/desktop/src/desktop_system/layout_algorithm.rs index 63d74fb4..2fb4de5f 100644 --- a/desktop/src/desktop_system/layout_algorithm.rs +++ b/desktop/src/desktop_system/layout_algorithm.rs @@ -19,62 +19,22 @@ pub(super) struct DesktopLayoutAlgorithm<'a> { 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_placements = Vec::with_capacity(child_sizes.len()); - - for (index, &child_size) in child_sizes.iter().enumerate() { - if index > 0 { - offset[axis_index] += spacing; - } - child_placements.push(TransformOffset::new(Transform::IDENTITY, offset)); - offset[axis_index] += child_size[axis_index] as i32; + self.place_standard_children(id, parent_offset, child_sizes) } - child_placements -} - -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) = @@ -110,78 +70,81 @@ 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 launcher = &self.aggregates.launchers[launcher_id]; - - // Compute child offsets: custom (Visor) or default container layout (Band). - let child_placements = if let Some(placements) = - launcher.panel_child_offsets(parent_offset, child_sizes, self.default_panel_size) - { - placements - } else { - match self.resolve_layout_spec(id) { - LayoutSpec::Container { - axis, - padding, - spacing, - } => { - let offset = parent_offset + Offset::from(padding.leading); - place_container_children(axis, spacing as i32, offset, child_sizes) - } - _ => Vec::new(), + 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) + { + 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, + }) } - }; - - // Compute 3D transforms for instance children. - let children = self.aggregates.hierarchy.get_nested(id); - 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(); + _ => None, + }) + .collect(); + + let layout_targets = + launcher.compute_instance_layout_targets(&instance_inputs, self.focused_instance); - let layout_targets = - launcher.compute_instance_layout_targets(&instance_inputs, self.focused_instance); - - // Rebuild placements: match each child with its computed transform. - let mut transform_by_instance: std::collections::HashMap = - layout_targets - .into_iter() - .map(|lt| (lt.instance_id, lt.layout_transform)) - .collect(); - - return 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) - }) + let mut transform_by_instance: std::collections::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 { @@ -190,9 +153,61 @@ 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; + } + child_placements.push(TransformOffset::new(Transform::IDENTITY, offset)); + offset[axis_index] += child_size[axis_index] as i32; + } + + child_placements } From 5b171d59f78e36df5bf027df71ad97e5dfa98355 Mon Sep 17 00:00:00 2001 From: Armin Sander Date: Thu, 23 Apr 2026 11:04:47 +0200 Subject: [PATCH 08/13] set_hover_rect -> set_hover_placement --- desktop/src/desktop_system/layout_effects.rs | 2 +- desktop/src/projects/project_presenter.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src/desktop_system/layout_effects.rs b/desktop/src/desktop_system/layout_effects.rs index f89fb3de..45dea74d 100644 --- a/desktop/src/desktop_system/layout_effects.rs +++ b/desktop/src/desktop_system/layout_effects.rs @@ -169,6 +169,6 @@ impl DesktopSystem { self.aggregates .project_presenter - .set_hover_rect(hover_placement); + .set_hover_placement(hover_placement); } } diff --git a/desktop/src/projects/project_presenter.rs b/desktop/src/projects/project_presenter.rs index 5bb226f2..cbd24138 100644 --- a/desktop/src/projects/project_presenter.rs +++ b/desktop/src/projects/project_presenter.rs @@ -55,7 +55,7 @@ impl ProjectPresenter { const HOVER_ANIMATION_DURATION: Duration = Duration::from_millis(250); - pub fn set_hover_rect(&mut self, placement: Option>) { + pub fn set_hover_placement(&mut self, placement: Option>) { match placement { Some(placement) => { self.hover_alpha.animate_if_changed( From dcd2c7ad2c97eab91410e4e7e257c9404f63f948 Mon Sep 17 00:00:00 2001 From: Armin Sander Date: Thu, 23 Apr 2026 11:46:23 +0200 Subject: [PATCH 09/13] hover presentation: Use the placement type --- desktop/src/projects/project_presenter.rs | 32 +++++++++++------------ 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/desktop/src/projects/project_presenter.rs b/desktop/src/projects/project_presenter.rs index cbd24138..899da629 100644 --- a/desktop/src/projects/project_presenter.rs +++ b/desktop/src/projects/project_presenter.rs @@ -2,7 +2,7 @@ use std::{sync::Arc, time::Duration}; use massive_animation::{Animated, Interpolation}; use massive_geometry::{Color, Point, Rect, RectPx, Transform, Vector3}; -use massive_layout::Placement; +use massive_layout::{Placement, Rect as LayoutRect}; use massive_scene::{Handle, IntoVisual, Location, Object, Visual}; use massive_shapes::{Shape, StrokeRect}; use massive_shell::Scene; @@ -23,8 +23,7 @@ pub struct ProjectPresenter { // Idea: Use a type that combines Alpha with another Interpolatable type. // Robustness: Alpha should be a type. hover_alpha: Animated, - hover_transform: Transform, - hover_rect: Rect, + hover_placement: Placement, hover_scene_transform: Handle, hover_location: Handle, // Idea: can't we just animate a visual / Handle? @@ -42,8 +41,7 @@ impl ProjectPresenter { Self { location: location.clone(), hover_alpha: scene.animated(0.0), - hover_transform: Transform::IDENTITY, - 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) @@ -63,12 +61,7 @@ impl ProjectPresenter { Self::HOVER_ANIMATION_DURATION, Interpolation::CubicOut, ); - - let rect_px: RectPx = placement.rect.into(); - let rect: Rect = rect_px.into(); - - self.hover_rect = rect; - self.hover_transform = placement.transform; + self.hover_placement = placement; } None => { self.hover_alpha.animate_if_changed( @@ -82,23 +75,28 @@ impl ProjectPresenter { pub fn apply_animations(&mut self) { let alpha = self.hover_alpha.value(); + let hover_placement = self.hover_placement; + + let rect_px: RectPx = hover_placement.rect.into(); + let hover_rect: Rect = rect_px.into(); + // Hover shapes are drawn in local coordinates (origin-based rect). - let local_rect = self.hover_rect.size().to_rect(); + let local_rect = hover_rect.size().to_rect(); let rect_alpha = (alpha != 0.0).then_some((local_rect, alpha)); // Position the hover visual in world space. For instances, the layout transform's // translate IS the center position (possibly offset for visor layout). For launchers, // the transform is IDENTITY so we derive position from the rect's center. let local_center = local_rect.center(); - let has_translate = self.hover_transform.translate != Vector3::ZERO; + let has_translate = hover_placement.transform.translate != Vector3::ZERO; let center_transform = if has_translate { - self.hover_transform + hover_placement.transform } else { - let center = self.hover_rect.center(); + let center = hover_rect.center(); Transform::new( Vector3::new(center.x, center.y, 0.0), - self.hover_transform.rotate, - self.hover_transform.scale, + hover_placement.transform.rotate, + hover_placement.transform.scale, ) }; let scene_transform = InstancePresenter::transform_with_layout( From 2d01174edc871a730595608d8dda897f7b38991d Mon Sep 17 00:00:00 2001 From: Armin Sander Date: Fri, 24 Apr 2026 09:42:04 +0200 Subject: [PATCH 10/13] Layout now returns a Transform and a size (the transform points to the center) and hit testing uses perspective correct depth and also uses the transforms returned by the layout --- desktop/src/desktop_system.rs | 9 +- desktop/src/desktop_system/focus_input.rs | 2 - .../src/desktop_system/layout_algorithm.rs | 17 +- desktop/src/desktop_system/layout_effects.rs | 12 +- desktop/src/desktop_system/navigation.rs | 57 ++++--- .../src/desktop_system/project_commands.rs | 2 +- desktop/src/hit_tester.rs | 159 +++++++++--------- desktop/src/instance_presenter.rs | 7 +- desktop/src/projects/launcher_presenter.rs | 42 +++-- desktop/src/projects/project_presenter.rs | 30 +--- 10 files changed, 166 insertions(+), 171 deletions(-) diff --git a/desktop/src/desktop_system.rs b/desktop/src/desktop_system.rs index e4b0729b..3e0d0424 100644 --- a/desktop/src/desktop_system.rs +++ b/desktop/src/desktop_system.rs @@ -26,7 +26,7 @@ use std::time::Duration; use massive_animation::Animated; use massive_applications::{InstanceId, ViewId}; -use massive_geometry::{PixelCamera, Rect, RectPx, SizePx}; +use massive_geometry::{PixelCamera, SizePx}; use massive_layout::{IncrementalLayouter, LayoutTopology, Placement}; use massive_scene::{Location, Object, Transform}; use massive_shell::{FontManager, Scene}; @@ -217,13 +217,6 @@ 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/focus_input.rs b/desktop/src/desktop_system/focus_input.rs index 921db5a6..c6e0511b 100644 --- a/desktop/src/desktop_system/focus_input.rs +++ b/desktop/src/desktop_system/focus_input.rs @@ -30,7 +30,6 @@ impl DesktopSystem { &self.aggregates.hierarchy, &self.layouter, &self.aggregates.launchers, - &self.aggregates.instances, render_geometry, ); @@ -117,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 2fb4de5f..0070feb2 100644 --- a/desktop/src/desktop_system/layout_algorithm.rs +++ b/desktop/src/desktop_system/layout_algorithm.rs @@ -1,6 +1,7 @@ use std::cmp::max; +use std::collections::HashMap; -use massive_geometry::{RectPx, SizePx, Transform}; +use massive_geometry::{RectPx, SizePx, Transform, Vector3}; use massive_layout::{ LayoutAlgorithm, LayoutAxis, Offset, Rect as LayoutRect, Size, TransformOffset, }; @@ -118,11 +119,10 @@ impl DesktopLayoutAlgorithm<'_> { let layout_targets = launcher.compute_instance_layout_targets(&instance_inputs, self.focused_instance); - let mut transform_by_instance: std::collections::HashMap = - layout_targets - .into_iter() - .map(|target| (target.instance_id, target.layout_transform)) - .collect(); + let mut transform_by_instance: HashMap = layout_targets + .into_iter() + .map(|target| (target.instance_id, target.layout_transform)) + .collect(); children .iter() @@ -205,7 +205,10 @@ pub(crate) fn place_container_children( if index > 0 { offset[axis_index] += spacing; } - child_placements.push(TransformOffset::new(Transform::IDENTITY, offset)); + let center_x = offset[0] as f64 + child_size[0] as f64 / 2.0; + let center_y = offset[1] as f64 + child_size[1] as f64 / 2.0; + 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; } diff --git a/desktop/src/desktop_system/layout_effects.rs b/desktop/src/desktop_system/layout_effects.rs index 45dea74d..85431e8d 100644 --- a/desktop/src/desktop_system/layout_effects.rs +++ b/desktop/src/desktop_system/layout_effects.rs @@ -2,7 +2,7 @@ use anyhow::Result; use massive_animation::Interpolation; use massive_applications::InstanceId; -use massive_geometry::{PointPx, Rect, RectPx, Transform}; +use massive_geometry::{PointPx, SizePx, Transform}; use massive_layout::Placement; use super::{DesktopLayoutAlgorithm, DesktopSystem, DesktopTarget, TransactionEffectsMode}; @@ -71,8 +71,8 @@ impl DesktopSystem { animate: bool, ) { for (id, placement) in changed { - let rect_px: RectPx = placement.rect.into(); - let rect: Rect = rect_px.into(); + let layout_size = placement.rect.size; + let size_px = SizePx::new(layout_size[0], layout_size[1]); let transform = placement.transform; match id { @@ -82,21 +82,21 @@ impl DesktopSystem { .instances .get_mut(&instance_id) .expect("Instance missing") - .set_layout(rect_px, transform, animate); + .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) => { 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? diff --git a/desktop/src/desktop_system/navigation.rs b/desktop/src/desktop_system/navigation.rs index 881bc234..9c769fe0 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, Size, Vector3}; use massive_scene::{ToCamera, Transform}; use super::{DesktopSystem, DesktopTarget}; @@ -32,19 +32,24 @@ 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 size_px = placement.rect.size; + let offset = placement.rect.offset; + let size = Size::new(size_px[0] as f64, size_px[1] as f64); + // The Desktop is the layout root — its transform is T::default() (IDENTITY), + // not center-based. Compute the center from the rect. + let center_x = offset[0] as f64 + size.width / 2.0; + let center_y = offset[1] as f64 + size.height / 2.0; + let center: Transform = Vector3::new(center_x, center_y, 0.0).into(); + Some(center.to_camera().with_size(size)) + } + DesktopTarget::Group(_) + | DesktopTarget::Launcher(_) => { + let transform = self.layouter.transform(focus)?; + 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 +77,8 @@ impl DesktopSystem { return None; } - let from_rect = self.rect(from)?; + let from_transform = self.layouter.transform(from)?; + let from_center = Point::new(from_transform.translate.x, from_transform.translate.y); let launcher_targets_without_instances = self .aggregates .launchers @@ -88,27 +94,30 @@ 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.transform(&target)?; + 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() { + 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/hit_tester.rs b/desktop/src/hit_tester.rs index 309596d9..fa9431cb 100644 --- a/desktop/src/hit_tester.rs +++ b/desktop/src/hit_tester.rs @@ -1,9 +1,9 @@ -use massive_applications::InstanceId; -use massive_geometry::{Contains, Point, Rect, RectPx, Size, Transform, Vector3}; +use massive_geometry::{ + Contains, PerspectiveDivide, Point, Rect, RectPx, Size, Transform, Vector3, Vector4, +}; use massive_layout::IncrementalLayouter; use massive_renderer::RenderGeometry; -use crate::instance_presenter::InstancePresenter; use crate::projects::{LaunchProfileId, LauncherPresenter}; use crate::{DesktopTarget, HitTester, Map, OrderedHierarchy}; @@ -11,7 +11,6 @@ pub(crate) struct AggregateHitTester<'a> { hierarchy: &'a OrderedHierarchy, layouter: &'a IncrementalLayouter, launchers: &'a Map, - instances: &'a Map, geometry: &'a RenderGeometry, } @@ -19,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<'_> { @@ -51,14 +49,12 @@ impl<'a> AggregateHitTester<'a> { hierarchy: &'a OrderedHierarchy, layouter: &'a IncrementalLayouter, launchers: &'a Map, - instances: &'a Map, geometry: &'a RenderGeometry, ) -> Self { Self { hierarchy, layouter, launchers, - instances, geometry, } } @@ -73,7 +69,7 @@ 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 { + (Some(regular), Some(overlay)) => Some(if overlay.surface_depth < regular.surface_depth { overlay } else { regular @@ -102,7 +98,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); } @@ -120,6 +116,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() @@ -132,7 +130,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); } @@ -148,7 +146,7 @@ impl<'a> AggregateHitTester<'a> { return Some(HitTestResult { target: root.clone(), local_pos, - surface_z: hit_surface.surface_z, + surface_depth: hit_depth, }); } @@ -160,35 +158,10 @@ impl<'a> AggregateHitTester<'a> { let rect_px: RectPx = (*rect).into(); Rect::from(rect_px) })?; + let size = rect.size(); - let transform = self.hit_test_transform(target, rect); - let surface_z = self.hit_surface_z(target); - - Some(HitSurface { - transform, - size: rect.size(), - surface_z, - }) - } - - fn hit_surface_z(&self, target: &DesktopTarget) -> f64 { - // For instances and views, use the instance's layout transform z from the layouter. - // We use the instance presenter's final_value() for hit depth stability during animations. - 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 - .final_value() - .translate - .z; - } - - // For non-instance targets, use the layouter's stored transform. - self.layouter - .transform(target) - .map_or(0.0, |t| t.translate.z) + let transform = self.hit_test_transform(target, size); + Some(HitSurface { transform, size }) } fn hit_test_surface(&self, screen_pos: Point, hit_surface: &HitSurface) -> Option { @@ -198,8 +171,28 @@ impl<'a> AggregateHitTester<'a> { /// 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, rect: Rect) -> Transform { - if let Some(instance_id) = self.hit_target_instance_id(target) { + fn hit_test_transform(&self, target: &DesktopTarget, size: Size) -> Transform { + let local_center = 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 = self + .layouter + .rect(target) + .map(|r| r.offset) + .unwrap_or_default(); + 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_transform = self .layouter @@ -207,50 +200,58 @@ impl<'a> AggregateHitTester<'a> { .copied() .unwrap_or(Transform::IDENTITY); - // For View targets, offset the instance transform by the view's position within the - // instance so that unprojection returns view-local coordinates. - let view_offset = match target { - DesktopTarget::View(_) => { - let instance_rect = self.layouter.rect(&instance_target).map(|r| { - let r_px: RectPx = (*r).into(); - Rect::from(r_px) - }); - instance_rect.map_or(Vector3::ZERO, |ir| { - let offset = rect.origin() - ir.origin(); - Vector3::new(offset.x, offset.y, 0.0) - }) - } - _ => Vector3::ZERO, - }; + let instance_rect = self + .layouter + .rect(&instance_target) + .copied() + .expect("Internal error: Missing instance rect in hit test"); + let view_rect = self + .layouter + .rect(target) + .copied() + .expect("Internal error: Missing view rect in hit test"); + + let instance_center = Vector3::new( + instance_rect.offset[0] as f64 + instance_rect.size[0] as f64 / 2.0, + instance_rect.offset[1] as f64 + instance_rect.size[1] as f64 / 2.0, + 0.0, + ); + let view_center = Vector3::new( + view_rect.offset[0] as f64 + view_rect.size[0] as f64 / 2.0, + view_rect.offset[1] as f64 + view_rect.size[1] as f64 / 2.0, + 0.0, + ); + let view_offset = view_center - instance_center; - let local_center = rect.size().to_rect().center(); let mut layout_transform = instance_transform; layout_transform.translate = layout_transform.translate + layout_transform.rotate * view_offset; - return InstancePresenter::transform_with_layout(layout_transform, local_center); + return Self::transform_with_layout(layout_transform, local_center); } - let origin = rect.origin(); - Transform::from_translation((origin.x, origin.y, 0.0)) + let layout_transform = self + .layouter + .transform(target) + .copied() + .unwrap_or(Transform::IDENTITY); + Self::transform_with_layout(layout_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) + } + } diff --git a/desktop/src/instance_presenter.rs b/desktop/src/instance_presenter.rs index c053aa61..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(); diff --git a/desktop/src/projects/launcher_presenter.rs b/desktop/src/projects/launcher_presenter.rs index 247d91a2..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, 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}; @@ -49,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, @@ -69,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() @@ -108,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), @@ -281,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(); } } @@ -305,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( diff --git a/desktop/src/projects/project_presenter.rs b/desktop/src/projects/project_presenter.rs index 899da629..ab0422ca 100644 --- a/desktop/src/projects/project_presenter.rs +++ b/desktop/src/projects/project_presenter.rs @@ -1,7 +1,7 @@ use std::{sync::Arc, time::Duration}; use massive_animation::{Animated, Interpolation}; -use massive_geometry::{Color, Point, Rect, RectPx, Transform, Vector3}; +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}; @@ -77,30 +77,14 @@ impl ProjectPresenter { let alpha = self.hover_alpha.value(); let hover_placement = self.hover_placement; - let rect_px: RectPx = hover_placement.rect.into(); - let hover_rect: Rect = rect_px.into(); - - // Hover shapes are drawn in local coordinates (origin-based rect). - let local_rect = hover_rect.size().to_rect(); + 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. For instances, the layout transform's - // translate IS the center position (possibly offset for visor layout). For launchers, - // the transform is IDENTITY so we derive position from the rect's center. + // Position the hover visual in world space using the placement's center-based transform. let local_center = local_rect.center(); - let has_translate = hover_placement.transform.translate != Vector3::ZERO; - let center_transform = if has_translate { - hover_placement.transform - } else { - let center = hover_rect.center(); - Transform::new( - Vector3::new(center.x, center.y, 0.0), - hover_placement.transform.rotate, - hover_placement.transform.scale, - ) - }; let scene_transform = InstancePresenter::transform_with_layout( - center_transform, + hover_placement.transform, Point::new(local_center.x, local_center.y), ); self.hover_scene_transform @@ -135,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(), } } } From 7d023bbefc4dfa48e870a583956825d588643efc Mon Sep 17 00:00:00 2001 From: Armin Sander Date: Fri, 24 Apr 2026 09:55:52 +0200 Subject: [PATCH 11/13] Remove rect() and transform() functions from IncrementalLayouter --- .github/copilot-instructions.md | 1 + desktop/src/desktop_system/navigation.rs | 25 ++++----- desktop/src/hit_tester.rs | 59 ++++++++++---------- layout/src/incremental_layouter.rs | 69 +++++++++++++----------- 4 files changed, 79 insertions(+), 75 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 739bed9c..9b7a9500 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -24,6 +24,7 @@ Update it whenever you learn something new about the project's patterns, convent - 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. diff --git a/desktop/src/desktop_system/navigation.rs b/desktop/src/desktop_system/navigation.rs index 9c769fe0..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, Size, Vector3}; +use massive_geometry::{PixelCamera, Point, Rect, RectPx}; use massive_scene::{ToCamera, Transform}; use super::{DesktopSystem, DesktopTarget}; @@ -34,19 +34,17 @@ impl DesktopSystem { match focus { DesktopTarget::Desktop => { let placement = self.placement(&DesktopTarget::Desktop)?; - let size_px = placement.rect.size; - let offset = placement.rect.offset; - let size = Size::new(size_px[0] as f64, size_px[1] as f64); + 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_x = offset[0] as f64 + size.width / 2.0; - let center_y = offset[1] as f64 + size.height / 2.0; - let center: Transform = Vector3::new(center_x, center_y, 0.0).into(); + 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.transform(focus)?; + DesktopTarget::Group(_) | DesktopTarget::Launcher(_) => { + let transform = self.layouter.placement(focus)?.transform; let camera_transform: Transform = transform.translate.into(); Some(camera_transform.to_camera()) } @@ -77,7 +75,7 @@ impl DesktopSystem { return None; } - let from_transform = self.layouter.transform(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 @@ -95,13 +93,12 @@ impl DesktopSystem { let navigation_candidates = launcher_targets_without_instances .chain(all_instances_or_views) .filter_map(|target| { - let t = self.layouter.transform(&target)?; + let t = self.layouter.placement(&target)?.transform; let center = Point::new(t.translate.x, t.translate.y); Some((target, center)) }); - let ordered = - ordered_points_in_direction(from_center, direction, navigation_candidates); + let ordered = ordered_points_in_direction(from_center, direction, navigation_candidates); if let Some((nearest, _distance)) = ordered.first() { return Some(nearest.clone()); } diff --git a/desktop/src/hit_tester.rs b/desktop/src/hit_tester.rs index fa9431cb..4290ea98 100644 --- a/desktop/src/hit_tester.rs +++ b/desktop/src/hit_tester.rs @@ -1,7 +1,7 @@ use massive_geometry::{ Contains, PerspectiveDivide, Point, Rect, RectPx, Size, Transform, Vector3, Vector4, }; -use massive_layout::IncrementalLayouter; +use massive_layout::{IncrementalLayouter, Rect as LayoutRect}; use massive_renderer::RenderGeometry; use crate::projects::{LaunchProfileId, LauncherPresenter}; @@ -69,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_depth < regular.surface_depth { - 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, @@ -154,8 +156,8 @@ 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(); + let rect = self.layouter.placement(target).map(|placement| { + let rect_px: RectPx = placement.rect.into(); Rect::from(rect_px) })?; let size = rect.size(); @@ -179,8 +181,8 @@ impl<'a> AggregateHitTester<'a> { if let DesktopTarget::Desktop = target { let offset = self .layouter - .rect(target) - .map(|r| r.offset) + .placement(target) + .map(|placement| placement.rect.offset) .unwrap_or_default(); return Transform::from_translation((offset[0] as f64, offset[1] as f64, 0.0)); } @@ -196,43 +198,34 @@ impl<'a> AggregateHitTester<'a> { let instance_target = DesktopTarget::Instance(instance_id); let instance_transform = self .layouter - .transform(&instance_target) - .copied() + .placement(&instance_target) + .map(|placement| placement.transform) .unwrap_or(Transform::IDENTITY); let instance_rect = self .layouter - .rect(&instance_target) - .copied() + .placement(&instance_target) + .map(|placement| placement.rect) .expect("Internal error: Missing instance rect in hit test"); let view_rect = self .layouter - .rect(target) - .copied() + .placement(target) + .map(|placement| placement.rect) .expect("Internal error: Missing view rect in hit test"); - let instance_center = Vector3::new( - instance_rect.offset[0] as f64 + instance_rect.size[0] as f64 / 2.0, - instance_rect.offset[1] as f64 + instance_rect.size[1] as f64 / 2.0, - 0.0, - ); - let view_center = Vector3::new( - view_rect.offset[0] as f64 + view_rect.size[0] as f64 / 2.0, - view_rect.offset[1] as f64 + view_rect.size[1] as f64 / 2.0, - 0.0, - ); + let instance_center = Self::layout_rect_center(instance_rect); + let view_center = Self::layout_rect_center(view_rect); let view_offset = view_center - instance_center; let mut layout_transform = instance_transform; - layout_transform.translate = - layout_transform.translate + layout_transform.rotate * view_offset; + layout_transform.translate += layout_transform.rotate * view_offset; return Self::transform_with_layout(layout_transform, local_center); } let layout_transform = self .layouter - .transform(target) - .copied() + .placement(target) + .map(|placement| placement.transform) .unwrap_or(Transform::IDENTITY); Self::transform_with_layout(layout_transform, local_center) } @@ -254,4 +247,10 @@ impl<'a> AggregateHitTester<'a> { 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/layout/src/incremental_layouter.rs b/layout/src/incremental_layouter.rs index e0d70dcf..e91cec8b 100644 --- a/layout/src/incremental_layouter.rs +++ b/layout/src/incremental_layouter.rs @@ -173,14 +173,6 @@ where self.placements.get(id) } - pub fn rect(&self, id: &Id) -> Option<&Rect> { - self.placements.get(id).map(|p| &p.rect) - } - - pub fn transform(&self, id: &Id) -> Option<&T> { - self.placements.get(id).map(|p| &p.transform) - } - /// Decides whether the current generation should traverse into `child`. /// /// Clean children with valid cached rects are reusable during placement, so traversal is only @@ -809,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])) ); } @@ -838,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])) ); } @@ -887,8 +879,8 @@ 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] @@ -927,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])) ); } @@ -961,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()) ); } @@ -999,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()) ); } @@ -1031,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()); } @@ -1057,7 +1059,7 @@ mod tests { let update = layouter.recompute(&topology, &algorithm, [0, 0]); - assert!(layouter.rect(&1).is_none()); + assert!(layouter.placement(&1).is_none()); assert!(update.changed.iter().any(|(id, ..)| id == &0)); } @@ -1084,7 +1086,7 @@ mod tests { let update = layouter.recompute(&topology, &algorithm, [0, 0]); - assert!(layouter.rect(&1).is_none()); + assert!(layouter.placement(&1).is_none()); assert!(update.changed.iter().any(|(id, ..)| id == &0)); } @@ -1110,10 +1112,10 @@ 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()) ); @@ -1147,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()) ); } From 0b48cfbbda445ffaa3d0bc2288b8f4d4cdafa98c Mon Sep 17 00:00:00 2001 From: Armin Sander Date: Fri, 24 Apr 2026 10:28:18 +0200 Subject: [PATCH 12/13] Simplify --- desktop/src/hit_tester.rs | 56 +++++++++++++-------------------------- 1 file changed, 19 insertions(+), 37 deletions(-) diff --git a/desktop/src/hit_tester.rs b/desktop/src/hit_tester.rs index 4290ea98..13800b7b 100644 --- a/desktop/src/hit_tester.rs +++ b/desktop/src/hit_tester.rs @@ -1,7 +1,7 @@ use massive_geometry::{ Contains, PerspectiveDivide, Point, Rect, RectPx, Size, Transform, Vector3, Vector4, }; -use massive_layout::{IncrementalLayouter, Rect as LayoutRect}; +use massive_layout::{IncrementalLayouter, Placement, Rect as LayoutRect}; use massive_renderer::RenderGeometry; use crate::projects::{LaunchProfileId, LauncherPresenter}; @@ -156,13 +156,11 @@ impl<'a> AggregateHitTester<'a> { } fn resolve_hit_surface(&self, target: &DesktopTarget) -> Option { - let rect = self.layouter.placement(target).map(|placement| { - let rect_px: RectPx = placement.rect.into(); - Rect::from(rect_px) - })?; - let size = rect.size(); + let placement = *self.layouter.placement(target)?; + let rect_px: RectPx = placement.rect.into(); + let size = Rect::from(rect_px).size(); - let transform = self.hit_test_transform(target, size); + let transform = self.hit_test_transform(target, placement); Some(HitSurface { transform, size }) } @@ -173,17 +171,18 @@ impl<'a> AggregateHitTester<'a> { /// 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, size: Size) -> Transform { - let local_center = size.to_rect().center(); + 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 = self - .layouter - .placement(target) - .map(|placement| placement.rect.offset) - .unwrap_or_default(); + let offset = placement.rect.offset; return Transform::from_translation((offset[0] as f64, offset[1] as f64, 0.0)); } @@ -196,38 +195,21 @@ impl<'a> AggregateHitTester<'a> { None => panic!("Internal error: View without parent in hit test"), }; let instance_target = DesktopTarget::Instance(instance_id); - let instance_transform = self - .layouter - .placement(&instance_target) - .map(|placement| placement.transform) - .unwrap_or(Transform::IDENTITY); - - let instance_rect = self + let instance_placement = self .layouter .placement(&instance_target) - .map(|placement| placement.rect) - .expect("Internal error: Missing instance rect in hit test"); - let view_rect = self - .layouter - .placement(target) - .map(|placement| placement.rect) - .expect("Internal error: Missing view rect in hit test"); + .expect("Internal error: Missing instance placement in hit test"); - let instance_center = Self::layout_rect_center(instance_rect); - let view_center = Self::layout_rect_center(view_rect); + 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_transform; + 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 layout_transform = self - .layouter - .placement(target) - .map(|placement| placement.transform) - .unwrap_or(Transform::IDENTITY); - Self::transform_with_layout(layout_transform, local_center) + Self::transform_with_layout(placement.transform, local_center) } fn transform_with_layout(layout_transform: Transform, local_center: Point) -> Transform { From bc8a19486770bd313570cf607b6ba056bf6697dc Mon Sep 17 00:00:00 2001 From: Armin Sander Date: Fri, 24 Apr 2026 10:35:56 +0200 Subject: [PATCH 13/13] Center --- desktop/src/desktop_system/layout_algorithm.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/src/desktop_system/layout_algorithm.rs b/desktop/src/desktop_system/layout_algorithm.rs index 0070feb2..22731e82 100644 --- a/desktop/src/desktop_system/layout_algorithm.rs +++ b/desktop/src/desktop_system/layout_algorithm.rs @@ -205,9 +205,9 @@ pub(crate) fn place_container_children( if index > 0 { offset[axis_index] += spacing; } - let center_x = offset[0] as f64 + child_size[0] as f64 / 2.0; - let center_y = offset[1] as f64 + child_size[1] as f64 / 2.0; - let transform = Transform::from_translation(Vector3::new(center_x, center_y, 0.0)); + 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; }