diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bf7fd3cc..4af399c4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -11,6 +11,7 @@ Update it whenever you learn something new about the project's patterns, convent - Comment only to explain non-obvious reasoning or intent. - Order functions high-level first, utilities last; order types by importance (public API first, private helpers last). - Prefer directory submodules with `mod.rs` over sibling `foo.rs` submodule files when introducing new submodule trees. +- When splitting large modules, extract low-coupling impl blocks first and preserve existing external imports via local re-exports in the parent module. ## Rust - Prefer `derive_more` traits (Debug, Deref) over manual implementations. @@ -33,7 +34,9 @@ Update it whenever you learn something new about the project's patterns, convent - For transient UI indicators (hover/focus highlights), derive visibility/target from current resolved state rather than only from enter/exit edge events. - For context-specific behavior, prefer targeted follow-up evaluation over broad global rule changes that affect unrelated paths. - When a generic pass applies fallback state, recompute context-specific state immediately afterward for impacted entities. +- For visual side effects derived from state transitions, prefer computing them in the centralized effects/update phase using previous/current state snapshots instead of duplicating eager updates across input and command paths. - Keep invariant gating at a single layer where practical; avoid repeating identical mode/eligibility checks across caller and callee. +- When an operation must not emit follow-up commands, model it as `Result<()>` and enforce the invariant at the forwarding boundary. - For internal invariant violations, prefer explicit panics over silent fallback/continue paths. - When code guarantees an invariant, avoid defensive fallback branches for that path; keep the direct path and fail explicitly if the invariant is violated. - For purely defensive invariant checks on hot paths, prefer debug-only assertions to avoid unnecessary release-build work. @@ -41,6 +44,7 @@ Update it whenever you learn something new about the project's patterns, convent - When multiple transient affordances represent the same interaction mode, keep them behind one shared state instead of parallel flags. - Cache repeated window state requests at the caller when the underlying platform mutation may hop to the main thread or otherwise be non-trivial. - On macOS, prefer native menu selector wiring for commands that users can remap in App Shortcuts, instead of hardcoded key-chord matching. +- When refactoring eventful flows, extract pure target/decision helpers first and keep side-effect dispatch ordering unchanged until tests lock transition semantics. ## Testing - Don't add tests unless explicitly asked. diff --git a/desktop/src/desktop.rs b/desktop/src/desktop.rs index 281c65ae..148377b3 100644 --- a/desktop/src/desktop.rs +++ b/desktop/src/desktop.rs @@ -15,7 +15,9 @@ use massive_shell::{ApplicationContext, FontManager, Scene, ShellEvent}; use massive_shell::{AsyncWindowRenderer, ShellWindow}; use crate::DesktopEnvironment; -use crate::desktop_system::{DesktopCommand, DesktopSystem, ProjectCommand}; +use crate::desktop_system::{ + DesktopCommand, DesktopSystem, ProjectCommand, TransactionEffectsMode, +}; use crate::event_sourcing::Transaction; use crate::instance_manager::{InstanceManager, ViewPath}; use crate::projects::{ @@ -125,10 +127,9 @@ impl Desktop { desktop_groups_transaction + project_setup_transaction + primary_view_transaction, &scene, &mut instance_manager, + TransactionEffectsMode::Setup, )?; - system.update_effects(false, true)?; - Ok(Self { scene, renderer, @@ -163,16 +164,22 @@ impl Desktop { &self.instance_manager, self.renderer.geometry(), )?; - let cursor_visible = self.system.cursor_visible(); + let cursor_visible = self.system.is_cursor_visible(); if self.cursor_visible != cursor_visible { self.window.set_cursor_visible(cursor_visible); self.cursor_visible = cursor_visible; } self.system - .transact(cmd, &self.scene, &mut self.instance_manager)?; - - let allow_camera_movements = !input_event.any_buttons_pressed(); - self.system.update_effects(true, allow_camera_movements)?; + .transact( + cmd, + &self.scene, + &mut self.instance_manager, + if input_event.any_buttons_pressed() { + TransactionEffectsMode::CameraLocked.into() + } else { + None + }, + )?; } self.renderer.resize_redraw(&window_event)?; @@ -190,7 +197,12 @@ impl Desktop { if self.system.is_present(&instance_id) { // Did it end on its own? -> Act as the user ended it. // Robustness: This should probably handled differently. - self.system.transact(DesktopCommand::StopInstance(instance_id), &self.scene, &mut self.instance_manager)?; + self.system.transact( + DesktopCommand::StopInstance(instance_id), + &self.scene, + &mut self.instance_manager, + None, + )?; } // Feature: Display the error to the user? @@ -234,6 +246,7 @@ impl Desktop { DesktopCommand::PresentView(instance, info), &self.scene, &mut self.instance_manager, + None, )?; } InstanceCommand::DestroyView(id, collector) => { @@ -241,6 +254,7 @@ impl Desktop { DesktopCommand::HideView((instance, id).into()), &self.scene, &mut self.instance_manager, + None, )?; self.instance_manager.remove_view((instance, id).into()); // Feature: Don't push the remaining changes immediately and fade the remaining diff --git a/desktop/src/desktop_system.rs b/desktop/src/desktop_system.rs index 5ac92cf7..bdb6fd0a 100644 --- a/desktop/src/desktop_system.rs +++ b/desktop/src/desktop_system.rs @@ -8,47 +8,42 @@ //! The goal here is to remove as much as possible from the specific instances into separate systems //! and aggregates that are event driven. -use anyhow::{Result, anyhow, bail}; +mod command_dispatch; +mod commands; +mod event_forwarding; +mod focus_input; +mod focus_path_ext; +mod hierarchy_focus; +mod layout_algorithm; +mod layout_effects; +mod navigation; +mod presentation; +mod project_commands; + +use anyhow::Result; use derive_more::{Debug, From}; -use log::warn; -use std::cmp::max; -use std::collections::HashSet; use std::time::Duration; -use winit::event::ElementState; -use winit::keyboard::{Key, NamedKey}; -use massive_animation::{Animated, Interpolation}; -use massive_applications::{ - CreationMode, InstanceId, InstanceParameters, ViewCreationInfo, ViewEvent, ViewId, ViewRole, -}; -use massive_geometry::{PixelCamera, PointPx, Rect, RectPx, SizePx}; -use massive_input::Event; -use massive_layout::{ - IncrementalLayouter, LayoutAlgorithm, LayoutAxis, LayoutTopology, Offset, Rect as LayoutRect, - Size, -}; -use massive_renderer::RenderGeometry; -use massive_scene::{Location, Object, ToCamera, Transform}; +use massive_animation::Animated; +use massive_applications::{InstanceId, ViewId}; +use massive_geometry::{PixelCamera, Rect, RectPx, SizePx}; +use massive_layout::{IncrementalLayouter, LayoutTopology}; +use massive_scene::{Location, Object, Transform}; use massive_shell::{FontManager, Scene}; -use crate::event_router::EventTransitions; +pub use commands::{DesktopCommand, ProjectCommand}; +use layout_algorithm::DesktopLayoutAlgorithm; +pub(crate) use layout_algorithm::place_container_children; + use crate::event_sourcing::{self, Transaction}; -use crate::focus_path::{FocusPath, PathResolver}; -use crate::hit_tester::AggregateHitTester; -use crate::instance_manager::{InstanceManager, ViewPath}; -use crate::instance_presenter::{ - InstancePresenter, InstancePresenterState, PrimaryViewPresenter, STRUCTURAL_ANIMATION_DURATION, -}; -use crate::layout::{LayoutSpec, ToContainer}; -use crate::navigation::ordered_rects_in_direction; +use crate::focus_path::FocusPath; +use crate::instance_manager::InstanceManager; +use crate::instance_presenter::InstancePresenter; use crate::projects::{ - GroupId, GroupPresenter, LaunchGroupProperties, LaunchProfile, LaunchProfileId, - LauncherInstanceLayoutInput, LauncherInstanceLayoutTarget, LauncherPresenter, ProjectPresenter, + GroupId, GroupPresenter, LaunchProfileId, LauncherPresenter, ProjectPresenter, }; -use crate::send_transition::{SendTransition, convert_to_send_transitions}; -use crate::{DesktopEnvironment, DirectionBias, EventRouter, Map, OrderedHierarchy, navigation}; +use crate::{DesktopEnvironment, EventRouter, Map, OrderedHierarchy}; -const SECTION_SPACING: u32 = 20; const POINTER_FEEDBACK_REENABLE_MIN_DISTANCE_PX: f64 = 24.0; const POINTER_FEEDBACK_REENABLE_MAX_DURATION: Duration = Duration::from_millis(200); /// This enum specifies a unique target inside the navigation and layout history. @@ -65,47 +60,14 @@ pub enum DesktopTarget { pub type DesktopFocusPath = FocusPath; -/// The commands the desktop system can execute. -#[derive(Debug)] -pub enum DesktopCommand { - Project(ProjectCommand), - StartInstance { - launcher: LaunchProfileId, - parameters: InstanceParameters, - }, - StopInstance(InstanceId), - PresentInstance { - launcher: LaunchProfileId, - instance: InstanceId, - }, - PresentView(InstanceId, ViewCreationInfo), - HideView(ViewPath), - ZoomOut, - Navigate(navigation::Direction), -} +pub type Cmd = event_sourcing::Cmd; -#[derive(Debug)] -pub enum ProjectCommand { - // Project Configuration - AddLaunchGroup { - parent: Option, - id: GroupId, - properties: LaunchGroupProperties, - }, - #[allow(unused)] - RemoveLaunchGroup(GroupId), - AddLauncher { - group: GroupId, - id: LaunchProfileId, - profile: LaunchProfile, - }, - #[allow(unused)] - RemoveLauncher(LaunchProfileId), - SetStartupProfile(Option), +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransactionEffectsMode { + Setup, + CameraLocked, } -pub type Cmd = event_sourcing::Cmd; - #[derive(Debug)] pub struct DesktopSystem { env: DesktopEnvironment, @@ -116,6 +78,7 @@ pub struct DesktopSystem { event_router: EventRouter, camera: Animated, pointer_feedback_enabled: bool, + last_effects_focus: Option, #[debug(skip)] layouter: IncrementalLayouter, @@ -181,6 +144,7 @@ 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), @@ -196,241 +160,15 @@ impl DesktopSystem { transaction: impl Into>, scene: &Scene, instance_manager: &mut InstanceManager, + effects_mode: impl Into>, ) -> Result<()> { + let effects_mode = effects_mode.into(); + for command in transaction.into() { self.apply_command(command, scene, instance_manager)?; } - Ok(()) - } - - // Architecture: The current focus is part of the system, so DesktopInteraction should probably be embedded here. - fn apply_command( - &mut self, - command: DesktopCommand, - scene: &Scene, - instance_manager: &mut InstanceManager, - ) -> Result<()> { - match command { - DesktopCommand::StartInstance { - launcher, - parameters, - } => { - // Feature: Support starting non-primary applications. - let application = self - .env - .applications - .get_named(&self.env.primary_application) - .ok_or(anyhow!("Internal error, application not registered"))?; - - let instance = - instance_manager.spawn(application, CreationMode::New(parameters))?; - - // Robustness: Should this be a real, logged event? - // Architecture: Better to start up the primary directly, so that we can remove the PresentInstance command? - self.apply_command( - DesktopCommand::PresentInstance { launcher, instance }, - scene, - instance_manager, - ) - } - - DesktopCommand::StopInstance(instance) => { - // Remove the instance from the focus first. - // - // Detail: This causes an unfocus event sent to the instance's view which may - // unexpected while teardown. - - let target = instance.into(); - let focused_path = self - .aggregates - .hierarchy - .resolve_path(self.event_router.focused()); - - let was_focused = focused_path.instance() == Some(instance); - let focus_neighbor = if was_focused { - self.aggregates - .hierarchy - .entry(&target) - .neighbor(DirectionBias::Begin) - .cloned() - } else { - None - }; - - self.unfocus(instance.into(), instance_manager)?; - - // Robustness: May add neighbor selection to unfocus as an option? - if let Some(neighbor_instance) = focus_neighbor { - if let [DesktopTarget::View(view)] = - self.aggregates.hierarchy.get_nested(&neighbor_instance) - { - assert!( - self.focus(&DesktopTarget::View(*view), instance_manager)? - .is_none() - ) - } else { - assert!(self.focus(&neighbor_instance, instance_manager)?.is_none()) - } - } - - // This might fail if StopInstance gets triggered with an instance that ended in - // itself (shouldn't the instance_manager keep it until we finally free it). - if let Err(e) = instance_manager.request_shutdown(instance) { - warn!("Failed to shutdown instance, it may be gone already: {e}"); - }; - - // We hide the instance as soon we request a shutdown so that they can't be in the - // navigation tree anymore. - self.hide_instance(instance)?; - - Ok(()) - } - - DesktopCommand::PresentInstance { launcher, instance } => { - let focused = self.event_router.focused(); - let focused_path = self.aggregates.hierarchy.resolve_path(focused); - - let originating_from = focused_path.instance(); - - let insertion_index = - self.present_instance(launcher, originating_from, instance, scene)?; - - let instance_target = DesktopTarget::Instance(instance); - - // Add this instance to the hierarchy. - self.aggregates.hierarchy.insert_at( - launcher.into(), - insertion_index, - instance_target.clone(), - )?; - self.layouter - .mark_reflow_pending(DesktopTarget::Launcher(launcher)); - - // Focus it. - let transitions = self.event_router.focus(&instance_target); - let cmd = self.forward_event_transitions(transitions, instance_manager)?; - assert!(cmd.is_none()); - Ok(()) - } - - DesktopCommand::PresentView(instance, creation_info) => { - self.present_view(instance, &creation_info)?; - - let focused = self.event_router.focused(); - // If this instance is currently focused and the new view is primary, make it - // foreground so that the view is focused. - if matches!(focused, Some(DesktopTarget::Instance(i)) if *i == instance) - && creation_info.role == ViewRole::Primary - { - let cmd = - self.focus(&DesktopTarget::View(creation_info.id), instance_manager)?; - assert!(cmd.is_none()) - } - - Ok(()) - } - DesktopCommand::HideView(view_path) => self.hide_view(view_path), - - DesktopCommand::Project(project_command) => { - self.apply_project_command(project_command, scene) - } - - DesktopCommand::ZoomOut => { - if let Some(focused) = self.event_router.focused() - && let Some(parent) = self.aggregates.hierarchy.parent(focused) - { - assert!(self.focus(&parent.clone(), instance_manager)?.is_none()); - } - Ok(()) - } - DesktopCommand::Navigate(direction) => { - if let Some(focused) = self.event_router.focused() - && let Some(candidate) = self.locate_navigation_candidate(focused, direction) - { - assert!(self.focus(&candidate, instance_manager)?.is_none()); - } - Ok(()) - } - } - } - - fn apply_project_command(&mut self, command: ProjectCommand, scene: &Scene) -> Result<()> { - match command { - ProjectCommand::AddLaunchGroup { - parent, - id, - properties, - } => { - let parent = parent.map(|p| p.into()).unwrap_or(DesktopTarget::Desktop); - self.aggregates.hierarchy.add(parent.clone(), id.into())?; - self.aggregates - .groups - .insert(id, GroupPresenter::new(properties))?; - self.layouter.mark_reflow_pending(parent); - } - ProjectCommand::RemoveLaunchGroup(group) => { - self.remove_target(&group.into())?; - self.aggregates.groups.remove(&group)?; - } - ProjectCommand::AddLauncher { group, id, profile } => { - let presenter = LauncherPresenter::new( - self.aggregates.project_presenter.location.clone(), - id, - profile, - massive_geometry::Rect::default(), - scene, - &mut self.fonts.lock(), - ); - self.aggregates.launchers.insert(id, presenter)?; - - self.aggregates.hierarchy.add(group.into(), id.into())?; - self.layouter - .mark_reflow_pending(DesktopTarget::Group(group)); - } - ProjectCommand::RemoveLauncher(id) => { - let target = DesktopTarget::Launcher(id); - self.remove_target(&target)?; - - self.aggregates.launchers.remove(&id)?; - } - ProjectCommand::SetStartupProfile(launch_profile_id) => { - self.aggregates.startup_profile = launch_profile_id - } - } - - Ok(()) - } - - /// Update all effects. - pub fn update_effects(&mut self, animate: bool, permit_camera_moves: bool) -> Result<()> { - // Layout & apply rects. - let algorithm = DesktopLayoutAlgorithm { - aggregates: &self.aggregates, - default_panel_size: self.default_panel_size, - }; - let changed = self - .layouter - .recompute(&self.aggregates.hierarchy, &algorithm, PointPx::origin()) - .changed; - self.apply_layout_changes(changed, animate); - - // Camera - - if permit_camera_moves && let Some(focused) = self.event_router.focused() { - let camera = self.camera_for_focus(focused); - if let Some(camera) = camera { - if animate { - self.camera.animate_if_changed( - camera, - STRUCTURAL_ANIMATION_DURATION, - Interpolation::CubicOut, - ); - } else { - self.camera.set_immediately(camera); - } - } - } + self.update_effects(effects_mode)?; Ok(()) } @@ -455,667 +193,13 @@ impl DesktopSystem { self.camera.value() } - pub fn cursor_visible(&self) -> bool { + pub fn is_cursor_visible(&self) -> bool { self.pointer_feedback_enabled } - pub fn process_input_event( - &mut self, - event: &Event, - instance_manager: &InstanceManager, - render_geometry: &RenderGeometry, - ) -> Result { - let keyboard_cmd = self.preprocess_keyboard_input(event)?; - - let cmd = if !keyboard_cmd.is_none() { - keyboard_cmd - } else { - let hit_tester = AggregateHitTester::new( - &self.aggregates.hierarchy, - &self.layouter, - &self.aggregates.launchers, - &self.aggregates.instances, - render_geometry, - ); - - let transitions = self.event_router.process(event, &hit_tester)?; - if let Some((from, to)) = transitions.keyboard_focus_change() { - self.apply_launcher_layout_for_focus_change(from.cloned(), to.cloned(), true); - } - - self.forward_event_transitions(transitions, instance_manager)? - }; - - self.update_pointer_feedback(event); - - Ok(cmd) - } - - fn update_pointer_feedback(&mut self, event: &Event) { - match (self.pointer_feedback_enabled, event.event()) { - ( - true, - ViewEvent::KeyboardInput { - event: key_event, .. - }, - ) if key_event.state == ElementState::Pressed && !key_event.repeat => { - self.pointer_feedback_enabled = false; - self.aggregates.project_presenter.set_hover_rect(None); - } - (false, ViewEvent::MouseInput { .. } | ViewEvent::MouseWheel { .. }) => { - self.pointer_feedback_enabled = true; - let pointer_focus = self.event_router.pointer_focus().cloned(); - self.sync_hover_rect_to_pointer_path(pointer_focus.as_ref()); - } - (false, ViewEvent::CursorMoved { .. }) - if event.cursor_has_velocity( - POINTER_FEEDBACK_REENABLE_MIN_DISTANCE_PX, - POINTER_FEEDBACK_REENABLE_MAX_DURATION, - ) => - { - self.pointer_feedback_enabled = true; - let pointer_focus = self.event_router.pointer_focus().cloned(); - self.sync_hover_rect_to_pointer_path(pointer_focus.as_ref()); - } - _ => {} - } - } - - fn focus(&mut self, target: &DesktopTarget, instance_manager: &InstanceManager) -> Result { - // Focus changes can alter launcher layout targets. - let transitions = self.event_router.focus(target); - if let Some((from, to)) = transitions.keyboard_focus_change() { - self.apply_launcher_layout_for_focus_change(from.cloned(), to.cloned(), true); - } - self.forward_event_transitions(transitions, instance_manager) - } - - /// If the target is involved in any focus path, unfocus it. - /// - /// For the keyboard focus, this focuses the parent. - /// - /// For the cursor focus, this clears the focus (we can't refocus here using the hit tester, - /// because the target may be in the hierarchy). - fn unfocus(&mut self, target: DesktopTarget, instance_manager: &InstanceManager) -> Result<()> { - // Keyboard focus - - let focus = self.event_router.focused(); - let focus_path = self.aggregates.hierarchy.resolve_path(focus); - // Optimization: The parent can be resolved directly from the focus path. - if focus_path.contains(&target) - && let Some(parent) = self.aggregates.hierarchy.parent(&target) - { - assert!(self.focus(&parent.clone(), instance_manager)?.is_none()); - } - - let pointer_focus = self.event_router.pointer_focus(); - let focus_path = self.aggregates.hierarchy.resolve_path(pointer_focus); - if focus_path.contains(&target) { - let transitions = self.event_router.unfocus_pointer()?; - assert!( - self.forward_event_transitions(transitions, instance_manager)? - .is_none() - ); - } - Ok(()) - } - - #[allow(unused)] - fn refocus_pointer( - &mut self, - instance_manager: &InstanceManager, - render_geometry: &RenderGeometry, - ) -> Result { - let transitions = self - .event_router - .reset_pointer_focus(&AggregateHitTester::new( - &self.aggregates.hierarchy, - &self.layouter, - &self.aggregates.launchers, - &self.aggregates.instances, - render_geometry, - ))?; - - self.forward_event_transitions(transitions, instance_manager) - } - - fn present_instance( - &mut self, - launcher: LaunchProfileId, - originating_from: Option, - instance: InstanceId, - scene: &Scene, - ) -> Result { - let originating_presenter = originating_from - .and_then(|originating_from| self.aggregates.instances.get(&originating_from)); - - let background_for_instance = self - .aggregates - .launchers - .get(&launcher) - .expect("Launcher not found") - .should_render_instance_background(); - - // Correctness: We animate from 0,0 if no originating exist. Need a position here. - let initial_center_translation = originating_presenter - .map(|op| op.layout_transform_animation.value().translate) - .unwrap_or_default(); - - let presenter = InstancePresenter::new( - initial_center_translation, - background_for_instance, - self.aggregates.project_presenter.location.clone(), - scene, - ); - - self.aggregates.instances.insert(instance, presenter)?; - - let nested = self.aggregates.hierarchy.get_nested(&launcher.into()); - let insertion_pos = if let Some(originating_from) = originating_from { - nested - .iter() - .position(|i| *i == DesktopTarget::Instance(originating_from)) - .map(|i| i + 1) - .unwrap_or(nested.len()) - } else { - 0 - }; - - // Inform the launcher to fade out. - self.aggregates - .launchers - .get_mut(&launcher) - .expect("Launcher not found") - .fade_out(); - - Ok(insertion_pos) - } - - fn hide_instance(&mut self, instance: InstanceId) -> Result<()> { - let Some(DesktopTarget::Launcher(launcher)) = - self.aggregates.hierarchy.parent(&instance.into()).cloned() - else { - bail!("Internal error: Launcher not found"); - }; - - self.remove_target(&DesktopTarget::Instance(instance))?; - self.aggregates.instances.remove(&instance)?; - - if !self - .aggregates - .hierarchy - .entry(&launcher.into()) - .has_nested() - { - self.aggregates - .launchers - .get_mut(&launcher) - .expect("Launcher not found") - .fade_in(); - } - - Ok(()) - } - - fn present_view( - &mut self, - instance: InstanceId, - view_creation_info: &ViewCreationInfo, - ) -> Result<()> { - if view_creation_info.role != ViewRole::Primary { - todo!("Only primary views are supported yet"); - } - - let Some(instance_presenter) = self.aggregates.instances.get_mut(&instance) else { - bail!("Instance not found"); - }; - - if !matches!( - instance_presenter.state, - InstancePresenterState::WaitingForPrimaryView - ) { - bail!("Primary view is already presenting"); - } - - // Architecture: Move this transition in the InstancePresenter - // - // Feature: Add a alpha animation just for the view. - instance_presenter.state = InstancePresenterState::Presenting { - view: PrimaryViewPresenter { - creation_info: view_creation_info.clone(), - }, - }; - - // Add the view to the hierarchy. - self.aggregates.hierarchy.add( - DesktopTarget::Instance(instance), - DesktopTarget::View(view_creation_info.id), - )?; - self.layouter - .mark_reflow_pending(DesktopTarget::Instance(instance)); - - Ok(()) - } - - fn hide_view(&mut self, path: ViewPath) -> Result<()> { - let Some(instance_presenter) = self.aggregates.instances.get_mut(&path.instance) else { - warn!("Can't hide view: Instance for view not found"); - // Robustness: Decide if this should return an error. - return Ok(()); - }; - - // Architecture: Move this into the InstancePresenter (don't make state pub). - match &instance_presenter.state { - InstancePresenterState::WaitingForPrimaryView => { - bail!( - "A view needs to be hidden, but instance presenter waits for a view with a primary role." - ) - } - InstancePresenterState::Presenting { view } => { - if view.creation_info.id == path.view { - // Feature: this should initiate a disappearing animation? - instance_presenter.state = InstancePresenterState::Disappearing; - } else { - bail!("Invalid view: It's not related to anything we present"); - } - } - InstancePresenterState::Disappearing => { - // ignored, we are already disappearing. - } - } - - // Robustness: What about focus? - - // And remove the view. - self.remove_target(&DesktopTarget::View(path.view))?; - - Ok(()) - } - - fn apply_layout_changes( - &mut self, - changed: Vec<(DesktopTarget, LayoutRect<2>)>, - animate: bool, - ) { - let mut launchers_to_relayout: HashSet = HashSet::new(); - - for (id, layout_rect) in changed { - let rect_px: RectPx = layout_rect.into(); - let rect: Rect = rect_px.into(); - - match id { - DesktopTarget::Desktop => {} - DesktopTarget::Instance(instance_id) => { - if let Some(launcher_id) = self.instance_launcher(instance_id) { - launchers_to_relayout.insert(launcher_id); - } - } - DesktopTarget::Group(group_id) => { - self.aggregates - .groups - .get_mut(&group_id) - .expect("Missing group") - .rect = rect; - } - DesktopTarget::Launcher(launcher_id) => { - launchers_to_relayout.insert(launcher_id); - - self.aggregates - .launchers - .get_mut(&launcher_id) - .expect("Launcher missing") - .set_rect(rect, animate); - } - DesktopTarget::View(..) => { - // Robustness: Support resize here? - } - } - } - - for launcher_id in launchers_to_relayout { - self.apply_launcher_instance_layout(launcher_id, animate); - } - } - - fn instance_launcher(&self, instance_id: InstanceId) -> Option { - let instance_target = DesktopTarget::Instance(instance_id); - match self.aggregates.hierarchy.parent(&instance_target) { - Some(DesktopTarget::Launcher(id)) => Some(*id), - _ => None, - } - } - - 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); - } - } - - 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); - } - } - - fn sync_hover_rect_to_pointer_path(&mut self, pointer_focus: Option<&DesktopTarget>) { - let hover_rect = match pointer_focus { - Some(DesktopTarget::Instance(instance_id)) => { - self.rect(&DesktopTarget::Instance(*instance_id)) - } - Some(DesktopTarget::View(view_id)) => match self - .aggregates - .hierarchy - .parent(&DesktopTarget::View(*view_id)) - { - Some(DesktopTarget::Instance(instance_id)) => { - self.rect(&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)) - } - _ => None, - }; - - self.aggregates.project_presenter.set_hover_rect(hover_rect); - } - - 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()?; - let launcher_id = self.instance_launcher(focused_instance)?; - let instance_count = self - .aggregates - .hierarchy - .get_nested(&DesktopTarget::Launcher(launcher_id)) - .len(); - - self.aggregates - .launchers - .get(&launcher_id) - .filter(|launcher| launcher.should_relayout_on_focus_change(instance_count)) - .map(|_| launcher_id) - } - - fn preprocess_keyboard_input(&self, event: &Event) -> Result { - // Catch CMD+t and CMD+w if an instance has the keyboard focus. - - if let ViewEvent::KeyboardInput { - event: key_event, .. - } = event.event() - && key_event.state == ElementState::Pressed - && !key_event.repeat - && event.device_states().is_command() - { - let focused = self.event_router.focused(); - let focused = self.aggregates.hierarchy.resolve_path(focused); - - // Simplify: Instance should probably return the launcher, too now. - if let Some(instance) = focused.instance() - && let Some(DesktopTarget::Launcher(launcher)) = - self.aggregates.hierarchy.parent(&instance.into()) - { - match &key_event.logical_key { - Key::Character(c) if c.as_str() == "t" => { - return Ok(DesktopCommand::StartInstance { - launcher: *launcher, - parameters: Default::default(), - } - .into()); - } - Key::Character(c) if c.as_str() == "w" => { - // Architecture: Shouldn't this just end the current view, and let the - // instance decide then? - return Ok(DesktopCommand::StopInstance(instance).into()); - } - _ => {} - } - } - - if let Some(direction) = match &key_event.logical_key { - Key::Named(NamedKey::ArrowLeft) => Some(navigation::Direction::Left), - Key::Named(NamedKey::ArrowRight) => Some(navigation::Direction::Right), - Key::Named(NamedKey::ArrowUp) => Some(navigation::Direction::Up), - Key::Named(NamedKey::ArrowDown) => Some(navigation::Direction::Down), - _ => None, - } { - return Ok(DesktopCommand::Navigate(direction).into()); - } - - if let Key::Named(NamedKey::Escape) = &key_event.logical_key { - return Ok(DesktopCommand::ZoomOut.into()); - } - } - - Ok(Cmd::None) - } - - fn forward_event_transitions( - &mut self, - transitions: EventTransitions, - instance_manager: &InstanceManager, - ) -> Result { - if self.pointer_feedback_enabled - && let Some(pointer_focus) = transitions.pointer_focus_target() - { - self.sync_hover_rect_to_pointer_path(pointer_focus); - } - - let mut cmd = Cmd::None; - - let keyboard_modifiers = self.event_router.keyboard_modifiers(); - - let send_transitions = convert_to_send_transitions( - transitions, - keyboard_modifiers, - &self.aggregates.hierarchy, - ); - - // Robustness: While we need to forward all transitions we currently process only one intent. - for transition in send_transitions { - cmd += self.forward_event_transition(transition, instance_manager)?; - } - - Ok(cmd) - } - - /// Forward event transitions to the appropriate handler based on the target type. - fn forward_event_transition( - &mut self, - SendTransition(target, event): SendTransition, - instance_manager: &InstanceManager, - ) -> Result { - // Route to the appropriate handler based on the last target in the path - match target { - DesktopTarget::Desktop => {} - DesktopTarget::Instance(..) => {} - DesktopTarget::View(view_id) => { - let path = self - .aggregates - .hierarchy - .resolve_path(Some(&view_id.into())); - let Some(instance) = path.instance() else { - // This happens when the instance is gone (resolve_path returns only the view, because it puts it by default in the first position). - warn!( - "Instance of view {view_id:?} not found (path: {path:?}), can't deliver event: {event:?}" - ); - 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 - }; - - if let Err(e) = instance_manager.send_view_event((instance, view_id), event.clone()) - { - // This might happen when an instance ends, but we haven't yet received the - // information. - warn!("Sending view event {event:?} failed with {e}"); - } - } - DesktopTarget::Group(..) => {} - DesktopTarget::Launcher(launcher_id) => { - let launcher = self - .aggregates - .launchers - .get_mut(&launcher_id) - .expect("Launcher not found"); - return launcher.process(event); - } - } - - Ok(Cmd::None) - } - - // Camera - - pub fn camera_for_focus(&self, focus: &DesktopTarget) -> Option { - match focus { - // Desktop and TopBand are constrained to their size. - DesktopTarget::Desktop => self - .rect(&DesktopTarget::Desktop) - .map(|rect| rect.to_camera()), - - DesktopTarget::Group(group) => { - Some(self.aggregates.groups[group].rect.center().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 - .layout_transform_animation - .final_value() - .translate - .into(); - Some(transform.to_camera()) - } - DesktopTarget::View(_) => { - // Forward this to the parent (which must be a ::Instance). - self.camera_for_focus(self.aggregates.hierarchy.parent(focus)?) - } - } - } - - fn locate_navigation_candidate( - &self, - from: &DesktopTarget, - direction: navigation::Direction, - ) -> Option { - if !matches!( - from, - DesktopTarget::Launcher(..) | DesktopTarget::Instance(..) | DesktopTarget::View(..), - ) { - return None; - } - - let from_rect = self.rect(from)?; - let launcher_targets_without_instances = self - .aggregates - .launchers - .keys() - .map(|l| DesktopTarget::Launcher(*l)) - .filter(|t| self.aggregates.hierarchy.get_nested(t).is_empty()); - let all_instances_or_views = self.aggregates.instances.keys().map(|instance| { - if let Some(view) = self.aggregates.view_of_instance(*instance) { - DesktopTarget::View(view) - } else { - DesktopTarget::Instance(*instance) - } - }); - let navigation_candidates = launcher_targets_without_instances - .chain(all_instances_or_views) - .filter_map(|target| self.rect(&target).map(|rect| (target, rect))); - - let ordered = - ordered_rects_in_direction(from_rect.center(), direction, navigation_candidates); - if let Some((nearest, _rect)) = ordered.first() { - return Some(nearest.clone()); - } - None - } - /// Remove the target from the hierarchy. Specific target aggregates are left /// untouched (they may be needed for fading out, etc.). - pub fn remove_target(&mut self, target: &DesktopTarget) -> Result<()> { + fn remove_target(&mut self, target: &DesktopTarget) -> Result<()> { // Check if all components that hold reference actually removed them. self.event_router.notify_removed(target)?; @@ -1167,160 +251,3 @@ impl LayoutTopology for OrderedHierarchy { self.parent(id).cloned() } } - -struct DesktopLayoutAlgorithm<'a> { - aggregates: &'a Aggregates, - default_panel_size: SizePx, -} - -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(), - } - } -} - -pub(crate) fn place_container_children( - axis: LayoutAxis, - spacing: i32, - mut offset: Offset<2>, - child_sizes: &[Size<2>], -) -> Vec> { - let axis_index: usize = axis.into(); - let mut child_offsets = Vec::with_capacity(child_sizes.len()); - - for (index, &child_size) in child_sizes.iter().enumerate() { - if index > 0 { - offset[axis_index] += spacing; - } - child_offsets.push(offset); - offset[axis_index] += child_size[axis_index] as i32; - } - - child_offsets -} - -impl LayoutAlgorithm for DesktopLayoutAlgorithm<'_> { - fn measure(&self, id: &DesktopTarget, child_sizes: &[Size<2>]) -> Size<2> { - if let DesktopTarget::Launcher(launcher_id) = id - && let Some(size) = - self.aggregates.launchers[launcher_id].panel_measure_size(self.default_panel_size) - { - return size; - } - - match self.resolve_layout_spec(id) { - LayoutSpec::Leaf(size) => size.into(), - LayoutSpec::Container { - axis, - padding, - spacing, - } => { - let axis = *axis; - let mut inner_size = Size::EMPTY; - - for (index, &child_size) in child_sizes.iter().enumerate() { - for dim in 0..2 { - if dim == axis { - inner_size[dim] += child_size[dim]; - if index > 0 { - inner_size[dim] += spacing; - } - } else { - inner_size[dim] = max(inner_size[dim], child_size[dim]); - } - } - } - - padding.leading + inner_size + padding.trailing - } - } - } - - fn place_children( - &self, - id: &DesktopTarget, - parent_offset: Offset<2>, - child_sizes: &[Size<2>], - ) -> Vec> { - if let DesktopTarget::Launcher(launcher_id) = id - && let Some(offsets) = self.aggregates.launchers[launcher_id].panel_child_offsets( - parent_offset, - child_sizes, - self.default_panel_size, - ) - { - return offsets; - } - - match self.resolve_layout_spec(id) { - LayoutSpec::Leaf(_) => Vec::new(), - LayoutSpec::Container { - axis, - padding, - spacing, - } => { - let offset = parent_offset + Offset::from(padding.leading); - - place_container_children(axis, spacing as i32, offset, child_sizes) - } - } - } -} - -// Path utilities - -impl DesktopFocusPath { - pub fn instance(&self) -> Option { - self.iter().rev().find_map(|t| match t { - DesktopTarget::Instance(id) => Some(*id), - _ => None, - }) - } - - /// Is this or a parent something that can be added new instances to? - pub fn instance_parent(&self) -> Option { - self.iter() - .enumerate() - .rev() - .find_map(|(i, t)| match t { - DesktopTarget::Desktop => None, - DesktopTarget::Group(..) => None, - DesktopTarget::Launcher(..) => Some(i + 1), - DesktopTarget::Instance(..) => Some(i), - DesktopTarget::View(..) => { - assert!(matches!(self[i - 1], DesktopTarget::Instance(..))); - Some(i - 1) - } - }) - .map(|i| self.iter().take(i).cloned().collect::>().into()) - } -} diff --git a/desktop/src/desktop_system/command_dispatch.rs b/desktop/src/desktop_system/command_dispatch.rs new file mode 100644 index 00000000..ea54582e --- /dev/null +++ b/desktop/src/desktop_system/command_dispatch.rs @@ -0,0 +1,141 @@ +use anyhow::{Result, anyhow}; +use log::warn; + +use massive_applications::{CreationMode, ViewRole}; +use massive_shell::Scene; + +use super::{DesktopCommand, DesktopSystem, DesktopTarget}; +use crate::focus_path::PathResolver; +use crate::instance_manager::InstanceManager; + +impl DesktopSystem { + // Architecture: The current focus is part of the system, so DesktopInteraction should probably be embedded here. + pub(super) fn apply_command( + &mut self, + command: DesktopCommand, + scene: &Scene, + instance_manager: &mut InstanceManager, + ) -> Result<()> { + match command { + DesktopCommand::StartInstance { + launcher, + parameters, + } => { + // Feature: Support starting non-primary applications. + let application = self + .env + .applications + .get_named(&self.env.primary_application) + .ok_or(anyhow!("Internal error, application not registered"))?; + + let instance = + instance_manager.spawn(application, CreationMode::New(parameters))?; + + // Robustness: Should this be a real, logged event? + // Architecture: Better to start up the primary directly, so that we can remove the PresentInstance command? + self.apply_command( + DesktopCommand::PresentInstance { launcher, instance }, + scene, + instance_manager, + ) + } + + DesktopCommand::StopInstance(instance) => { + // Remove the instance from the focus first. + // + // Detail: This causes an unfocus event sent to the instance's view which may + // unexpected while teardown. + + let target = DesktopTarget::Instance(instance); + let replacement_focus = self + .aggregates + .hierarchy + .resolve_replacement_focus_for_stopping_instance( + self.event_router.focused(), + instance, + ); + + if let Some(replacement_focus) = replacement_focus { + self.focus(&replacement_focus, instance_manager)?; + } + + self.unfocus_pointer_if_path_contains(&target, instance_manager)?; + + // This might fail if StopInstance gets triggered with an instance that ended in + // itself (shouldn't the instance_manager keep it until we finally free it). + if let Err(e) = instance_manager.request_shutdown(instance) { + warn!("Failed to shutdown instance, it may be gone already: {e}"); + }; + + // We hide the instance as soon we request a shutdown so that they can't be in the + // navigation tree anymore. + self.hide_instance(instance)?; + + Ok(()) + } + + DesktopCommand::PresentInstance { launcher, instance } => { + let originating_from = self + .aggregates + .hierarchy + .resolve_path(self.event_router.focused()) + .instance(); + + let insertion_index = + self.present_instance(launcher, originating_from, instance, scene)?; + + let instance_target = DesktopTarget::Instance(instance); + + // Add this instance to the hierarchy. + self.aggregates.hierarchy.insert_at( + launcher.into(), + insertion_index, + instance_target.clone(), + )?; + self.layouter + .mark_reflow_pending(DesktopTarget::Launcher(launcher)); + + // Focus it. + self.focus(&instance_target, instance_manager)?; + Ok(()) + } + + DesktopCommand::PresentView(instance, creation_info) => { + self.present_view(instance, &creation_info)?; + + let focused = self.event_router.focused(); + // If this instance is currently focused and the new view is primary, make it + // foreground so that the view is focused. + if matches!(focused, Some(DesktopTarget::Instance(i)) if *i == instance) + && creation_info.role == ViewRole::Primary + { + self.focus(&DesktopTarget::View(creation_info.id), instance_manager)?; + } + + Ok(()) + } + DesktopCommand::HideView(view_path) => self.hide_view(view_path), + + DesktopCommand::Project(project_command) => { + self.apply_project_command(project_command, scene) + } + + DesktopCommand::ZoomOut => { + if let Some(focused) = self.event_router.focused() + && let Some(parent) = self.aggregates.hierarchy.parent(focused) + { + self.focus(&parent.clone(), instance_manager)?; + } + Ok(()) + } + DesktopCommand::Navigate(direction) => { + if let Some(focused) = self.event_router.focused() + && let Some(candidate) = self.locate_navigation_candidate(focused, direction) + { + self.focus(&candidate, instance_manager)?; + } + Ok(()) + } + } + } +} diff --git a/desktop/src/desktop_system/commands.rs b/desktop/src/desktop_system/commands.rs new file mode 100644 index 00000000..fcdad874 --- /dev/null +++ b/desktop/src/desktop_system/commands.rs @@ -0,0 +1,46 @@ +use derive_more::Debug; + +use massive_applications::{InstanceId, InstanceParameters, ViewCreationInfo}; + +use super::navigation::Direction; +use crate::instance_manager::ViewPath; +use crate::projects::{LaunchGroupProperties, LaunchProfile, LaunchProfileId}; + +/// The commands the desktop system can execute. +#[derive(Debug)] +pub enum DesktopCommand { + Project(ProjectCommand), + StartInstance { + launcher: LaunchProfileId, + parameters: InstanceParameters, + }, + StopInstance(InstanceId), + PresentInstance { + launcher: LaunchProfileId, + instance: InstanceId, + }, + PresentView(InstanceId, ViewCreationInfo), + HideView(ViewPath), + ZoomOut, + Navigate(Direction), +} + +#[derive(Debug)] +pub enum ProjectCommand { + // Project Configuration + AddLaunchGroup { + parent: Option, + id: crate::projects::GroupId, + properties: LaunchGroupProperties, + }, + #[allow(unused)] + RemoveLaunchGroup(crate::projects::GroupId), + AddLauncher { + group: crate::projects::GroupId, + id: LaunchProfileId, + profile: LaunchProfile, + }, + #[allow(unused)] + RemoveLauncher(LaunchProfileId), + SetStartupProfile(Option), +} diff --git a/desktop/src/desktop_system/event_forwarding.rs b/desktop/src/desktop_system/event_forwarding.rs new file mode 100644 index 00000000..f89b5a63 --- /dev/null +++ b/desktop/src/desktop_system/event_forwarding.rs @@ -0,0 +1,120 @@ +use anyhow::Result; +use log::warn; + +use crate::event_router::EventTransitions; +use crate::focus_path::PathResolver; +use crate::instance_manager::InstanceManager; +use crate::send_transition::{SendTransition, convert_to_send_transitions}; + +use super::{Cmd, DesktopSystem, DesktopTarget}; + +impl DesktopSystem { + pub(super) fn forward_event_transitions( + &mut self, + transitions: EventTransitions, + instance_manager: &InstanceManager, + ) -> Result { + if self.pointer_feedback_enabled + && let Some(pointer_focus) = transitions.pointer_focus_target() + { + self.sync_hover_rect_to_pointer_path(pointer_focus); + } + + let mut cmd = Cmd::None; + + let keyboard_modifiers = self.event_router.keyboard_modifiers(); + + let send_transitions = convert_to_send_transitions( + transitions, + keyboard_modifiers, + &self.aggregates.hierarchy, + ); + + // Robustness: While we need to forward all transitions we currently process only one intent. + for transition in send_transitions { + cmd += self.forward_event_transition(transition, instance_manager)?; + } + + Ok(cmd) + } + + /// Forward event transitions to the appropriate handler based on the target type. + fn forward_event_transition( + &mut self, + SendTransition(target, event): SendTransition, + instance_manager: &InstanceManager, + ) -> Result { + // Route to the appropriate handler based on the last target in the path + match target { + DesktopTarget::Desktop => {} + DesktopTarget::Instance(..) => {} + DesktopTarget::View(view_id) => { + let path = self + .aggregates + .hierarchy + .resolve_path(Some(&view_id.into())); + let Some(instance) = path.instance() else { + // This happens when the instance is gone (resolve_path returns only the view, because it puts it by default in the first position). + warn!( + "Instance of view {view_id:?} not found (path: {path:?}), can't deliver event: {event:?}" + ); + 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 + }; + + if let Err(e) = instance_manager.send_view_event((instance, view_id), event.clone()) + { + // This might happen when an instance ends, but we haven't yet received the + // information. + warn!("Sending view event {event:?} failed with {e}"); + } + } + DesktopTarget::Group(..) => {} + DesktopTarget::Launcher(launcher_id) => { + let launcher = self + .aggregates + .launchers + .get_mut(&launcher_id) + .expect("Launcher not found"); + return launcher.process(event); + } + } + + Ok(Cmd::None) + } + + pub(super) fn sync_hover_rect_to_pointer_path( + &mut self, + pointer_focus: Option<&DesktopTarget>, + ) { + let hover_rect = match pointer_focus { + Some(DesktopTarget::Instance(instance_id)) => { + self.rect(&DesktopTarget::Instance(*instance_id)) + } + Some(DesktopTarget::View(view_id)) => match self + .aggregates + .hierarchy + .parent(&DesktopTarget::View(*view_id)) + { + Some(DesktopTarget::Instance(instance_id)) => { + self.rect(&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)) + } + _ => None, + }; + + self.aggregates.project_presenter.set_hover_rect(hover_rect); + } +} diff --git a/desktop/src/desktop_system/focus_input.rs b/desktop/src/desktop_system/focus_input.rs new file mode 100644 index 00000000..61716661 --- /dev/null +++ b/desktop/src/desktop_system/focus_input.rs @@ -0,0 +1,182 @@ +use anyhow::Result; +use winit::event::ElementState; +use winit::keyboard::{Key, NamedKey}; + +use massive_applications::ViewEvent; +use massive_input::Event; +use massive_renderer::RenderGeometry; + +use super::{ + Cmd, DesktopCommand, DesktopSystem, DesktopTarget, POINTER_FEEDBACK_REENABLE_MAX_DURATION, + POINTER_FEEDBACK_REENABLE_MIN_DISTANCE_PX, navigation::Direction, +}; +use crate::focus_path::PathResolver; +use crate::hit_tester::AggregateHitTester; +use crate::instance_manager::InstanceManager; + +impl DesktopSystem { + pub fn process_input_event( + &mut self, + event: &Event, + instance_manager: &InstanceManager, + render_geometry: &RenderGeometry, + ) -> Result { + let keyboard_cmd = self.preprocess_keyboard_input(event)?; + + let cmd = if !keyboard_cmd.is_none() { + keyboard_cmd + } else { + let hit_tester = AggregateHitTester::new( + &self.aggregates.hierarchy, + &self.layouter, + &self.aggregates.launchers, + &self.aggregates.instances, + render_geometry, + ); + + let transitions = self.event_router.process(event, &hit_tester)?; + self.forward_event_transitions(transitions, instance_manager)? + }; + + self.update_pointer_feedback(event); + + Ok(cmd) + } + + fn update_pointer_feedback(&mut self, event: &Event) { + match (self.pointer_feedback_enabled, event.event()) { + ( + true, + ViewEvent::KeyboardInput { + event: key_event, .. + }, + ) if key_event.state == ElementState::Pressed && !key_event.repeat => { + self.pointer_feedback_enabled = false; + self.aggregates.project_presenter.set_hover_rect(None); + } + (false, ViewEvent::MouseInput { .. } | ViewEvent::MouseWheel { .. }) => { + self.pointer_feedback_enabled = true; + let pointer_focus = self.event_router.pointer_focus().cloned(); + self.sync_hover_rect_to_pointer_path(pointer_focus.as_ref()); + } + (false, ViewEvent::CursorMoved { .. }) + if event.cursor_has_velocity( + POINTER_FEEDBACK_REENABLE_MIN_DISTANCE_PX, + POINTER_FEEDBACK_REENABLE_MAX_DURATION, + ) => + { + self.pointer_feedback_enabled = true; + let pointer_focus = self.event_router.pointer_focus().cloned(); + self.sync_hover_rect_to_pointer_path(pointer_focus.as_ref()); + } + _ => {} + } + } + + pub(super) fn focus( + &mut self, + target: &DesktopTarget, + instance_manager: &InstanceManager, + ) -> Result<()> { + let transitions = self.event_router.focus(target); + + // Invariant: Programmatic focus changes must not trigger commands. + assert!( + self.forward_event_transitions(transitions, instance_manager)? + .is_none() + ); + + Ok(()) + } + + pub(super) fn unfocus_pointer_if_path_contains( + &mut self, + target: &DesktopTarget, + instance_manager: &InstanceManager, + ) -> Result<()> { + if self + .aggregates + .hierarchy + .path_contains_target(self.event_router.pointer_focus(), target) + { + let transitions = self.event_router.unfocus_pointer()?; + assert!( + self.forward_event_transitions(transitions, instance_manager)? + .is_none() + ); + } + Ok(()) + } + + #[allow(unused)] + fn refocus_pointer( + &mut self, + instance_manager: &InstanceManager, + render_geometry: &RenderGeometry, + ) -> Result { + let transitions = self + .event_router + .reset_pointer_focus(&AggregateHitTester::new( + &self.aggregates.hierarchy, + &self.layouter, + &self.aggregates.launchers, + &self.aggregates.instances, + render_geometry, + ))?; + + self.forward_event_transitions(transitions, instance_manager) + } + + fn preprocess_keyboard_input(&self, event: &Event) -> Result { + // Catch CMD+t and CMD+w if an instance has the keyboard focus. + + if let ViewEvent::KeyboardInput { + event: key_event, .. + } = event.event() + && key_event.state == ElementState::Pressed + && !key_event.repeat + && event.device_states().is_command() + { + let focused = self.event_router.focused(); + let focused = self.aggregates.hierarchy.resolve_path(focused); + + // Simplify: Instance should probably return the launcher, too now. + if let Some(instance) = focused.instance() + && let Some(DesktopTarget::Launcher(launcher)) = + self.aggregates.hierarchy.parent(&instance.into()) + { + match &key_event.logical_key { + Key::Character(c) if c.as_str() == "t" => { + return Ok(DesktopCommand::StartInstance { + launcher: *launcher, + parameters: Default::default(), + } + .into()); + } + Key::Character(c) if c.as_str() == "w" => { + // Architecture: Shouldn't this just end the current view, and let the + // instance decide then? + return Ok(DesktopCommand::StopInstance(instance).into()); + } + _ => {} + } + } + + if let Some(direction) = match &key_event.logical_key { + Key::Named(NamedKey::ArrowLeft) => Some(Direction::Left), + Key::Named(NamedKey::ArrowRight) => Some(Direction::Right), + Key::Named(NamedKey::ArrowUp) => Some(Direction::Up), + Key::Named(NamedKey::ArrowDown) => Some(Direction::Down), + _ => None, + } { + return Ok(DesktopCommand::Navigate(direction).into()); + } + + if let Key::Named(NamedKey::Escape) = &key_event.logical_key { + return Ok(DesktopCommand::ZoomOut.into()); + } + } + + Ok(Cmd::None) + } +} diff --git a/desktop/src/desktop_system/focus_path_ext.rs b/desktop/src/desktop_system/focus_path_ext.rs new file mode 100644 index 00000000..79cb096f --- /dev/null +++ b/desktop/src/desktop_system/focus_path_ext.rs @@ -0,0 +1,31 @@ +use massive_applications::InstanceId; + +use super::{DesktopFocusPath, DesktopTarget}; + +impl DesktopFocusPath { + pub(super) fn instance(&self) -> Option { + self.iter().rev().find_map(|t| match t { + DesktopTarget::Instance(id) => Some(*id), + _ => None, + }) + } + + #[allow(unused)] + /// Is this or a parent something that can be added new instances to? + pub(super) fn instance_parent(&self) -> Option { + self.iter() + .enumerate() + .rev() + .find_map(|(i, t)| match t { + DesktopTarget::Desktop => None, + DesktopTarget::Group(..) => None, + DesktopTarget::Launcher(..) => Some(i + 1), + DesktopTarget::Instance(..) => Some(i), + DesktopTarget::View(..) => { + assert!(matches!(self[i - 1], DesktopTarget::Instance(..))); + Some(i - 1) + } + }) + .map(|i| self.iter().take(i).cloned().collect::>().into()) + } +} diff --git a/desktop/src/desktop_system/hierarchy_focus.rs b/desktop/src/desktop_system/hierarchy_focus.rs new file mode 100644 index 00000000..43de6625 --- /dev/null +++ b/desktop/src/desktop_system/hierarchy_focus.rs @@ -0,0 +1,237 @@ +use massive_applications::InstanceId; + +use super::DesktopTarget; +use crate::focus_path::PathResolver; +use crate::{DirectionBias, OrderedHierarchy}; + +impl OrderedHierarchy { + pub(super) fn resolve_replacement_focus_for_stopping_instance( + &self, + focused: Option<&DesktopTarget>, + instance: InstanceId, + ) -> Option { + let instance_target = DesktopTarget::Instance(instance); + if !self.path_contains_target(focused, &instance_target) { + return None; + } + + if let Some(neighbor) = self.resolve_neighbor_for_stopping_instance(focused, instance) { + return Some(self.resolve_neighbor_focus_target(&neighbor)); + } + + Some( + self.parent(&instance_target) + .expect("Internal error: instance has no parent") + .clone(), + ) + } + + pub(super) fn resolve_neighbor_for_stopping_instance( + &self, + focused: Option<&DesktopTarget>, + instance: InstanceId, + ) -> Option { + let focused_path = self.resolve_path(focused); + if focused_path.instance() != Some(instance) { + return None; + } + + let instance_target = DesktopTarget::Instance(instance); + self.entry(&instance_target) + .neighbor(DirectionBias::Begin) + .cloned() + } + + pub(super) fn resolve_neighbor_focus_target(&self, neighbor: &DesktopTarget) -> DesktopTarget { + match neighbor { + DesktopTarget::Instance(_) => { + if let [DesktopTarget::View(view)] = self.get_nested(neighbor) { + DesktopTarget::View(*view) + } else { + neighbor.clone() + } + } + _ => neighbor.clone(), + } + } + + pub(super) fn path_contains_target( + &self, + focused: Option<&DesktopTarget>, + target: &DesktopTarget, + ) -> bool { + self.resolve_path(focused).contains(target) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::projects::LaunchProfileId; + use uuid::Uuid; + + fn instance_id() -> InstanceId { + Uuid::new_v4().into() + } + + fn view_id() -> massive_applications::ViewId { + Uuid::new_v4().into() + } + + fn launcher_id() -> LaunchProfileId { + Uuid::new_v4().into() + } + + fn hierarchy_with_instances( + instances: &[InstanceId], + ) -> (OrderedHierarchy, LaunchProfileId) { + let launcher = launcher_id(); + + let mut hierarchy = OrderedHierarchy::default(); + hierarchy + .add(DesktopTarget::Desktop, DesktopTarget::Launcher(launcher)) + .unwrap(); + + for instance in instances { + hierarchy + .add( + DesktopTarget::Launcher(launcher), + DesktopTarget::Instance(*instance), + ) + .unwrap(); + } + + (hierarchy, launcher) + } + + #[test] + fn resolve_neighbor_for_stopping_instance_returns_none_when_instance_is_not_focused() { + let first = instance_id(); + let second = instance_id(); + let (hierarchy, _launcher) = hierarchy_with_instances(&[first, second]); + + let focused = DesktopTarget::Instance(second); + let neighbor = hierarchy.resolve_neighbor_for_stopping_instance(Some(&focused), first); + + assert_eq!(neighbor, None); + } + + #[test] + fn resolve_neighbor_for_stopping_instance_returns_sibling_when_focused() { + let first = instance_id(); + let second = instance_id(); + let (hierarchy, _launcher) = hierarchy_with_instances(&[first, second]); + + let focused = DesktopTarget::Instance(first); + let neighbor = hierarchy.resolve_neighbor_for_stopping_instance(Some(&focused), first); + + assert_eq!(neighbor, Some(DesktopTarget::Instance(second))); + } + + #[test] + fn resolve_replacement_focus_for_stopping_instance_returns_none_when_target_not_in_focus_path() + { + let first = instance_id(); + let second = instance_id(); + let (hierarchy, _launcher) = hierarchy_with_instances(&[first, second]); + + let focused = DesktopTarget::Instance(second); + let replacement = + hierarchy.resolve_replacement_focus_for_stopping_instance(Some(&focused), first); + + assert_eq!(replacement, None); + } + + #[test] + fn resolve_replacement_focus_for_stopping_instance_prefers_neighbor_view() { + let first = instance_id(); + let second = instance_id(); + let view = view_id(); + let (mut hierarchy, _launcher) = hierarchy_with_instances(&[first, second]); + + hierarchy + .add(DesktopTarget::Instance(second), DesktopTarget::View(view)) + .unwrap(); + + let focused = DesktopTarget::Instance(first); + let replacement = + hierarchy.resolve_replacement_focus_for_stopping_instance(Some(&focused), first); + + assert_eq!(replacement, Some(DesktopTarget::View(view))); + } + + #[test] + fn resolve_replacement_focus_for_stopping_instance_works_when_focus_is_view_inside_instance() { + let first = instance_id(); + let second = instance_id(); + let first_view = view_id(); + let second_view = view_id(); + let (mut hierarchy, _launcher) = hierarchy_with_instances(&[first, second]); + + hierarchy + .add( + DesktopTarget::Instance(first), + DesktopTarget::View(first_view), + ) + .unwrap(); + hierarchy + .add( + DesktopTarget::Instance(second), + DesktopTarget::View(second_view), + ) + .unwrap(); + + let focused = DesktopTarget::View(first_view); + let replacement = + hierarchy.resolve_replacement_focus_for_stopping_instance(Some(&focused), first); + + assert_eq!(replacement, Some(DesktopTarget::View(second_view))); + } + + #[test] + fn resolve_replacement_focus_for_stopping_instance_falls_back_to_parent() { + let instance = instance_id(); + let (hierarchy, launcher) = hierarchy_with_instances(&[instance]); + + let focused = DesktopTarget::Instance(instance); + let replacement = + hierarchy.resolve_replacement_focus_for_stopping_instance(Some(&focused), instance); + + assert_eq!(replacement, Some(DesktopTarget::Launcher(launcher))); + } + + #[test] + fn resolve_neighbor_focus_target_prefers_single_view_of_instance() { + let instance = instance_id(); + let view = view_id(); + let (mut hierarchy, _launcher) = hierarchy_with_instances(&[instance]); + + hierarchy + .add(DesktopTarget::Instance(instance), DesktopTarget::View(view)) + .unwrap(); + + let focus_target = + hierarchy.resolve_neighbor_focus_target(&DesktopTarget::Instance(instance)); + assert_eq!(focus_target, DesktopTarget::View(view)); + } + + #[test] + fn resolve_neighbor_focus_target_keeps_instance_without_view() { + let instance = instance_id(); + let (hierarchy, _launcher) = hierarchy_with_instances(&[instance]); + + let focus_target = + hierarchy.resolve_neighbor_focus_target(&DesktopTarget::Instance(instance)); + assert_eq!(focus_target, DesktopTarget::Instance(instance)); + } + + #[test] + fn resolve_neighbor_focus_target_keeps_non_instance_target() { + let launcher = launcher_id(); + let hierarchy = OrderedHierarchy::default(); + + let focus_target = + hierarchy.resolve_neighbor_focus_target(&DesktopTarget::Launcher(launcher)); + assert_eq!(focus_target, DesktopTarget::Launcher(launcher)); + } +} diff --git a/desktop/src/desktop_system/layout_algorithm.rs b/desktop/src/desktop_system/layout_algorithm.rs new file mode 100644 index 00000000..b7e93e6a --- /dev/null +++ b/desktop/src/desktop_system/layout_algorithm.rs @@ -0,0 +1,137 @@ +use std::cmp::max; + +use massive_geometry::SizePx; +use massive_layout::{LayoutAlgorithm, LayoutAxis, Offset, Size}; + +use super::{Aggregates, DesktopTarget}; +use crate::layout::{LayoutSpec, ToContainer}; + +const SECTION_SPACING: u32 = 20; + +pub(super) struct DesktopLayoutAlgorithm<'a> { + pub(super) aggregates: &'a Aggregates, + pub(super) default_panel_size: SizePx, +} + +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(), + } + } +} + +pub(crate) fn place_container_children( + axis: LayoutAxis, + spacing: i32, + mut offset: Offset<2>, + child_sizes: &[Size<2>], +) -> Vec> { + let axis_index: usize = axis.into(); + let mut child_offsets = Vec::with_capacity(child_sizes.len()); + + for (index, &child_size) in child_sizes.iter().enumerate() { + if index > 0 { + offset[axis_index] += spacing; + } + child_offsets.push(offset); + offset[axis_index] += child_size[axis_index] as i32; + } + + child_offsets +} + +impl LayoutAlgorithm for DesktopLayoutAlgorithm<'_> { + fn measure(&self, id: &DesktopTarget, child_sizes: &[Size<2>]) -> Size<2> { + if let DesktopTarget::Launcher(launcher_id) = id + && let Some(size) = + self.aggregates.launchers[launcher_id].panel_measure_size(self.default_panel_size) + { + return size; + } + + match self.resolve_layout_spec(id) { + LayoutSpec::Leaf(size) => size.into(), + LayoutSpec::Container { + axis, + padding, + spacing, + } => { + let axis = *axis; + let mut inner_size = Size::EMPTY; + + for (index, &child_size) in child_sizes.iter().enumerate() { + for dim in 0..2 { + if dim == axis { + inner_size[dim] += child_size[dim]; + if index > 0 { + inner_size[dim] += spacing; + } + } else { + inner_size[dim] = max(inner_size[dim], child_size[dim]); + } + } + } + + padding.leading + inner_size + padding.trailing + } + } + } + + fn place_children( + &self, + id: &DesktopTarget, + parent_offset: Offset<2>, + child_sizes: &[Size<2>], + ) -> Vec> { + if let DesktopTarget::Launcher(launcher_id) = id + && let Some(offsets) = self.aggregates.launchers[launcher_id].panel_child_offsets( + parent_offset, + child_sizes, + self.default_panel_size, + ) + { + return offsets; + } + + match self.resolve_layout_spec(id) { + LayoutSpec::Leaf(_) => Vec::new(), + LayoutSpec::Container { + axis, + padding, + spacing, + } => { + let offset = parent_offset + Offset::from(padding.leading); + + place_container_children(axis, spacing as i32, offset, child_sizes) + } + } + } +} diff --git a/desktop/src/desktop_system/layout_effects.rs b/desktop/src/desktop_system/layout_effects.rs new file mode 100644 index 00000000..7dd7ed13 --- /dev/null +++ b/desktop/src/desktop_system/layout_effects.rs @@ -0,0 +1,218 @@ +use std::collections::HashSet; + +use anyhow::Result; + +use massive_animation::Interpolation; +use massive_applications::InstanceId; +use massive_geometry::{PointPx, Rect, RectPx}; +use massive_layout::Rect as LayoutRect; + +use super::{DesktopLayoutAlgorithm, DesktopSystem, DesktopTarget, TransactionEffectsMode}; +use crate::focus_path::PathResolver; +use crate::instance_presenter::STRUCTURAL_ANIMATION_DURATION; +use crate::projects::{LaunchProfileId, LauncherInstanceLayoutInput, LauncherInstanceLayoutTarget}; + +impl DesktopSystem { + /// Update layout changes and the camera position. + pub fn update_effects(&mut self, mode: Option) -> Result<()> { + let (animate, permit_camera_moves) = match mode { + Some(TransactionEffectsMode::Setup) => (false, true), + Some(TransactionEffectsMode::CameraLocked) => (true, false), + None => (true, true), + }; + + // Layout & apply rects. + let algorithm = DesktopLayoutAlgorithm { + aggregates: &self.aggregates, + default_panel_size: self.default_panel_size, + }; + let changed = self + .layouter + .recompute(&self.aggregates.hierarchy, &algorithm, PointPx::origin()) + .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() { + let camera = self.camera_for_focus(focused); + if let Some(camera) = camera { + if animate { + self.camera.animate_if_changed( + camera, + STRUCTURAL_ANIMATION_DURATION, + Interpolation::CubicOut, + ); + } else { + self.camera.set_immediately(camera); + } + } + } + + Ok(()) + } + + fn apply_layout_changes( + &mut self, + changed: Vec<(DesktopTarget, LayoutRect<2>)>, + animate: bool, + ) { + let mut launchers_to_relayout: HashSet = HashSet::new(); + + for (id, layout_rect) in changed { + let rect_px: RectPx = layout_rect.into(); + let rect: Rect = rect_px.into(); + + match id { + DesktopTarget::Desktop => {} + DesktopTarget::Instance(instance_id) => { + if let Some(launcher_id) = self.instance_launcher(instance_id) { + launchers_to_relayout.insert(launcher_id); + } + } + DesktopTarget::Group(group_id) => { + self.aggregates + .groups + .get_mut(&group_id) + .expect("Missing group") + .rect = rect; + } + DesktopTarget::Launcher(launcher_id) => { + launchers_to_relayout.insert(launcher_id); + + self.aggregates + .launchers + .get_mut(&launcher_id) + .expect("Launcher missing") + .set_rect(rect, animate); + } + DesktopTarget::View(..) => { + // Robustness: Support resize here? + } + } + } + + for launcher_id in launchers_to_relayout { + self.apply_launcher_instance_layout(launcher_id, animate); + } + } + + fn instance_launcher(&self, instance_id: InstanceId) -> Option { + let instance_target = DesktopTarget::Instance(instance_id); + match self.aggregates.hierarchy.parent(&instance_target) { + Some(DesktopTarget::Launcher(id)) => Some(*id), + _ => None, + } + } + + 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); + } + } + + 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()?; + let launcher_id = self.instance_launcher(focused_instance)?; + let instance_count = self + .aggregates + .hierarchy + .get_nested(&DesktopTarget::Launcher(launcher_id)) + .len(); + + self.aggregates + .launchers + .get(&launcher_id) + .filter(|launcher| launcher.should_relayout_on_focus_change(instance_count)) + .map(|_| launcher_id) + } +} diff --git a/desktop/src/desktop_system/navigation.rs b/desktop/src/desktop_system/navigation.rs new file mode 100644 index 00000000..881bc234 --- /dev/null +++ b/desktop/src/desktop_system/navigation.rs @@ -0,0 +1,119 @@ +use std::cmp::Ordering; + +use massive_geometry::{PixelCamera, Point, Rect}; +use massive_scene::{ToCamera, Transform}; + +use super::{DesktopSystem, DesktopTarget}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Left, + Right, + Up, + Down, +} + +impl Direction { + // Given a 45 degree code starting from center in the direction, return true if the other point + // is visible. Also returns false if it's the same point. + fn is_visible(&self, center: Point, other: Point) -> bool { + let dx = other.x - center.x; + let dy = other.y - center.y; + + match self { + Direction::Left => dx < 0.0 && dx.abs() >= dy.abs(), + Direction::Right => dx > 0.0 && dx.abs() >= dy.abs(), + Direction::Up => dy < 0.0 && dy.abs() >= dx.abs(), + Direction::Down => dy > 0.0 && dy.abs() >= dx.abs(), + } + } +} + +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::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 + .layout_transform_animation + .final_value() + .translate + .into(); + Some(transform.to_camera()) + } + DesktopTarget::View(_) => { + self.camera_for_focus(self.aggregates.hierarchy.parent(focus)?) + } + } + } + + pub(super) fn locate_navigation_candidate( + &self, + from: &DesktopTarget, + direction: Direction, + ) -> Option { + if !matches!( + from, + DesktopTarget::Launcher(..) | DesktopTarget::Instance(..) | DesktopTarget::View(..), + ) { + return None; + } + + let from_rect = self.rect(from)?; + let launcher_targets_without_instances = self + .aggregates + .launchers + .keys() + .map(|l| DesktopTarget::Launcher(*l)) + .filter(|t| self.aggregates.hierarchy.get_nested(t).is_empty()); + let all_instances_or_views = self.aggregates.instances.keys().map(|instance| { + if let Some(view) = self.aggregates.view_of_instance(*instance) { + DesktopTarget::View(view) + } else { + DesktopTarget::Instance(*instance) + } + }); + let navigation_candidates = launcher_targets_without_instances + .chain(all_instances_or_views) + .filter_map(|target| self.rect(&target).map(|rect| (target, rect))); + + let ordered = + ordered_rects_in_direction(from_rect.center(), direction, navigation_candidates); + if let Some((nearest, _rect)) = ordered.first() { + return Some(nearest.clone()); + } + None + } +} + +pub(super) fn ordered_rects_in_direction( + center: Point, + direction: Direction, + rects: 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(); + (key, distance) + }) + }) + .collect(); + + results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal)); + results +} diff --git a/desktop/src/desktop_system/presentation.rs b/desktop/src/desktop_system/presentation.rs new file mode 100644 index 00000000..d7836d39 --- /dev/null +++ b/desktop/src/desktop_system/presentation.rs @@ -0,0 +1,167 @@ +use anyhow::{Result, bail}; +use log::warn; + +use massive_applications::{InstanceId, ViewCreationInfo, ViewRole}; +use massive_shell::Scene; + +use super::DesktopTarget; +use crate::instance_manager::ViewPath; +use crate::instance_presenter::{InstancePresenter, InstancePresenterState, PrimaryViewPresenter}; +use crate::projects::LaunchProfileId; + +use super::DesktopSystem; + +impl DesktopSystem { + pub(super) fn present_instance( + &mut self, + launcher: LaunchProfileId, + originating_from: Option, + instance: InstanceId, + scene: &Scene, + ) -> Result { + let originating_presenter = originating_from + .and_then(|originating_from| self.aggregates.instances.get(&originating_from)); + + let background_for_instance = self + .aggregates + .launchers + .get(&launcher) + .expect("Launcher not found") + .should_render_instance_background(); + + // Correctness: We animate from 0,0 if no originating exist. Need a position here. + let initial_center_translation = originating_presenter + .map(|op| op.layout_transform_animation.value().translate) + .unwrap_or_default(); + + let presenter = InstancePresenter::new( + initial_center_translation, + background_for_instance, + self.aggregates.project_presenter.location.clone(), + scene, + ); + + self.aggregates.instances.insert(instance, presenter)?; + + let nested = self.aggregates.hierarchy.get_nested(&launcher.into()); + let insertion_pos = if let Some(originating_from) = originating_from { + nested + .iter() + .position(|i| *i == DesktopTarget::Instance(originating_from)) + .map(|i| i + 1) + .unwrap_or(nested.len()) + } else { + 0 + }; + + // Inform the launcher to fade out. + self.aggregates + .launchers + .get_mut(&launcher) + .expect("Launcher not found") + .fade_out(); + + Ok(insertion_pos) + } + + pub(super) fn hide_instance(&mut self, instance: InstanceId) -> Result<()> { + let Some(DesktopTarget::Launcher(launcher)) = + self.aggregates.hierarchy.parent(&instance.into()).cloned() + else { + bail!("Internal error: Launcher not found"); + }; + + self.remove_target(&DesktopTarget::Instance(instance))?; + self.aggregates.instances.remove(&instance)?; + + if !self + .aggregates + .hierarchy + .entry(&launcher.into()) + .has_nested() + { + self.aggregates + .launchers + .get_mut(&launcher) + .expect("Launcher not found") + .fade_in(); + } + + Ok(()) + } + + pub(super) fn present_view( + &mut self, + instance: InstanceId, + view_creation_info: &ViewCreationInfo, + ) -> Result<()> { + if view_creation_info.role != ViewRole::Primary { + todo!("Only primary views are supported yet"); + } + + let Some(instance_presenter) = self.aggregates.instances.get_mut(&instance) else { + bail!("Instance not found"); + }; + + if !matches!( + instance_presenter.state, + InstancePresenterState::WaitingForPrimaryView + ) { + bail!("Primary view is already presenting"); + } + + // Architecture: Move this transition in the InstancePresenter + // + // Feature: Add a alpha animation just for the view. + instance_presenter.state = InstancePresenterState::Presenting { + view: PrimaryViewPresenter { + creation_info: view_creation_info.clone(), + }, + }; + + // Add the view to the hierarchy. + self.aggregates.hierarchy.add( + DesktopTarget::Instance(instance), + DesktopTarget::View(view_creation_info.id), + )?; + self.layouter + .mark_reflow_pending(DesktopTarget::Instance(instance)); + + Ok(()) + } + + pub(super) fn hide_view(&mut self, path: ViewPath) -> Result<()> { + let Some(instance_presenter) = self.aggregates.instances.get_mut(&path.instance) else { + warn!("Can't hide view: Instance for view not found"); + // Robustness: Decide if this should return an error. + return Ok(()); + }; + + // Architecture: Move this into the InstancePresenter (don't make state pub). + match &instance_presenter.state { + InstancePresenterState::WaitingForPrimaryView => { + bail!( + "A view needs to be hidden, but instance presenter waits for a view with a primary role." + ) + } + InstancePresenterState::Presenting { view } => { + if view.creation_info.id == path.view { + // Feature: this should initiate a disappearing animation? + instance_presenter.state = InstancePresenterState::Disappearing; + } else { + bail!("Invalid view: It's not related to anything we present"); + } + } + InstancePresenterState::Disappearing => { + // ignored, we are already disappearing. + } + } + + // Robustness: What about focus? + + // And remove the view. + self.remove_target(&DesktopTarget::View(path.view))?; + + Ok(()) + } +} diff --git a/desktop/src/desktop_system/project_commands.rs b/desktop/src/desktop_system/project_commands.rs new file mode 100644 index 00000000..8e36ca83 --- /dev/null +++ b/desktop/src/desktop_system/project_commands.rs @@ -0,0 +1,59 @@ +use anyhow::Result; + +use massive_shell::Scene; + +use super::{DesktopSystem, DesktopTarget, ProjectCommand}; +use crate::projects::{GroupPresenter, LauncherPresenter}; + +impl DesktopSystem { + pub(super) fn apply_project_command( + &mut self, + command: ProjectCommand, + scene: &Scene, + ) -> Result<()> { + match command { + ProjectCommand::AddLaunchGroup { + parent, + id, + properties, + } => { + let parent = parent.map(|p| p.into()).unwrap_or(DesktopTarget::Desktop); + self.aggregates.hierarchy.add(parent.clone(), id.into())?; + self.aggregates + .groups + .insert(id, GroupPresenter::new(properties))?; + self.layouter.mark_reflow_pending(parent); + } + ProjectCommand::RemoveLaunchGroup(group) => { + self.remove_target(&group.into())?; + self.aggregates.groups.remove(&group)?; + } + ProjectCommand::AddLauncher { group, id, profile } => { + let presenter = LauncherPresenter::new( + self.aggregates.project_presenter.location.clone(), + id, + profile, + massive_geometry::Rect::default(), + scene, + &mut self.fonts.lock(), + ); + self.aggregates.launchers.insert(id, presenter)?; + + self.aggregates.hierarchy.add(group.into(), id.into())?; + self.layouter + .mark_reflow_pending(DesktopTarget::Group(group)); + } + ProjectCommand::RemoveLauncher(id) => { + let target = DesktopTarget::Launcher(id); + self.remove_target(&target)?; + + self.aggregates.launchers.remove(&id)?; + } + ProjectCommand::SetStartupProfile(launch_profile_id) => { + self.aggregates.startup_profile = launch_profile_id + } + } + + Ok(()) + } +} diff --git a/desktop/src/lib.rs b/desktop/src/lib.rs index 8e94c6fd..eaa9daf8 100644 --- a/desktop/src/lib.rs +++ b/desktop/src/lib.rs @@ -11,7 +11,6 @@ mod hit_tester; mod instance_manager; mod instance_presenter; mod layout; -mod navigation; mod projects; mod send_transition; diff --git a/desktop/src/navigation.rs b/desktop/src/navigation.rs deleted file mode 100644 index c44e5904..00000000 --- a/desktop/src/navigation.rs +++ /dev/null @@ -1,47 +0,0 @@ -#![allow(unused)] -use std::cmp::Ordering; - -use massive_geometry::{Point, Rect}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Direction { - Left, - Right, - Up, - Down, -} - -impl Direction { - // Given a 45 degree code starting from center in the direction, return true if the other point - // is visible. Also returns false if it's the same point. - pub fn is_visible(&self, center: Point, other: Point) -> bool { - let dx = other.x - center.x; - let dy = other.y - center.y; - - match self { - Direction::Left => dx < 0.0 && dx.abs() >= dy.abs(), - Direction::Right => dx > 0.0 && dx.abs() >= dy.abs(), - Direction::Up => dy < 0.0 && dy.abs() >= dx.abs(), - Direction::Down => dy > 0.0 && dy.abs() >= dx.abs(), - } - } -} - -pub fn ordered_rects_in_direction( - center: Point, - direction: Direction, - rects: 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(); - (key, distance) - }) - }) - .collect(); - - results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal)); - results -} diff --git a/desktop/src/send_transition.rs b/desktop/src/send_transition.rs index 1d915316..f60a93c8 100644 --- a/desktop/src/send_transition.rs +++ b/desktop/src/send_transition.rs @@ -85,3 +85,98 @@ where send_transitions } + +#[cfg(test)] +mod tests { + use super::*; + use crate::OrderedHierarchy; + + fn transition_signature(transitions: Vec>) -> Vec<(i32, &'static str)> { + transitions + .into_iter() + .map(|SendTransition(target, event)| { + let kind = match event { + ViewEvent::Focused(true) => "FocusIn", + ViewEvent::Focused(false) => "FocusOut", + ViewEvent::CursorEntered => "CursorEntered", + ViewEvent::CursorLeft => "CursorLeft", + ViewEvent::ModifiersChanged(_) => "ModifiersChanged", + _ => "Other", + }; + (target, kind) + }) + .collect() + } + + fn tree_with_shared_root() -> OrderedHierarchy { + let mut hierarchy = OrderedHierarchy::default(); + hierarchy.add(1, 2).unwrap(); + hierarchy.add(2, 3).unwrap(); + hierarchy.add(1, 4).unwrap(); + hierarchy.add(4, 5).unwrap(); + hierarchy + } + + #[test] + fn keyboard_focus_change_adds_enter_exit_and_modifiers_tail() { + let hierarchy = tree_with_shared_root(); + let transitions = convert_to_send_transitions( + [EventTransition::ChangeKeyboardFocus { + from: Some(3), + to: Some(5), + }], + Modifiers::default(), + &hierarchy, + ); + + assert_eq!( + transition_signature(transitions), + vec![ + (3, "FocusOut"), + (2, "FocusOut"), + (4, "FocusIn"), + (5, "FocusIn"), + (5, "ModifiersChanged"), + ] + ); + } + + #[test] + fn pointer_focus_change_also_includes_modifiers_tail() { + let hierarchy = tree_with_shared_root(); + let transitions = convert_to_send_transitions( + [EventTransition::ChangePointerFocus { + from: Some(3), + to: Some(5), + }], + Modifiers::default(), + &hierarchy, + ); + + assert_eq!( + transition_signature(transitions), + vec![ + (3, "CursorLeft"), + (2, "CursorLeft"), + (4, "CursorEntered"), + (5, "CursorEntered"), + (5, "ModifiersChanged"), + ] + ); + } + + #[test] + fn none_to_none_focus_change_produces_no_transitions() { + let hierarchy = tree_with_shared_root(); + let transitions = convert_to_send_transitions( + [EventTransition::ChangeKeyboardFocus { + from: None, + to: None, + }], + Modifiers::default(), + &hierarchy, + ); + + assert!(transitions.is_empty()); + } +} diff --git a/examples/shared/src/application.rs b/examples/shared/src/application.rs index 431f4ed1..35684751 100644 --- a/examples/shared/src/application.rs +++ b/examples/shared/src/application.rs @@ -142,17 +142,15 @@ impl Application { } => { self.rotation = VectorPx::default(); } - WindowEvent::ModifiersChanged(modifiers) => { - if self.modifiers != *modifiers { - // If there is an ongoing move and modifiers change, reset origins. - // if let Some(ref mut mouse_pressed) = self.left_mouse_button_pressed { - // mouse_pressed.origin = self.positions[&mouse_pressed.device_id]; - // mouse_pressed.translation_origin = self.translation; - // mouse_pressed.rotation_origin = self.rotation; - // } - - self.modifiers = *modifiers - } + WindowEvent::ModifiersChanged(modifiers) if self.modifiers != *modifiers => { + // If there is an ongoing move and modifiers change, reset origins. + // if let Some(ref mut mouse_pressed) = self.left_mouse_button_pressed { + // mouse_pressed.origin = self.positions[&mouse_pressed.device_id]; + // mouse_pressed.translation_origin = self.translation; + // mouse_pressed.rotation_origin = self.rotation; + // } + + self.modifiers = *modifiers } WindowEvent::KeyboardInput { event: