diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 61640434..b4b5ce82 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -9,6 +9,7 @@ Update it whenever you learn something new about the project's patterns, convent - Keep functions small, clear, and deterministic. - Avoid multiple exit points that return the same result; consolidate them when it improves readability. - Comment only to explain non-obvious reasoning or intent. +- Prefer concise, ideally one-line comments for conceptual or semantic blocks inside functions. - Order functions high-level first, utilities last; order types by importance (public API first, private helpers last). - When splitting large modules, extract low-coupling impl blocks first and preserve existing external imports via local re-exports in the parent module. diff --git a/.harper-dictionary.txt b/.harper-dictionary.txt index f08520a8..3a5f423b 100644 --- a/.harper-dictionary.txt +++ b/.harper-dictionary.txt @@ -1,2 +1,4 @@ pre-view relayout +teardown +unfocus diff --git a/desktop/src/desktop_system.rs b/desktop/src/desktop_system.rs index adc9d349..c5aa9ce3 100644 --- a/desktop/src/desktop_system.rs +++ b/desktop/src/desktop_system.rs @@ -42,7 +42,6 @@ use layout_state::DesktopLayoutState; use crate::event_sourcing::{self, Transaction}; use crate::focus_path::FocusPath; -use crate::focus_path::PathResolver; use crate::instance_manager::InstanceManager; use crate::instance_presenter::InstancePresenter; use crate::projects::{ @@ -130,6 +129,7 @@ pub struct DesktopSystem { event_router: EventRouter, camera: Animated, pointer_feedback_enabled: bool, + /// Launchers queued for focus-driven relayout once pointer buttons are released. deferred_focus_layout_launchers: HashSet, #[debug(skip)] @@ -242,12 +242,6 @@ impl DesktopSystem { } pub fn apply_animations(&mut self) { - let focused_instance = self - .aggregates - .hierarchy - .resolve_path(self.event_router.focused()) - .instance(); - let launcher_instance_ids: Vec<_> = self .aggregates .launchers @@ -266,11 +260,7 @@ impl DesktopSystem { .launchers .get_mut(&launcher_id) .expect("Launcher missing") - .apply_animations( - &mut self.aggregates.instances, - &child_instances, - focused_instance, - ); + .apply_animations(&mut self.aggregates.instances, &child_instances); } for project in self.aggregates.projects.values_mut() { diff --git a/desktop/src/desktop_system/effects.rs b/desktop/src/desktop_system/effects.rs index d5bee2ab..52a542ed 100644 --- a/desktop/src/desktop_system/effects.rs +++ b/desktop/src/desktop_system/effects.rs @@ -6,7 +6,6 @@ use super::DesktopTarget; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum DesktopEffect { - UpdateLauncherExpansion, Measure(DesktopTarget), Place(DesktopTarget), ApplyLayout(DesktopTarget), diff --git a/desktop/src/desktop_system/focus_input.rs b/desktop/src/desktop_system/focus_input.rs index 885df979..8e7d1477 100644 --- a/desktop/src/desktop_system/focus_input.rs +++ b/desktop/src/desktop_system/focus_input.rs @@ -11,6 +11,7 @@ use super::{ Cmd, DesktopCommand, DesktopSystem, DesktopTarget, Effects, POINTER_FEEDBACK_REENABLE_MAX_DURATION, POINTER_FEEDBACK_REENABLE_MIN_DISTANCE_PX, }; +use crate::event_router::EventTransitions; use crate::focus_path::PathResolver; use crate::hit_tester::AggregateHitTester; use crate::instance_manager::InstanceManager; @@ -24,6 +25,7 @@ impl DesktopSystem { ) -> Result<(Cmd, Effects)> { let keyboard_cmd = self.preprocess_keyboard_input(event)?; let mut effects = Effects::None; + let any_buttons_pressed = event.any_buttons_pressed(); let cmd = if !keyboard_cmd.is_none() { keyboard_cmd @@ -36,17 +38,17 @@ impl DesktopSystem { ); let transitions = self.event_router.process(event, &hit_tester)?; - if event.any_buttons_pressed() { - self.defer_layout_for_focus_change(transitions.keyboard_focus_change()); - } else { - effects += - self.invalidate_layout_for_focus_change(transitions.keyboard_focus_change()); - } - self.forward_event_transitions(transitions, instance_manager)? + let (cmd, transition_effects) = self.apply_and_forward_focus_transitions( + transitions, + instance_manager, + any_buttons_pressed, + )?; + effects += transition_effects; + cmd }; self.update_pointer_feedback(event); - if !event.any_buttons_pressed() { + if !any_buttons_pressed { effects += self.flush_deferred_focus_layout(); } @@ -84,17 +86,70 @@ impl DesktopSystem { instance_manager: &InstanceManager, ) -> Result { let transitions = self.event_router.focus(target); - let effects = self.invalidate_layout_for_focus_change(transitions.keyboard_focus_change()); + let (cmd, effects) = + self.apply_and_forward_focus_transitions(transitions, instance_manager, false)?; // Invariant: Programmatic focus changes must not trigger commands. - assert!( - self.forward_event_transitions(transitions, instance_manager)? - .is_none() - ); + assert!(cmd.is_none()); Ok(effects) } + fn apply_and_forward_focus_transitions( + &mut self, + transitions: EventTransitions, + instance_manager: &InstanceManager, + defer_layout: bool, + ) -> Result<(Cmd, Effects)> { + let effects = self.apply_keyboard_focus_change_effects(&transitions, defer_layout); + let cmd = self.forward_event_transitions(transitions, instance_manager)?; + + Ok((cmd, effects)) + } + + fn apply_keyboard_focus_change_effects( + &mut self, + transitions: &EventTransitions, + defer_layout: bool, + ) -> Effects { + let keyboard_focus_change = transitions.keyboard_focus_change(); + + if !keyboard_focus_change.is_empty() { + self.update_launcher_focus_anchor_on_keyboard_focus_change(); + } + + if defer_layout { + self.defer_layout_for_focus_change(keyboard_focus_change); + Effects::None + } else { + self.invalidate_layout_for_focus_change(keyboard_focus_change) + } + } + + // Inform the launchers that are affected by the focus change. + fn update_launcher_focus_anchor_on_keyboard_focus_change(&mut self) { + let focused_instance = self + .aggregates + .hierarchy + .resolve_path(self.event_router.focused()) + .instance(); + + let Some(instance_id) = focused_instance else { + return; + }; + + let Some(launcher_id) = self.instance_launcher(instance_id) else { + return; + }; + + let launcher = self + .aggregates + .launchers + .get_mut(&launcher_id) + .expect("Launcher missing"); + launcher.set_focus_anchor_instance(instance_id); + } + pub(super) fn unfocus_pointer_if_path_contains( &mut self, target: &DesktopTarget, @@ -133,7 +188,7 @@ impl DesktopSystem { } fn preprocess_keyboard_input(&self, event: &Event) -> Result { - // Catch CMD+t and CMD+w if an instance has the keyboard focus. + // Catch `CMD+t` and `CMD+w` if an instance has the keyboard focus. if let ViewEvent::KeyboardInput { event: key_event, .. diff --git a/desktop/src/desktop_system/layout_effects.rs b/desktop/src/desktop_system/layout_effects.rs index 1d1aa728..17d43cb4 100644 --- a/desktop/src/desktop_system/layout_effects.rs +++ b/desktop/src/desktop_system/layout_effects.rs @@ -14,7 +14,7 @@ use crate::projects::LaunchProfileId; impl DesktopSystem { pub(super) fn transaction_effects(&self, command_effects: Effects) -> Effects { - let mut effects = Effects::from(DesktopEffect::UpdateLauncherExpansion); + let mut effects = Effects::None; effects += command_effects; effects += DesktopEffect::Measure(DesktopTarget::Desktop); effects @@ -48,10 +48,6 @@ impl DesktopSystem { effects_mode: TransactionEffectsMode, ) -> Result { match effect { - DesktopEffect::UpdateLauncherExpansion => { - self.update_launcher_expansion_effect(effects_mode); - Ok(Effects::None) - } DesktopEffect::Measure(target) => self.measure_layout_effect(target), DesktopEffect::Place(root) => self.place_layout_effect(root), DesktopEffect::ApplyLayout(target) => self.apply_layout_effect(target, effects_mode), @@ -66,11 +62,6 @@ impl DesktopSystem { } } - fn update_launcher_expansion_effect(&mut self, effects_mode: TransactionEffectsMode) { - let focused_target = self.event_router.focused().cloned(); - self.update_launcher_visor_expansion(focused_target.as_ref(), effects_mode.animate()); - } - /// Measures one layout target in a bottom-up pass and schedules follow-up work. /// /// If any direct child is still unmeasured, this does not measure the target yet. @@ -91,6 +82,7 @@ impl DesktopSystem { focused_instance, }; + // If measurements of children are not available, push them as effects and return early. let missing_children = self .layout_state .missing_child_measures(&target, &self.aggregates.hierarchy); @@ -259,7 +251,7 @@ impl DesktopSystem { } } - fn instance_launcher(&self, instance_id: InstanceId) -> Option { + pub(super) 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), @@ -348,27 +340,6 @@ impl DesktopSystem { self.desktop_presenter.set_hover_placement(hover_placement); } - fn update_launcher_visor_expansion( - &mut self, - focused_target: Option<&DesktopTarget>, - animate: bool, - ) { - let focused_path = self.aggregates.hierarchy.resolve_path(focused_target); - let launcher_ids: Vec<_> = self.aggregates.launchers.keys().copied().collect(); - - for launcher_id in launcher_ids { - let launcher_target = DesktopTarget::Launcher(launcher_id); - let expanded = focused_path.contains(&launcher_target); - - let launcher = self - .aggregates - .launchers - .get_mut(&launcher_id) - .expect("Launcher missing"); - launcher.set_visor_expansion(expanded, animate); - } - } - fn instance_hover_placement(&self, instance_id: InstanceId) -> Option> { let mut placement = self.placement(&DesktopTarget::Instance(instance_id))?; diff --git a/desktop/src/desktop_system/layout_state.rs b/desktop/src/desktop_system/layout_state.rs index 64bc705b..b0d495fd 100644 --- a/desktop/src/desktop_system/layout_state.rs +++ b/desktop/src/desktop_system/layout_state.rs @@ -92,6 +92,8 @@ impl DesktopLayoutState { .collect() } + // Place the children of a given target, and return a list of size changes if there are any. For + // example, for children that expand to fill their parent. pub fn place_children_of( &mut self, target: &DesktopTarget, @@ -124,6 +126,7 @@ impl DesktopLayoutState { let child_placements = self.place_children(target, children, algorithm); + // Update the placements, and see if there are size changes. let mut updates = Vec::with_capacity(children.len()); for (child, placement) in children.iter().zip(child_placements) { let Some(entry) = self.entries.get_mut(child) else { diff --git a/desktop/src/projects/launcher_presenter.rs b/desktop/src/projects/launcher_presenter.rs index 516f3df7..e6640abd 100644 --- a/desktop/src/projects/launcher_presenter.rs +++ b/desktop/src/projects/launcher_presenter.rs @@ -33,17 +33,13 @@ const TEXT_COLOR: Color = Color::WHITE; const FADING_DURATION: Duration = Duration::from_millis(500); const STRUCTURAL_ANIMATION_DURATION: Duration = Duration::from_millis(500); -const VISOR_EXPANSION_ANIMATION_DURATION: Duration = Duration::from_millis(500); const COLLAPSED_NON_ANCHOR_Z_OFFSET: f64 = 1.0; const CHILD_SPACING: i32 = 0; #[derive(Debug, Clone, Copy)] -struct VisorPlacementContext { +struct VisorLayoutSummary { group_center_x: f64, flat_span: f64, - focused_index: Option, - anchor_index: usize, - expansion_factor: f64, instance_count: usize, } @@ -65,8 +61,9 @@ pub struct LauncherPresenter { // Alpha fading of name / background. fader: Animated, - visor_expansion_animation: Animated, - last_focused_instance: Option, + /// The most recent focused instance in this launcher, used as visor anchor when nothing in + /// this launcher is currently focused. + most_recent_focused_instance: Option, events: EventManager, } @@ -126,8 +123,7 @@ impl LauncherPresenter { background, name, fader: scene.animated(1.0), - visor_expansion_animation: scene.animated(1.0), - last_focused_instance: None, + most_recent_focused_instance: None, events: EventManager::default(), } } @@ -162,18 +158,22 @@ impl LauncherPresenter { child_sizes, ), LauncherMode::Visor => { - let focused_index = focused_index.or_else(|| { - self.last_focused_instance.and_then(|focused| { - child_instances - .iter() - .position(|&instance| instance == focused) + let expanded = focused_index.is_some(); + let center_index = focused_index + .or_else(|| { + self.most_recent_focused_instance.and_then(|focused| { + child_instances + .iter() + .position(|&instance| instance == focused) + }) }) - }); + .unwrap_or_default(); self.place_visor_panel_children( local_offset, child_sizes, - focused_index, + center_index, + expanded, default_panel_size, ) } @@ -184,17 +184,14 @@ impl LauncherPresenter { &self, local_offset: Offset<2>, child_sizes: &[LayoutSize<2>], - focused_index: Option, + center_index: usize, + expanded: bool, default_panel_size: SizePx, ) -> Vec> { let offset = centered_children_offset(local_offset, child_sizes, default_panel_size.width as i32); - let expansion_factor = self.visor_expansion_animation.value(); - - let Some(summary) = - visor_layout_summary(offset, child_sizes, focused_index, expansion_factor) - else { + let Some(summary) = visor_layout_summary(offset, child_sizes) else { return place_container_children( LayoutAxis::HORIZONTAL, CHILD_SPACING, @@ -212,7 +209,8 @@ impl LauncherPresenter { } let center_y = child_center_y(offset, child_size); - let transform = visor_child_transform(child_index, center_y, summary); + let transform = + visor_child_transform(child_index, center_y, summary, center_index, expanded); child_placements.push(Placement::new( transform, @@ -235,24 +233,8 @@ impl LauncherPresenter { matches!(self.mode, LauncherMode::Visor) && instance_count > 1 } - pub fn set_visor_expansion(&mut self, expanded: bool, animate: bool) { - match self.mode { - LauncherMode::Visor => { - let target = if expanded { 1.0 } else { 0.0 }; - if animate { - self.visor_expansion_animation.animate_if_changed( - target, - VISOR_EXPANSION_ANIMATION_DURATION, - Interpolation::Linear, - ); - } else { - self.visor_expansion_animation.set_immediately(target); - } - } - LauncherMode::Band => { - self.visor_expansion_animation.set_immediately(1.0); - } - } + pub fn set_focus_anchor_instance(&mut self, instance: InstanceId) { + self.most_recent_focused_instance = Some(instance); } // Architecture: I don't want the launcher here to directly generate commands. may be @@ -331,14 +313,7 @@ impl LauncherPresenter { &mut self, instances: &mut Map, child_instances: &[InstanceId], - focused_instance: Option, ) { - if let Some(focused) = focused_instance - && child_instances.contains(&focused) - { - self.last_focused_instance = Some(focused); - } - self.apply_presenter_animations(); self.apply_child_instance_animations(instances, child_instances); } @@ -411,9 +386,7 @@ fn children_span(child_sizes: &[LayoutSize<2>]) -> i32 { fn visor_layout_summary( mut offset: Offset<2>, child_sizes: &[LayoutSize<2>], - focused_index: Option, - expansion_factor: f64, -) -> Option { +) -> Option { let mut first_center_x = None; let mut last_center_x = None; @@ -436,14 +409,10 @@ fn visor_layout_summary( let first_center_x = first_center_x.expect("Internal error: Expected at least one instance"); let last_center_x = last_center_x.expect("Internal error: Expected at least one instance"); - let anchor_index = focused_index.unwrap_or(instance_count / 2); - Some(VisorPlacementContext { + Some(VisorLayoutSummary { group_center_x: (first_center_x + last_center_x) * 0.5, flat_span: (last_center_x - first_center_x).abs(), - focused_index, - anchor_index, - expansion_factor, instance_count, }) } @@ -459,17 +428,17 @@ fn child_center_y(offset: Offset<2>, child_size: LayoutSize<2>) -> f64 { fn visor_child_transform( instance_index: usize, center_y: f64, - summary: VisorPlacementContext, + summary: VisorLayoutSummary, + center_index: usize, + expanded: bool, ) -> Transform { - let focused_index = summary - .focused_index - .or((summary.expansion_factor < 1.0).then_some(summary.anchor_index)); + let expansion_factor = if expanded { 1.0 } else { 0.0 }; let placement = visor_layout::placement( instance_index, summary.instance_count, summary.flat_span, - focused_index, - summary.expansion_factor, + center_index, + expansion_factor, ) .expect("Internal error: Visor placement requires at least two instances"); let mut transform = Transform::new( @@ -482,8 +451,8 @@ fn visor_child_transform( 1.0, ); - if instance_index != summary.anchor_index { - transform.translate.z += COLLAPSED_NON_ANCHOR_Z_OFFSET * (1.0 - summary.expansion_factor); + if instance_index != center_index { + transform.translate.z += COLLAPSED_NON_ANCHOR_Z_OFFSET * if expanded { 0.0 } else { 1.0 }; } transform diff --git a/desktop/src/projects/visor_layout.rs b/desktop/src/projects/visor_layout.rs index b8af2ed5..5d003893 100644 --- a/desktop/src/projects/visor_layout.rs +++ b/desktop/src/projects/visor_layout.rs @@ -16,7 +16,7 @@ pub fn placement( index: usize, instance_count: usize, flat_span: f64, - focused_index: Option, + center_index: usize, expansion_factor: f64, ) -> Option { if instance_count <= 1 { @@ -26,9 +26,7 @@ pub fn placement( let regular_arc = effective_arc(instance_count); let radius = radius_for_center_span(flat_span, regular_arc); - let focused_rotation = focused_index - .map(|focused| base_angle(focused, instance_count, regular_arc)) - .unwrap_or(0.0); + let focused_rotation = base_angle(center_index, instance_count, regular_arc); let regular_angle = base_angle(index, instance_count, regular_arc) - focused_rotation; let angle = regular_angle * expansion_factor;