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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,17 @@ Update it whenever you learn something new about the project's patterns, convent
- Include complete state in events rather than deltas to provide full context to handlers.
- Prefer grouping semantically paired values into a single parameter or type when they are always used together.
- Use cohesive domain types as API boundaries when related values are expected to move together.
- When a domain struct already models paired values, prefer it over tuple payloads in change streams and method signatures.
- When a cohesive domain struct is the canonical state, prefer a single accessor returning that struct over parallel field-specific accessors.
- For layout APIs, prefer named transform+offset structs over tuple returns so ordering and intent stay explicit.
- For small paired-value structs, prefer constructor derives (for example `derive_more::Constructor`) and use constructors at call sites instead of field-literal repetition.
- Prefer behavior-named capability methods on presenters/components over exposing raw mode enums to system-level callers.

## Safety & Quality
- Avoid unsafe or experimental APIs unless required.
- Preserve backwards compatibility unless instructed otherwise.
- When refactoring, don't add trait implementations that weren't present; prefer deriving over manual implementation.
- For event transition summaries used by side effects, collect all relevant transition payloads rather than stopping at the first match.
- Prefer proper platform-native solutions over UI-level workarounds or quick fixes.
- Keep one source of truth for mutable state; avoid mirrored caches and route reads through narrow accessors.
- For transient UI indicators (hover/focus highlights), derive visibility/target from current resolved state rather than only from enter/exit edge events.
Expand Down
15 changes: 5 additions & 10 deletions desktop/src/desktop_system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ use std::time::Duration;

use massive_animation::Animated;
use massive_applications::{InstanceId, ViewId};
use massive_geometry::{PixelCamera, Rect, RectPx, SizePx};
use massive_layout::{IncrementalLayouter, LayoutTopology};
use massive_geometry::{PixelCamera, SizePx};
use massive_layout::{IncrementalLayouter, LayoutTopology, Placement};
use massive_scene::{Location, Object, Transform};
use massive_shell::{FontManager, Scene};

Expand Down Expand Up @@ -78,10 +78,9 @@ pub struct DesktopSystem {
event_router: EventRouter<DesktopTarget>,
camera: Animated<PixelCamera>,
pointer_feedback_enabled: bool,
last_effects_focus: Option<DesktopTarget>,

#[debug(skip)]
layouter: IncrementalLayouter<DesktopTarget, 2>,
layouter: IncrementalLayouter<DesktopTarget, Transform, 2>,

aggregates: Aggregates,
}
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -219,11 +217,8 @@ impl DesktopSystem {
Ok(())
}

fn rect(&self, target: &DesktopTarget) -> Option<Rect> {
self.layouter.rect(target).map(|rect| {
let rect_px: RectPx = (*rect).into();
rect_px.into()
})
fn placement(&self, target: &DesktopTarget) -> Option<Placement<Transform, 2>> {
self.layouter.placement(target).copied()
}
}

Expand Down
8 changes: 1 addition & 7 deletions desktop/src/desktop_system/event_forwarding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
{
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/desktop_system/focus_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ impl DesktopSystem {
&self.aggregates.hierarchy,
&self.layouter,
&self.aggregates.launchers,
&self.aggregates.instances,
render_geometry,
);

let transitions = self.event_router.process(event, &hit_tester)?;
self.invalidate_layout_for_focus_change(transitions.keyboard_focus_change());
self.forward_event_transitions(transitions, instance_manager)?
};

Expand Down Expand Up @@ -74,6 +74,7 @@ impl DesktopSystem {
instance_manager: &InstanceManager,
) -> Result<()> {
let transitions = self.event_router.focus(target);
self.invalidate_layout_for_focus_change(transitions.keyboard_focus_change());

// Invariant: Programmatic focus changes must not trigger commands.
assert!(
Expand Down Expand Up @@ -115,7 +116,6 @@ impl DesktopSystem {
&self.aggregates.hierarchy,
&self.layouter,
&self.aggregates.launchers,
&self.aggregates.instances,
render_geometry,
))?;

Expand Down
209 changes: 144 additions & 65 deletions desktop/src/desktop_system/layout_algorithm.rs
Original file line number Diff line number Diff line change
@@ -1,74 +1,41 @@
use std::cmp::max;
use std::collections::HashMap;

use massive_geometry::SizePx;
use massive_layout::{LayoutAlgorithm, LayoutAxis, Offset, Size};
use massive_geometry::{RectPx, SizePx, Transform, Vector3};
use massive_layout::{
LayoutAlgorithm, LayoutAxis, Offset, Rect as LayoutRect, Size, TransformOffset,
};

use massive_applications::InstanceId;

use super::{Aggregates, DesktopTarget};
use crate::layout::{LayoutSpec, ToContainer};
use crate::projects::LauncherInstanceLayoutInput;

const SECTION_SPACING: u32 = 20;

pub(super) struct DesktopLayoutAlgorithm<'a> {
pub(super) aggregates: &'a Aggregates,
pub(super) default_panel_size: SizePx,
pub(super) focused_instance: Option<InstanceId>,
}

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<DesktopTarget, Transform, 2> for DesktopLayoutAlgorithm<'_> {
fn place_children(
&self,
id: &DesktopTarget,
parent_offset: Offset<2>,
child_sizes: &[Size<2>],
) -> Vec<TransformOffset<Transform, 2>> {
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<Offset<2>> {
let axis_index: usize = axis.into();
let mut child_offsets = Vec::with_capacity(child_sizes.len());

for (index, &child_size) in child_sizes.iter().enumerate() {
if index > 0 {
offset[axis_index] += spacing;
}
child_offsets.push(offset);
offset[axis_index] += child_size[axis_index] as i32;
self.place_standard_children(id, parent_offset, child_sizes)
}

child_offsets
}

impl LayoutAlgorithm<DesktopTarget, 2> for DesktopLayoutAlgorithm<'_> {
fn measure(&self, id: &DesktopTarget, child_sizes: &[Size<2>]) -> Size<2> {
if let DesktopTarget::Launcher(launcher_id) = id
&& let Some(size) =
Expand Down Expand Up @@ -104,23 +71,80 @@ impl LayoutAlgorithm<DesktopTarget, 2> for DesktopLayoutAlgorithm<'_> {
}
}
}
}

fn place_children(
impl DesktopLayoutAlgorithm<'_> {
fn place_launcher_children(
&self,
id: &DesktopTarget,
parent_offset: Offset<2>,
child_sizes: &[Size<2>],
) -> Vec<Offset<2>> {
if let DesktopTarget::Launcher(launcher_id) = id
&& let Some(offsets) = self.aggregates.launchers[launcher_id].panel_child_offsets(
parent_offset,
child_sizes,
self.default_panel_size,
)
) -> Vec<TransformOffset<Transform, 2>> {
let DesktopTarget::Launcher(launcher_id) = id else {
panic!("place_launcher_children requires a launcher target")
};

let launcher = &self.aggregates.launchers[launcher_id];

// Launchers can provide a custom 2D placement pass. If unavailable,
// we reuse the standard container algorithm to keep behavior consistent.
let child_placements = if let Some(placements) =
launcher.panel_child_offsets(parent_offset, child_sizes, self.default_panel_size)
{
return offsets;
}
placements
} else {
self.place_standard_children(id, parent_offset, child_sizes)
};

let children = self.aggregates.hierarchy.get_nested(id);

// The launcher then upgrades instance transforms based on panel context
// (focus/depth/arrangement), while preserving offsets from the 2D pass.
let instance_inputs: Vec<LauncherInstanceLayoutInput> = children
.iter()
.zip(child_placements.iter().zip(child_sizes.iter()))
.filter_map(|(target, (child_transform_offset, size))| match target {
DesktopTarget::Instance(instance_id) => {
let rect_px: RectPx =
LayoutRect::new(child_transform_offset.offset, *size).into();
Some(LauncherInstanceLayoutInput {
instance_id: *instance_id,
rect: rect_px,
})
}
_ => None,
})
.collect();

let layout_targets =
launcher.compute_instance_layout_targets(&instance_inputs, self.focused_instance);

let mut transform_by_instance: HashMap<InstanceId, Transform> = 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<TransformOffset<Transform, 2>> {
match self.resolve_layout_spec(id) {
LayoutSpec::Leaf(_) => Vec::new(),
LayoutSpec::Container {
Expand All @@ -129,9 +153,64 @@ impl LayoutAlgorithm<DesktopTarget, 2> 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<TransformOffset<Transform, 2>> {
let axis_index: usize = axis.into();
let mut child_placements = Vec::with_capacity(child_sizes.len());

for (index, &child_size) in child_sizes.iter().enumerate() {
if index > 0 {
offset[axis_index] += spacing;
}
let rect: RectPx = LayoutRect::new(offset, child_size).into();
let center = rect.center().to_f64();
let transform = Transform::from_translation(Vector3::new(center.x, center.y, 0.0));
child_placements.push(TransformOffset::new(transform, offset));
offset[axis_index] += child_size[axis_index] as i32;
}

child_placements
}
Loading
Loading