From 99f171bacc000837b0af3eff67c44addd7e81f24 Mon Sep 17 00:00:00 2001 From: acsandmann <157552025+acsandmann@users.noreply.github.com> Date: Mon, 1 Dec 2025 16:34:21 -0500 Subject: [PATCH 1/5] feat --- rift.default.toml | 13 ++++ src/actor/drag_swap.rs | 25 ++++-- src/actor/event_tap.rs | 8 +- src/actor/reactor.rs | 55 +++++++++----- src/actor/reactor/events/command.rs | 2 +- src/actor/reactor/events/drag.rs | 97 ++++++++++++++++++++---- src/actor/reactor/events/window.rs | 10 +-- src/common/config.rs | 36 ++++++++- src/layout_engine/engine.rs | 26 +++++++ src/layout_engine/systems.rs | 7 ++ src/layout_engine/systems/bsp.rs | 11 +++ src/layout_engine/systems/traditional.rs | 71 +++++++++++++++++ src/sys/event.rs | 12 ++- src/sys/hotkey.rs | 39 +++++++++- 14 files changed, 363 insertions(+), 49 deletions(-) diff --git a/rift.default.toml b/rift.default.toml index 0b699a28..ece3c92d 100644 --- a/rift.default.toml +++ b/rift.default.toml @@ -184,6 +184,19 @@ haptic_pattern = "level_change" # If you prefer more aggressive swapping, lower the value; increase it # to require greater overlap before a swap occurs. drag_swap_fraction = 0.3 +# Default action when you drag a window on top of another: +# "swap" (swap positions), "stack" (stack them), "move" (move in the +# inferred direction relative to target). Defaults to swap. +drag_overlap_default_action = "swap" +# Optional per-modifier overrides. Modifiers are case-insensitive and can be +# combined with '+', e.g., "shift+cmd". Common values: "shift", "ctrl", "cmd", +# "alt", or combos. Example below: hold Shift while dragging to stack instead +# of swap. +drag_overlap_overrides = [ + { modifiers = "shift", action = "stack" }, + # { modifiers = "cmd+shift", action = "swap" }, + # { modifiers = "ctrl", action = "move" }, +] [virtual_workspaces] # Virtual workspaces diff --git a/src/actor/drag_swap.rs b/src/actor/drag_swap.rs index 49fd79df..00269c02 100644 --- a/src/actor/drag_swap.rs +++ b/src/actor/drag_swap.rs @@ -200,7 +200,10 @@ mod tests { #[test] fn selects_candidate_based_on_scored_overlap() { - let mut dm = DragManager::new(WindowSnappingSettings { drag_swap_fraction: 0.3 }); + let mut dm = DragManager::new(WindowSnappingSettings { + drag_swap_fraction: 0.3, + ..Default::default() + }); let dragged = rect(0.0, 0.0, 100.0, 100.0); let wid = WindowId::new(1, 1); @@ -214,7 +217,10 @@ mod tests { #[test] fn respects_last_target_to_avoid_repeats() { - let mut dm = DragManager::new(WindowSnappingSettings { drag_swap_fraction: 0.25 }); + let mut dm = DragManager::new(WindowSnappingSettings { + drag_swap_fraction: 0.25, + ..Default::default() + }); let wid = WindowId::new(1, 10); let dragged = rect(0.0, 0.0, 200.0, 100.0); @@ -229,7 +235,10 @@ mod tests { #[test] fn clears_active_target_when_overlap_is_lost() { - let mut dm = DragManager::new(WindowSnappingSettings { drag_swap_fraction: 0.2 }); + let mut dm = DragManager::new(WindowSnappingSettings { + drag_swap_fraction: 0.2, + ..Default::default() + }); let wid = WindowId::new(1, 42); let dragged = rect(0.0, 0.0, 100.0, 100.0); let cand = (WindowId::new(1, 99), rect(0.0, 0.0, 60.0, 100.0)); @@ -246,7 +255,10 @@ mod tests { #[test] fn hysteresis_keeps_candidate_when_overlap_drops_slightly() { - let mut dm = DragManager::new(WindowSnappingSettings { drag_swap_fraction: 0.4 }); + let mut dm = DragManager::new(WindowSnappingSettings { + drag_swap_fraction: 0.4, + ..Default::default() + }); let wid = WindowId::new(5, 1); let dragged = rect(0.0, 0.0, 100.0, 100.0); let cand = (WindowId::new(5, 2), rect(0.0, 0.0, 50.0, 100.0)); // 50% @@ -262,7 +274,10 @@ mod tests { #[test] fn switches_only_when_new_candidate_is_meaningfully_better() { - let mut dm = DragManager::new(WindowSnappingSettings { drag_swap_fraction: 0.3 }); + let mut dm = DragManager::new(WindowSnappingSettings { + drag_swap_fraction: 0.3, + ..Default::default() + }); let wid = WindowId::new(7, 1); let dragged = rect(0.0, 0.0, 120.0, 100.0); diff --git a/src/actor/event_tap.rs b/src/actor/event_tap.rs index 63d87d04..2f904f0c 100644 --- a/src/actor/event_tap.rs +++ b/src/actor/event_tap.rs @@ -19,7 +19,9 @@ use crate::common::collections::{HashMap, HashSet}; use crate::common::config::{Config, HapticPattern}; use crate::common::log::trace_misc; use crate::layout_engine::LayoutCommand as LC; -use crate::sys::event::{self, Hotkey, KeyCode, MouseState, set_mouse_state}; +use crate::sys::event::{ + self, Hotkey, KeyCode, MouseState, set_current_modifiers, set_mouse_state, +}; use crate::sys::geometry::CGRectExt; use crate::sys::haptics; use crate::sys::hotkey::{ @@ -299,6 +301,9 @@ impl EventTap { return true; } + let flags = CGEvent::flags(Some(event)); + set_current_modifiers(modifiers_from_flags(flags)); + match event_type { CGEventType::LeftMouseDown | CGEventType::RightMouseDown => { set_mouse_state(MouseState::Down); @@ -311,6 +316,7 @@ impl EventTap { } let mut state = self.state.borrow_mut(); + state.current_flags = flags; if matches!( event_type, diff --git a/src/actor/reactor.rs b/src/actor/reactor.rs index 228275eb..7dd8fd51 100644 --- a/src/actor/reactor.rs +++ b/src/actor/reactor.rs @@ -46,7 +46,7 @@ use crate::actor::raise_manager::{self, RaiseManager, RaiseRequest}; use crate::actor::reactor::events::window_discovery::WindowDiscoveryHandler; use crate::actor::{self, menu_bar, stack_line}; use crate::common::collections::{BTreeMap, HashMap, HashSet}; -use crate::common::config::Config; +use crate::common::config::{Config, DragOverlapAction}; use crate::common::log::MetricsCommand; use crate::layout_engine::{self as layout, Direction, LayoutCommand, LayoutEngine, LayoutEvent}; use crate::model::VirtualWorkspaceId; @@ -54,6 +54,7 @@ use crate::model::tx_store::WindowTxStore; use crate::sys::event::MouseState; use crate::sys::executor::Executor; use crate::sys::geometry::{CGRectDef, CGRectExt}; +use crate::sys::hotkey::Modifiers; use crate::sys::screen::{ScreenId, SpaceId, get_active_space_number}; use crate::sys::timer::Timer; use crate::sys::window_server::{ @@ -309,9 +310,10 @@ pub enum DragState { Active { session: DragSession, }, - PendingSwap { + PendingDrop { session: DragSession, target: WindowId, + action: DragOverlapAction, }, } @@ -503,7 +505,7 @@ impl Reactor { drag_manager: managers::DragManager { drag_state: DragState::Inactive, drag_swap_manager: crate::actor::drag_swap::DragManager::new( - config.settings.window_snapping, + config.settings.window_snapping.clone(), ), skip_layout_for_window: None, }, @@ -564,7 +566,7 @@ impl Reactor { fn is_in_drag(&self) -> bool { matches!( self.drag_manager.drag_state, - DragState::Active { .. } | DragState::PendingSwap { .. } + DragState::Active { .. } | DragState::PendingDrop { .. } ) } @@ -575,9 +577,9 @@ impl Reactor { ) } - fn get_pending_drag_swap(&self) -> Option<(WindowId, WindowId)> { - if let DragState::PendingSwap { session, target } = &self.drag_manager.drag_state { - Some((session.window, *target)) + fn get_pending_drag_action(&self) -> Option<(WindowId, WindowId, DragOverlapAction)> { + if let DragState::PendingDrop { session, target, action } = &self.drag_manager.drag_state { + Some((session.window, *target, *action)) } else { None } @@ -602,7 +604,7 @@ impl Reactor { fn take_active_drag_session(&mut self) -> Option { match std::mem::replace(&mut self.drag_manager.drag_state, DragState::Inactive) { DragState::Active { session } => Some(session), - DragState::PendingSwap { session, .. } => Some(session), + DragState::PendingDrop { session, .. } => Some(session), _ => None, } } @@ -1342,6 +1344,16 @@ impl Reactor { } } + fn drag_overlap_action(&self, modifiers: Modifiers) -> DragOverlapAction { + let settings = &self.config_manager.config.settings.window_snapping; + settings + .drag_overlap_overrides + .iter() + .find(|ov| modifiers.contains(ov.modifiers)) + .map(|ov| ov.action) + .unwrap_or(settings.drag_overlap_default_action) + } + fn pid_has_changing_screens(&self, pid: pid_t) -> bool { self.space_manager.changing_screens.iter().any(|wsid| { if let Some(wid) = self.window_manager.window_ids.get(wsid) { @@ -1942,6 +1954,9 @@ impl Reactor { return; } + let modifiers = crate::sys::event::get_current_modifiers(); + let overlap_action = self.drag_overlap_action(modifiers); + let server_id = { let Some(window) = self.window_manager.windows.get(&wid) else { return; @@ -1979,7 +1994,7 @@ impl Reactor { if let Some(origin_space) = origin_space_hint { if origin_space != space { - if let Some((pending_wid, pending_target)) = self.get_pending_drag_swap() { + if let Some((pending_wid, pending_target, _)) = self.get_pending_drag_action() { if pending_wid == wid { trace!( ?wid, @@ -2030,28 +2045,34 @@ impl Reactor { candidates.push((other_wid, other_state.frame_monotonic)); } - let previous_pending = self.get_pending_drag_swap(); + let previous_pending = self.get_pending_drag_action(); let new_candidate = self.drag_manager.drag_swap_manager.on_frame_change(wid, new_frame, &candidates); let active_target = self.drag_manager.drag_swap_manager.last_target(); if let Some(target_wid) = active_target { - if new_candidate.is_some() || previous_pending != Some((wid, target_wid)) { + if new_candidate.is_some() + || previous_pending != Some((wid, target_wid, overlap_action)) + { trace!( ?wid, ?target_wid, - "Detected swap candidate; deferring until MouseUp" + ?overlap_action, + "Detected drag candidate; deferring until MouseUp" ); } if let Some(session) = self.take_active_drag_session() { - self.drag_manager.drag_state = - DragState::PendingSwap { session, target: target_wid }; + self.drag_manager.drag_state = DragState::PendingDrop { + session, + target: target_wid, + action: overlap_action, + }; } else { trace!( ?wid, ?target_wid, - "Skipping pending swap; no active drag session" + "Skipping pending drag action; no active drag session" ); self.drag_manager.drag_state = DragState::Inactive; self.drag_manager.skip_layout_for_window = None; @@ -2060,12 +2081,12 @@ impl Reactor { self.drag_manager.skip_layout_for_window = Some(wid); } else { - if let Some((pending_wid, pending_target)) = previous_pending { + if let Some((pending_wid, pending_target, _)) = previous_pending { if pending_wid == wid { trace!( ?wid, ?pending_target, - "Clearing pending drag swap; overlap ended before MouseUp" + "Clearing pending drag action; overlap ended before MouseUp" ); if let Some(session) = self.take_active_drag_session() { self.drag_manager.drag_state = DragState::Active { session }; diff --git a/src/actor/reactor/events/command.rs b/src/actor/reactor/events/command.rs index ed8743fb..96ad8e36 100644 --- a/src/actor/reactor/events/command.rs +++ b/src/actor/reactor/events/command.rs @@ -120,7 +120,7 @@ impl CommandEventHandler { reactor .drag_manager - .update_config(reactor.config_manager.config.settings.window_snapping); + .update_config(reactor.config_manager.config.settings.window_snapping.clone()); if let Some(tx) = &reactor.communication_manager.stack_line_tx { if let Err(e) = tx.try_send(StackLineEvent::ConfigUpdated( diff --git a/src/actor/reactor/events/drag.rs b/src/actor/reactor/events/drag.rs index e24295f1..b4556154 100644 --- a/src/actor/reactor/events/drag.rs +++ b/src/actor/reactor/events/drag.rs @@ -2,7 +2,8 @@ use tracing::{trace, warn}; use crate::actor::reactor::{DragState, Reactor}; use crate::common::collections::HashMap; -use crate::layout_engine::LayoutCommand; +use crate::common::config::DragOverlapAction; +use crate::layout_engine::{Direction, LayoutCommand}; use crate::sys::screen::{SpaceId, order_visible_spaces_by_position}; pub struct DragEventHandler; @@ -11,10 +12,15 @@ impl DragEventHandler { pub fn handle_mouse_up(reactor: &mut Reactor) { let mut need_layout_refresh = false; - let pending_swap = reactor.get_pending_drag_swap(); + let pending_action = reactor.get_pending_drag_action(); - if let Some((dragged_wid, target_wid)) = pending_swap { - trace!(?dragged_wid, ?target_wid, "Performing deferred swap on MouseUp"); + if let Some((dragged_wid, target_wid, action)) = pending_action { + trace!( + ?dragged_wid, + ?target_wid, + ?action, + "Performing deferred drag action on MouseUp" + ); reactor.drag_manager.skip_layout_for_window = Some(dragged_wid); @@ -24,9 +30,14 @@ impl DragEventHandler { trace!( ?dragged_wid, ?target_wid, - "Skipping deferred swap; one of the windows no longer exists" + "Skipping deferred action; one of the windows no longer exists" ); } else { + let dragged_frame = + reactor.window_manager.windows.get(&dragged_wid).map(|w| w.frame_monotonic); + let target_frame = + reactor.window_manager.windows.get(&target_wid).map(|w| w.frame_monotonic); + let visible_spaces_input: Vec<(SpaceId, _)> = reactor .space_manager .screens @@ -61,15 +72,73 @@ impl DragEventHandler { .and_then(|f| reactor.best_space_for_frame(&f)) }) .or_else(|| reactor.space_manager.screens.iter().find_map(|s| s.space)); - let response = reactor.layout_manager.layout_engine.handle_command( - swap_space, - &visible_spaces, - &visible_space_centers, - LayoutCommand::SwapWindows(dragged_wid, target_wid), - ); - reactor.handle_layout_response(response, None); - - need_layout_refresh = true; + match action { + DragOverlapAction::Swap => { + let response = reactor.layout_manager.layout_engine.handle_command( + swap_space, + &visible_spaces, + &visible_space_centers, + LayoutCommand::SwapWindows(dragged_wid, target_wid), + ); + reactor.handle_layout_response(response, None); + need_layout_refresh = true; + } + DragOverlapAction::Stack => { + let response = reactor.layout_manager.layout_engine.handle_command( + swap_space, + &visible_spaces, + &visible_space_centers, + LayoutCommand::StackWindows(dragged_wid, target_wid), + ); + reactor.handle_layout_response(response, None); + need_layout_refresh = true; + } + DragOverlapAction::Move => { + if let (Some(space), Some(df), Some(tf)) = + (swap_space, dragged_frame, target_frame) + { + let delta_x = tf.mid().x - df.mid().x; + let delta_y = tf.mid().y - df.mid().y; + let direction = if delta_x.abs() >= delta_y.abs() { + if delta_x >= 0.0 { + Direction::Right + } else { + Direction::Left + } + } else if delta_y >= 0.0 { + Direction::Down + } else { + Direction::Up + }; + + if reactor + .layout_manager + .layout_engine + .select_window_in_space(space, dragged_wid) + { + let response = reactor.layout_manager.layout_engine.handle_command( + Some(space), + &visible_spaces, + &visible_space_centers, + LayoutCommand::MoveNode(direction), + ); + reactor.handle_layout_response(response, None); + need_layout_refresh = true; + } else { + trace!( + ?dragged_wid, + "Skipping move action; could not select dragged window" + ); + } + } else { + trace!( + ?dragged_wid, + ?target_wid, + "Skipping move action; unable to determine space or frames" + ); + } + } + } } } diff --git a/src/actor/reactor/events/window.rs b/src/actor/reactor/events/window.rs index 6aa17fbf..4b497771 100644 --- a/src/actor/reactor/events/window.rs +++ b/src/actor/reactor/events/window.rs @@ -89,11 +89,11 @@ impl WindowEventHandler { reactor.window_manager.windows.remove(&wid); reactor.send_layout_event(LayoutEvent::WindowRemoved(wid)); - if let DragState::PendingSwap { session, target } = &reactor.drag_manager.drag_state { + if let DragState::PendingDrop { session, target, .. } = &reactor.drag_manager.drag_state { if session.window == wid || *target == wid { trace!( ?wid, - "Clearing pending drag swap because a participant window was destroyed" + "Clearing pending drag action because a participant window was destroyed" ); reactor.drag_manager.drag_state = DragState::Inactive; } @@ -284,7 +284,7 @@ impl WindowEventHandler { let dragging = event_mouse_state == Some(MouseState::Down) || matches!( reactor.drag_manager.drag_state, - DragState::Active { .. } | DragState::PendingSwap { .. } + DragState::Active { .. } | DragState::PendingDrop { .. } ); if !dragging && !triggered_by_rift { @@ -321,7 +321,7 @@ impl WindowEventHandler { if old_space != new_space { if matches!( reactor.drag_manager.drag_state, - DragState::Active { .. } | DragState::PendingSwap { .. } + DragState::Active { .. } | DragState::PendingDrop { .. } ) || matches!( &reactor.drag_manager.drag_state, DragState::Active { session } if session.window == wid @@ -418,7 +418,7 @@ fn handle_mouse_up_if_needed(reactor: &mut Reactor, mouse_state: Option, } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy, Default)] @@ -502,6 +525,15 @@ fn default_mission_control_fade_duration_ms() -> f64 { 180.0 } fn default_drag_swap_fraction() -> f64 { 0.3 } +fn default_drag_overlap_action() -> DragOverlapAction { DragOverlapAction::Swap } + +fn default_drag_overlap_overrides() -> Vec { + vec![DragOverlapOverride { + modifiers: Modifiers::SHIFT, + action: DragOverlapAction::Stack, + }] +} + #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy, Default)] #[serde(rename_all = "snake_case")] pub enum HorizontalPlacement { diff --git a/src/layout_engine/engine.rs b/src/layout_engine/engine.rs index a69572de..faabf7c3 100644 --- a/src/layout_engine/engine.rs +++ b/src/layout_engine/engine.rs @@ -62,6 +62,7 @@ pub enum LayoutCommand { SwitchToLastWorkspace, SwapWindows(crate::actor::app::WindowId, crate::actor::app::WindowId), + StackWindows(crate::actor::app::WindowId, crate::actor::app::WindowId), } #[non_exhaustive] @@ -827,6 +828,16 @@ impl LayoutEngine { EventResponse::default() } + LayoutCommand::StackWindows(a, b) => { + let layout = self.layout(space); + let default_orientation = self.layout_settings.stack.default_orientation; + if !self.tree.stack_windows(layout, a, b, default_orientation) { + warn!("StackWindows command ignored (missing nodes)"); + } + + EventResponse::default() + } + LayoutCommand::NextWindow => self.move_focus_internal( space, visible_spaces, @@ -1596,6 +1607,21 @@ impl LayoutEngine { self.workspace_layouts.for_each_active(|layout| self.tree.rebalance(layout)); } + pub fn select_window_in_space(&mut self, space: SpaceId, wid: WindowId) -> bool { + if self.floating.is_floating(wid) { + return false; + } + + let Some(workspace_id) = self.virtual_workspace_manager.active_workspace(space) else { + return false; + }; + let Some(layout) = self.workspace_layouts.active(space, workspace_id) else { + return false; + }; + + self.tree.select_window(layout, wid) + } + pub fn is_window_in_active_workspace(&self, space: SpaceId, window_id: WindowId) -> bool { self.virtual_workspace_manager.is_window_in_active_workspace(space, window_id) } diff --git a/src/layout_engine/systems.rs b/src/layout_engine/systems.rs index bb4fac35..10e8e530 100644 --- a/src/layout_engine/systems.rs +++ b/src/layout_engine/systems.rs @@ -55,6 +55,13 @@ pub trait LayoutSystem: Serialize + for<'de> Deserialize<'de> { ); fn swap_windows(&mut self, layout: LayoutId, a: WindowId, b: WindowId) -> bool; + fn stack_windows( + &mut self, + layout: LayoutId, + dragged: WindowId, + target: WindowId, + default_orientation: crate::common::config::StackDefaultOrientation, + ) -> bool; fn move_selection(&mut self, layout: LayoutId, direction: Direction) -> bool; fn move_selection_to_layout_after_selection( diff --git a/src/layout_engine/systems/bsp.rs b/src/layout_engine/systems/bsp.rs index 29b897c0..f8c9d250 100644 --- a/src/layout_engine/systems/bsp.rs +++ b/src/layout_engine/systems/bsp.rs @@ -921,6 +921,17 @@ impl LayoutSystem for BspLayoutSystem { true } + fn stack_windows( + &mut self, + _layout: LayoutId, + _dragged: WindowId, + _target: WindowId, + _default_orientation: crate::common::config::StackDefaultOrientation, + ) -> bool { + // BSP layouts do not currently support stacking via drag-and-drop. + false + } + fn move_selection_to_layout_after_selection( &mut self, from_layout: LayoutId, diff --git a/src/layout_engine/systems/traditional.rs b/src/layout_engine/systems/traditional.rs index ed149bd3..fa859091 100644 --- a/src/layout_engine/systems/traditional.rs +++ b/src/layout_engine/systems/traditional.rs @@ -902,6 +902,77 @@ impl LayoutSystem for TraditionalLayoutSystem { true } + fn stack_windows( + &mut self, + layout: LayoutId, + dragged: WindowId, + target: WindowId, + default_orientation: crate::common::config::StackDefaultOrientation, + ) -> bool { + let Some(dragged_node) = self.tree.data.window.node_for(layout, dragged) else { + return false; + }; + let Some(target_node) = self.tree.data.window.node_for(layout, target) else { + return false; + }; + + if dragged_node == target_node { + return false; + } + + let target_parent = target_node.parent(self.map()); + + let stack_kind = match target_parent.map(|p| self.layout(p)) { + Some(LayoutKind::HorizontalStack) => LayoutKind::HorizontalStack, + Some(LayoutKind::VerticalStack) => LayoutKind::VerticalStack, + Some(LayoutKind::Horizontal) => match default_orientation { + crate::common::config::StackDefaultOrientation::Perpendicular => { + LayoutKind::VerticalStack + } + crate::common::config::StackDefaultOrientation::Same + | crate::common::config::StackDefaultOrientation::Horizontal => { + LayoutKind::HorizontalStack + } + crate::common::config::StackDefaultOrientation::Vertical => { + LayoutKind::VerticalStack + } + }, + Some(LayoutKind::Vertical) => match default_orientation { + crate::common::config::StackDefaultOrientation::Perpendicular => { + LayoutKind::HorizontalStack + } + crate::common::config::StackDefaultOrientation::Same + | crate::common::config::StackDefaultOrientation::Vertical => { + LayoutKind::VerticalStack + } + crate::common::config::StackDefaultOrientation::Horizontal => { + LayoutKind::HorizontalStack + } + }, + None => match default_orientation { + crate::common::config::StackDefaultOrientation::Vertical => { + LayoutKind::VerticalStack + } + _ => LayoutKind::HorizontalStack, + }, + }; + + let container = if target_parent.is_some_and(|p| self.layout(p).is_stacked()) { + target_parent.unwrap() + } else { + self.nest_in_container_internal(layout, target_node, stack_kind) + }; + + // Ensure the container is stacked even if nest_in_container_internal reused an + // existing parent. + self.tree.data.layout.set_kind(container, stack_kind); + + let _ = dragged_node.detach(&mut self.tree).insert_after(target_node).finish(); + + self.select(container); + true + } + fn toggle_tile_orientation(&mut self, layout: LayoutId) { use crate::layout_engine::LayoutKind; diff --git a/src/sys/event.rs b/src/sys/event.rs index e6c2ad50..c6626436 100644 --- a/src/sys/event.rs +++ b/src/sys/event.rs @@ -1,5 +1,6 @@ use std::convert::TryFrom; -use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::atomic::AtomicU8; +use std::sync::atomic::Ordering::Relaxed; use objc2_core_foundation::CGPoint; use objc2_core_graphics::{ @@ -22,6 +23,7 @@ pub enum MouseState { const MOUSE_STATE_UNKNOWN: u8 = 0; static MOUSE_STATE: AtomicU8 = AtomicU8::new(MOUSE_STATE_UNKNOWN); +static MODIFIER_STATE: AtomicU8 = AtomicU8::new(0); impl From for u8 { fn from(state: MouseState) -> u8 { state as u8 } @@ -39,15 +41,19 @@ impl TryFrom for MouseState { } } -pub fn set_mouse_state(state: MouseState) { MOUSE_STATE.store(state.into(), Ordering::Relaxed); } +pub fn set_mouse_state(state: MouseState) { MOUSE_STATE.store(state.into(), Relaxed); } pub fn get_mouse_state() -> Option { - match MouseState::try_from(MOUSE_STATE.load(Ordering::Relaxed)) { + match MouseState::try_from(MOUSE_STATE.load(Relaxed)) { Ok(s) => Some(s), Err(_) => None, } } +pub fn set_current_modifiers(mods: Modifiers) { MODIFIER_STATE.store(mods.bits(), Relaxed); } + +pub fn get_current_modifiers() -> Modifiers { Modifiers::from_bits(MODIFIER_STATE.load(Relaxed)) } + pub fn warp_mouse(point: CGPoint) -> Result<(), CGError> { cg_ok(unsafe { CGWarpMouseCursorPosition(point) }) } diff --git a/src/sys/hotkey.rs b/src/sys/hotkey.rs index 1e290ceb..5a15a808 100644 --- a/src/sys/hotkey.rs +++ b/src/sys/hotkey.rs @@ -5,7 +5,7 @@ use anyhow::anyhow; use objc2_core_graphics::{CGEvent, CGEventField, CGEventFlags}; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash)] +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash, Default)] pub struct Modifiers(u8); impl Modifiers { @@ -16,6 +16,10 @@ impl Modifiers { pub fn empty() -> Self { Modifiers(0) } + pub fn bits(self) -> u8 { self.0 } + + pub fn from_bits(bits: u8) -> Self { Modifiers(bits) } + pub fn contains(&self, other: Modifiers) -> bool { (self.0 & other.0) == other.0 } pub fn insert(&mut self, other: Modifiers) { self.0 |= other.0; } @@ -45,6 +49,39 @@ impl Modifiers { } } +pub mod modifier_serde { + use serde::{Deserialize, Deserializer, Serializer}; + + use super::Modifiers; + + pub fn serialize(mods: &Modifiers, serializer: S) -> Result + where S: Serializer { + let s = mods.to_string().to_lowercase(); + serializer.serialize_str(&s) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where D: Deserializer<'de> { + let raw = String::deserialize(deserializer)?; + if raw.trim().is_empty() { + return Ok(Modifiers::empty()); + } + + let mut mods = Modifiers::empty(); + for token in raw.split('+') { + let token = token.trim(); + if token.is_empty() { + continue; + } + if !mods.insert_from_token(token) { + return Err(serde::de::Error::custom(format!("Unknown modifier: {token}"))); + } + } + + Ok(mods) + } +} + impl fmt::Display for Modifiers { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let mut parts: Vec<&str> = Vec::new(); From 09ae6455563953c0d603b1792ece9a1c046d9c64 Mon Sep 17 00:00:00 2001 From: acsandmann <157552025+acsandmann@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:09:35 -0500 Subject: [PATCH 2/5] wip --- src/actor/drag_swap.rs | 60 ++++++++++++++++++++--------------- src/actor/reactor.rs | 34 ++++++++++++++++++-- src/actor/reactor/managers.rs | 23 +++++++++++++- src/ui.rs | 1 + 4 files changed, 88 insertions(+), 30 deletions(-) diff --git a/src/actor/drag_swap.rs b/src/actor/drag_swap.rs index 00269c02..f5802349 100644 --- a/src/actor/drag_swap.rs +++ b/src/actor/drag_swap.rs @@ -53,6 +53,7 @@ impl DragManager { wid: WindowId, new_frame: CGRect, candidates: &[(WindowId, CGRect)], + required_overlap: Option, ) -> Option { if self.dragged_window.is_none() { self.dragged_window = Some(wid); @@ -69,8 +70,10 @@ impl DragManager { return None; } - let stick_fraction = (self.config.drag_swap_fraction * STICK_RATIO) - .clamp(0.0, self.config.drag_swap_fraction); + let overlap_threshold = required_overlap + .unwrap_or(self.config.drag_swap_fraction) + .clamp(0.0, 1.0); + let stick_fraction = (overlap_threshold * STICK_RATIO).clamp(0.0, overlap_threshold); let dragged_center = Self::rect_center(new_frame); let dragged_diag = f64::hypot(new_frame.size.width, new_frame.size.height).max(f64::EPSILON); @@ -128,24 +131,24 @@ impl DragManager { .active_candidate .and_then(|active| scored.iter().copied().find(|c| c.window == active.window)); - if let Some(active) = active_metrics { - self.active_candidate = Some(ActiveCandidate { window: active.window }); + if let Some(active) = active_metrics { + self.active_candidate = Some(ActiveCandidate { window: active.window }); - if active.window == best.window { - return None; - } + if active.window == best.window { + return None; + } - if best.overlap >= self.config.drag_swap_fraction - && best.score >= active.score + SWITCH_DELTA - { - self.active_candidate = Some(ActiveCandidate { window: best.window }); - return Some(best.window); - } + if best.overlap >= overlap_threshold + && best.score >= active.score + SWITCH_DELTA + { + self.active_candidate = Some(ActiveCandidate { window: best.window }); + return Some(best.window); + } - return None; - } + return None; + } - if best.overlap >= self.config.drag_swap_fraction { + if best.overlap >= overlap_threshold { self.active_candidate = Some(ActiveCandidate { window: best.window }); return Some(best.window); } @@ -211,7 +214,7 @@ mod tests { let cand_a = (WindowId::new(1, 2), rect(0.0, 0.0, 40.0, 100.0)); // 40% let cand_b = (WindowId::new(1, 3), rect(0.0, 0.0, 60.0, 100.0)); // 60% - let chosen = dm.on_frame_change(wid, dragged, &[cand_a, cand_b]); + let chosen = dm.on_frame_change(wid, dragged, &[cand_a, cand_b], None); assert_eq!(chosen, Some(WindowId::new(1, 3))); } @@ -226,10 +229,10 @@ mod tests { let cand = (WindowId::new(1, 20), rect(0.0, 0.0, 100.0, 100.0)); // 50% overlap - let chosen1 = dm.on_frame_change(wid, dragged, &[cand]); + let chosen1 = dm.on_frame_change(wid, dragged, &[cand], None); assert_eq!(chosen1, Some(WindowId::new(1, 20))); - let chosen2 = dm.on_frame_change(wid, dragged, &[cand]); + let chosen2 = dm.on_frame_change(wid, dragged, &[cand], None); assert_eq!(chosen2, None); } @@ -243,12 +246,12 @@ mod tests { let dragged = rect(0.0, 0.0, 100.0, 100.0); let cand = (WindowId::new(1, 99), rect(0.0, 0.0, 60.0, 100.0)); - let chosen = dm.on_frame_change(wid, dragged, &[cand]); + let chosen = dm.on_frame_change(wid, dragged, &[cand], None); assert_eq!(chosen, Some(WindowId::new(1, 99))); assert_eq!(dm.last_target(), Some(WindowId::new(1, 99))); let moved = rect(200.0, 0.0, 100.0, 100.0); - let cleared = dm.on_frame_change(wid, moved, &[cand]); + let cleared = dm.on_frame_change(wid, moved, &[cand], None); assert!(cleared.is_none()); assert!(dm.last_target().is_none()); } @@ -263,11 +266,11 @@ mod tests { let dragged = rect(0.0, 0.0, 100.0, 100.0); let cand = (WindowId::new(5, 2), rect(0.0, 0.0, 50.0, 100.0)); // 50% - let chosen = dm.on_frame_change(wid, dragged, &[cand]); + let chosen = dm.on_frame_change(wid, dragged, &[cand], None); assert_eq!(chosen, Some(WindowId::new(5, 2))); let shifted = rect(20.0, 0.0, 100.0, 100.0); // 30% overlap - let result = dm.on_frame_change(wid, shifted, &[cand]); + let result = dm.on_frame_change(wid, shifted, &[cand], None); assert!(result.is_none()); assert_eq!(dm.last_target(), Some(WindowId::new(5, 2))); } @@ -285,17 +288,22 @@ mod tests { let cand_b = (WindowId::new(7, 3), rect(0.0, 0.0, 68.0, 100.0)); // 56.6% assert_eq!( - dm.on_frame_change(wid, dragged, &[cand_a, cand_b]), + dm.on_frame_change(wid, dragged, &[cand_a, cand_b], None), Some(WindowId::new(7, 3)) ); let cand_a_shifted = (WindowId::new(7, 2), rect(0.0, 0.0, 66.0, 100.0)); // 55% - let result = dm.on_frame_change(wid, dragged, &[cand_a_shifted, cand_b]); + let result = dm.on_frame_change(wid, dragged, &[cand_a_shifted, cand_b], None); assert!(result.is_none()); assert_eq!(dm.last_target(), Some(WindowId::new(7, 3))); let cand_a_dominant = (WindowId::new(7, 2), rect(-10.0, 0.0, 120.0, 100.0)); // 100% overlap - let switched = dm.on_frame_change(wid, dragged, &[cand_a_dominant, cand_b]); + let switched = dm.on_frame_change( + wid, + dragged, + &[cand_a_dominant, cand_b], + None, + ); assert_eq!(switched, Some(WindowId::new(7, 2))); assert_eq!(dm.last_target(), Some(WindowId::new(7, 2))); } diff --git a/src/actor/reactor.rs b/src/actor/reactor.rs index 7dd8fd51..4dc59fc6 100644 --- a/src/actor/reactor.rs +++ b/src/actor/reactor.rs @@ -61,6 +61,7 @@ use crate::sys::window_server::{ self, WindowServerId, WindowServerInfo, current_cursor_location, space_is_fullscreen, wait_for_native_fullscreen_transition, window_level, }; +use crate::ui::drag_feedback::FeedbackKind; pub type Sender = actor::Sender; type Receiver = actor::Receiver; @@ -508,6 +509,7 @@ impl Reactor { config.settings.window_snapping.clone(), ), skip_layout_for_window: None, + drag_feedback: crate::ui::drag_feedback::DragFeedback::new().ok(), }, workspace_switch_manager: managers::WorkspaceSwitchManager { workspace_switch_state: WorkspaceSwitchState::Inactive, @@ -1951,6 +1953,7 @@ impl Reactor { fn maybe_swap_on_drag(&mut self, wid: WindowId, new_frame: CGRect) { if !self.is_in_drag() { trace!(?wid, "Skipping swap: not in drag (mouse up received)"); + self.drag_manager.hide_feedback(); return; } @@ -1959,6 +1962,7 @@ impl Reactor { let server_id = { let Some(window) = self.window_manager.windows.get(&wid) else { + self.drag_manager.hide_feedback(); return; }; @@ -1967,6 +1971,7 @@ impl Reactor { .is_some_and(|wsid| self.space_manager.changing_screens.contains(&wsid)) { trace!(?wid, "Skipping swap: window is changing screens"); + self.drag_manager.hide_feedback(); return; } @@ -1980,6 +1985,7 @@ impl Reactor { } else { self.best_space_for_window(&new_frame, server_id) }) else { + self.drag_manager.hide_feedback(); return; }; @@ -2012,12 +2018,13 @@ impl Reactor { ?space, "Resetting drag swap tracking after space change" ); - self.drag_manager.drag_swap_manager.reset(); + self.drag_manager.reset(); return; } } if !self.layout_manager.layout_engine.is_window_in_active_workspace(space, wid) { + self.drag_manager.hide_feedback(); return; } @@ -2046,11 +2053,29 @@ impl Reactor { } let previous_pending = self.get_pending_drag_action(); - let new_candidate = - self.drag_manager.drag_swap_manager.on_frame_change(wid, new_frame, &candidates); + let overlap_move_threshold = if overlap_action == DragOverlapAction::Move { + Some(0.0) + } else { + None + }; + let new_candidate = self.drag_manager.drag_swap_manager.on_frame_change( + wid, + new_frame, + &candidates, + overlap_move_threshold, + ); let active_target = self.drag_manager.drag_swap_manager.last_target(); if let Some(target_wid) = active_target { + if let Some(target_state) = self.window_manager.windows.get(&target_wid) { + let relative = target_state.window_server_id.map(|id| id.as_u32()); + let kind = match overlap_action { + DragOverlapAction::Move => FeedbackKind::Outline { fill: true }, + _ => FeedbackKind::Outline { fill: false }, + }; + self.drag_manager.show_feedback(target_state.frame_monotonic, relative, kind); + } + if new_candidate.is_some() || previous_pending != Some((wid, target_wid, overlap_action)) { @@ -2099,6 +2124,9 @@ impl Reactor { if self.drag_manager.skip_layout_for_window == Some(wid) { self.drag_manager.skip_layout_for_window = None; } + + // No active target: ensure the overlay stays hidden instead of lingering or filling the screen. + self.drag_manager.hide_feedback(); } // wait for mouse::up before doing *anything* } diff --git a/src/actor/reactor/managers.rs b/src/actor/reactor/managers.rs index 5dfd4984..06bb4364 100644 --- a/src/actor/reactor/managers.rs +++ b/src/actor/reactor/managers.rs @@ -20,6 +20,7 @@ use crate::common::config::{Config, WindowSnappingSettings}; use crate::layout_engine::LayoutEngine; use crate::sys::screen::{ScreenId, SpaceId}; use crate::sys::window_server::{WindowServerId, WindowServerInfo}; +use crate::ui::drag_feedback::{DragFeedback, FeedbackKind}; /// Manages window state and lifecycle pub struct WindowManager { @@ -105,10 +106,14 @@ pub struct DragManager { pub drag_state: super::DragState, pub drag_swap_manager: DragSwapManager, pub skip_layout_for_window: Option, + pub drag_feedback: Option, } impl DragManager { - pub fn reset(&mut self) { self.drag_swap_manager.reset(); } + pub fn reset(&mut self) { + self.drag_swap_manager.reset(); + self.hide_feedback(); + } pub fn last_target(&self) -> Option { self.drag_swap_manager.last_target() } @@ -119,6 +124,22 @@ impl DragManager { pub fn update_config(&mut self, config: WindowSnappingSettings) { self.drag_swap_manager.update_config(config); } + + pub fn show_feedback(&mut self, frame: CGRect, relative_to: Option, kind: FeedbackKind) { + if let Some(feedback) = self.drag_feedback.as_mut() { + if let Err(err) = feedback.show(frame, relative_to, kind) { + trace!(error=?err, "failed to show drag feedback overlay"); + } + } + } + + pub fn hide_feedback(&mut self) { + if let Some(feedback) = self.drag_feedback.as_mut() { + if let Err(err) = feedback.hide() { + trace!(error=?err, "failed to hide drag feedback overlay"); + } + } + } } /// Manages window notifications diff --git a/src/ui.rs b/src/ui.rs index 7a686255..fb4d7e4c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,3 +1,4 @@ +pub mod drag_feedback; pub mod menu_bar; pub mod mission_control; pub mod stack_line; From 613eaec3692f4e803c3e9be33f8415a78ba08e8c Mon Sep 17 00:00:00 2001 From: acsandmann <157552025+acsandmann@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:09:40 -0500 Subject: [PATCH 3/5] wip --- src/ui/drag_feedback.rs | 195 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 src/ui/drag_feedback.rs diff --git a/src/ui/drag_feedback.rs b/src/ui/drag_feedback.rs new file mode 100644 index 00000000..4e300eee --- /dev/null +++ b/src/ui/drag_feedback.rs @@ -0,0 +1,195 @@ +use std::ptr; + +use objc2::rc::Retained; +use objc2_app_kit::NSStatusWindowLevel; +use objc2_core_foundation::{CFType, CGPoint, CGRect, CGSize}; +use objc2_core_graphics::CGContext; +use objc2_quartz_core::CALayer; +use tracing::warn; + +use crate::layout_engine::Direction; +use crate::sys::cgs_window::{CgsWindow, CgsWindowError}; +use crate::sys::skylight::{ + CFRelease, G_CONNECTION, SLSFlushWindowContentRegion, SLWindowContextCreate, +}; + +unsafe extern "C" { + fn CGContextFlush(ctx: *mut CGContext); + fn CGContextClearRect(ctx: *mut CGContext, rect: CGRect); + fn CGContextSaveGState(ctx: *mut CGContext); + fn CGContextRestoreGState(ctx: *mut CGContext); + fn CGContextTranslateCTM(ctx: *mut CGContext, tx: f64, ty: f64); + fn CGContextScaleCTM(ctx: *mut CGContext, sx: f64, sy: f64); +} + +const BORDER_WIDTH: f64 = 2.0; +const CORNER_RADIUS: f64 = 9.0; +const FILL_ALPHA: f64 = 0.12; +const STROKE_ALPHA: f64 = 0.9; +const EDGE_THICKNESS: f64 = 8.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FeedbackKind { + Outline { fill: bool }, + Edge(Direction), +} + +pub struct DragFeedback { + frame: CGRect, + root_layer: Retained, + highlight_layer: Retained, + cgs_window: CgsWindow, + visible: bool, + last_kind: FeedbackKind, +} + +impl DragFeedback { + pub fn new() -> Result { + let frame = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(1.0, 1.0)); + + let root_layer = CALayer::layer(); + root_layer.setFrame(CGRect::new(CGPoint::new(0.0, 0.0), frame.size)); + root_layer.setGeometryFlipped(true); + + let highlight_layer = CALayer::layer(); + highlight_layer.setFrame(CGRect::new(CGPoint::new(0.0, 0.0), frame.size)); + let stroke = + objc2_app_kit::NSColor::colorWithRed_green_blue_alpha(0.0, 0.5, 1.0, STROKE_ALPHA); + highlight_layer.setBorderWidth(BORDER_WIDTH); + highlight_layer.setCornerRadius(CORNER_RADIUS); + highlight_layer.setBorderColor(Some(&stroke.CGColor())); + highlight_layer.setBackgroundColor(None); + + root_layer.addSublayer(&highlight_layer); + + let cgs_window = CgsWindow::new(frame)?; + if let Err(err) = cgs_window.set_opacity(false) { + warn!(error=?err, "failed to set drag feedback window opacity"); + } + if let Err(err) = cgs_window.set_alpha(1.0) { + warn!(error=?err, "failed to set drag feedback window alpha"); + } + if let Err(err) = cgs_window.set_level(NSStatusWindowLevel as i32) { + warn!(error=?err, "failed to set drag feedback window level"); + } + + Ok(Self { + frame, + root_layer, + highlight_layer, + cgs_window, + visible: false, + last_kind: FeedbackKind::Outline { fill: false }, + }) + } + + pub fn show( + &mut self, + frame: CGRect, + relative_to: Option, + kind: FeedbackKind, + ) -> Result<(), CgsWindowError> { + if frame.size.width <= 0.0 || frame.size.height <= 0.0 { + return self.hide(); + } + + let same_frame = self.visible + && (self.frame.origin.x - frame.origin.x).abs() < 0.5 + && (self.frame.origin.y - frame.origin.y).abs() < 0.5 + && (self.frame.size.width - frame.size.width).abs() < 0.5 + && (self.frame.size.height - frame.size.height).abs() < 0.5 + && self.last_kind == kind; + + self.frame = frame; + self.last_kind = kind; + + if !same_frame { + self.cgs_window.set_shape(frame)?; + self.root_layer.setFrame(CGRect::new(CGPoint::new(0.0, 0.0), frame.size)); + } + + match kind { + FeedbackKind::Outline { fill } => { + self.highlight_layer.setCornerRadius(CORNER_RADIUS); + self.highlight_layer.setBorderWidth(BORDER_WIDTH); + self.highlight_layer.setFrame(CGRect::new(CGPoint::new(0.0, 0.0), frame.size)); + if fill { + let fill = objc2_app_kit::NSColor::colorWithRed_green_blue_alpha( + 0.0, 0.5, 1.0, FILL_ALPHA, + ); + self.highlight_layer.setBackgroundColor(Some(&fill.CGColor())); + } else { + self.highlight_layer.setBackgroundColor(None); + } + } + FeedbackKind::Edge(direction) => { + self.highlight_layer.setCornerRadius(3.0); + self.highlight_layer.setBorderWidth(0.0); + self.highlight_layer.setBackgroundColor(None); + let bar = match direction { + Direction::Left => CGRect::new( + CGPoint::new(0.0, 0.0), + CGSize::new(EDGE_THICKNESS, frame.size.height), + ), + Direction::Right => CGRect::new( + CGPoint::new(frame.size.width - EDGE_THICKNESS, 0.0), + CGSize::new(EDGE_THICKNESS, frame.size.height), + ), + Direction::Up => CGRect::new( + CGPoint::new(0.0, 0.0), + CGSize::new(frame.size.width, EDGE_THICKNESS), + ), + Direction::Down => CGRect::new( + CGPoint::new(0.0, frame.size.height - EDGE_THICKNESS), + CGSize::new(frame.size.width, EDGE_THICKNESS), + ), + }; + self.highlight_layer.setFrame(bar); + } + } + + self.present(); + self.visible = true; + if !same_frame { + self.cgs_window.order_above(relative_to)?; + } + Ok(()) + } + + pub fn hide(&mut self) -> Result<(), CgsWindowError> { + if !self.visible { + return Ok(()); + } + self.visible = false; + self.cgs_window.order_out() + } + + pub fn is_visible(&self) -> bool { self.visible } + + fn present(&self) { + let frame = self.frame; + let ctx: *mut CGContext = unsafe { + SLWindowContextCreate( + *G_CONNECTION, + self.cgs_window.id(), + ptr::null_mut() as *mut CFType, + ) + }; + if ctx.is_null() { + return; + } + + unsafe { + let clear = CGRect::new(CGPoint::new(0.0, 0.0), frame.size); + CGContextClearRect(ctx, clear); + CGContextSaveGState(ctx); + CGContextTranslateCTM(ctx, 0.0, frame.size.height); + CGContextScaleCTM(ctx, 1.0, -1.0); + self.root_layer.renderInContext(&*ctx); + CGContextRestoreGState(ctx); + CGContextFlush(ctx); + SLSFlushWindowContentRegion(*G_CONNECTION, self.cgs_window.id(), ptr::null_mut()); + CFRelease(ctx as *mut CFType); + } + } +} From 4a4beec98fb00787db9e08371a10cf9a71168267 Mon Sep 17 00:00:00 2001 From: acsandmann <157552025+acsandmann@users.noreply.github.com> Date: Tue, 2 Dec 2025 20:16:41 -0500 Subject: [PATCH 4/5] wip --- src/actor/drag_swap.rs | 36 ++++------ src/actor/reactor.rs | 129 +++++++++++++++++++++++++++++++++--- src/layout_engine/engine.rs | 11 +++ src/ui/drag_feedback.rs | 4 +- 4 files changed, 145 insertions(+), 35 deletions(-) diff --git a/src/actor/drag_swap.rs b/src/actor/drag_swap.rs index f5802349..e32733f5 100644 --- a/src/actor/drag_swap.rs +++ b/src/actor/drag_swap.rs @@ -70,9 +70,8 @@ impl DragManager { return None; } - let overlap_threshold = required_overlap - .unwrap_or(self.config.drag_swap_fraction) - .clamp(0.0, 1.0); + let overlap_threshold = + required_overlap.unwrap_or(self.config.drag_swap_fraction).clamp(0.0, 1.0); let stick_fraction = (overlap_threshold * STICK_RATIO).clamp(0.0, overlap_threshold); let dragged_center = Self::rect_center(new_frame); let dragged_diag = @@ -131,23 +130,21 @@ impl DragManager { .active_candidate .and_then(|active| scored.iter().copied().find(|c| c.window == active.window)); - if let Some(active) = active_metrics { - self.active_candidate = Some(ActiveCandidate { window: active.window }); - - if active.window == best.window { - return None; - } - - if best.overlap >= overlap_threshold - && best.score >= active.score + SWITCH_DELTA - { - self.active_candidate = Some(ActiveCandidate { window: best.window }); - return Some(best.window); - } + if let Some(active) = active_metrics { + self.active_candidate = Some(ActiveCandidate { window: active.window }); + if active.window == best.window { return None; } + if best.overlap >= overlap_threshold && best.score >= active.score + SWITCH_DELTA { + self.active_candidate = Some(ActiveCandidate { window: best.window }); + return Some(best.window); + } + + return None; + } + if best.overlap >= overlap_threshold { self.active_candidate = Some(ActiveCandidate { window: best.window }); return Some(best.window); @@ -298,12 +295,7 @@ mod tests { assert_eq!(dm.last_target(), Some(WindowId::new(7, 3))); let cand_a_dominant = (WindowId::new(7, 2), rect(-10.0, 0.0, 120.0, 100.0)); // 100% overlap - let switched = dm.on_frame_change( - wid, - dragged, - &[cand_a_dominant, cand_b], - None, - ); + let switched = dm.on_frame_change(wid, dragged, &[cand_a_dominant, cand_b], None); assert_eq!(switched, Some(WindowId::new(7, 2))); assert_eq!(dm.last_target(), Some(WindowId::new(7, 2))); } diff --git a/src/actor/reactor.rs b/src/actor/reactor.rs index 4dc59fc6..92919494 100644 --- a/src/actor/reactor.rs +++ b/src/actor/reactor.rs @@ -31,7 +31,7 @@ use events::system::SystemEventHandler; use events::window::WindowEventHandler; use main_window::MainWindowTracker; use managers::LayoutManager; -use objc2_core_foundation::{CGPoint, CGRect}; +use objc2_core_foundation::{CGPoint, CGRect, CGSize}; pub use replay::{Record, replay}; use serde::{Deserialize, Serialize}; use serde_json; @@ -55,7 +55,9 @@ use crate::sys::event::MouseState; use crate::sys::executor::Executor; use crate::sys::geometry::{CGRectDef, CGRectExt}; use crate::sys::hotkey::Modifiers; -use crate::sys::screen::{ScreenId, SpaceId, get_active_space_number}; +use crate::sys::screen::{ + ScreenId, SpaceId, get_active_space_number, order_visible_spaces_by_position, +}; use crate::sys::timer::Timer; use crate::sys::window_server::{ self, WindowServerId, WindowServerInfo, current_cursor_location, space_is_fullscreen, @@ -2052,6 +2054,12 @@ impl Reactor { candidates.push((other_wid, other_state.frame_monotonic)); } + // No candidate windows to swap with; hide feedback and return early + if candidates.is_empty() { + self.drag_manager.hide_feedback(); + return; + } + let previous_pending = self.get_pending_drag_action(); let overlap_move_threshold = if overlap_action == DragOverlapAction::Move { Some(0.0) @@ -2067,14 +2075,27 @@ impl Reactor { let active_target = self.drag_manager.drag_swap_manager.last_target(); if let Some(target_wid) = active_target { - if let Some(target_state) = self.window_manager.windows.get(&target_wid) { - let relative = target_state.window_server_id.map(|id| id.as_u32()); - let kind = match overlap_action { - DragOverlapAction::Move => FeedbackKind::Outline { fill: true }, - _ => FeedbackKind::Outline { fill: false }, - }; - self.drag_manager.show_feedback(target_state.frame_monotonic, relative, kind); - } + let Some(target_state) = self.window_manager.windows.get(&target_wid) else { + // Target window no longer exists; hide feedback and reset + self.drag_manager.hide_feedback(); + self.drag_manager.drag_swap_manager.reset(); + return; + }; + + let relative = target_state.window_server_id.map(|id| id.as_u32()); + let kind = match overlap_action { + DragOverlapAction::Move => FeedbackKind::Outline { fill: true }, + _ => FeedbackKind::Outline { fill: false }, + }; + let preview_frame = if overlap_action == DragOverlapAction::Move { + let direction = + Self::move_direction_for_preview(new_frame, target_state.frame_monotonic); + self.preview_frame_for_move(space, wid, direction) + .unwrap_or(target_state.frame_monotonic) + } else { + target_state.frame_monotonic + }; + self.drag_manager.show_feedback(preview_frame, relative, kind); if new_candidate.is_some() || previous_pending != Some((wid, target_wid, overlap_action)) @@ -2131,6 +2152,94 @@ impl Reactor { // wait for mouse::up before doing *anything* } + fn move_direction_for_preview(dragged_frame: CGRect, target_frame: CGRect) -> Direction { + let delta_x = target_frame.mid().x - dragged_frame.mid().x; + let delta_y = target_frame.mid().y - dragged_frame.mid().y; + if delta_x.abs() >= delta_y.abs() { + if delta_x >= 0.0 { + Direction::Right + } else { + Direction::Left + } + } else if delta_y >= 0.0 { + Direction::Down + } else { + Direction::Up + } + } + + fn preview_frame_for_move( + &self, + space: SpaceId, + dragged_wid: WindowId, + direction: Direction, + ) -> Option { + let (screen_frame, display_uuid) = + self.space_manager.screens.iter().find_map(|screen| { + if self.space_manager.space_for_screen(screen) == Some(space) { + Some((screen.frame, screen.display_uuid.clone())) + } else { + None + } + })?; + let visible_spaces_input: Vec<(SpaceId, CGPoint)> = self + .space_manager + .screens + .iter() + .filter_map(|screen| { + self.space_manager + .space_for_screen(screen) + .map(|space_id| (space_id, screen.frame.mid())) + }) + .collect(); + if visible_spaces_input.is_empty() { + return None; + } + let mut visible_space_centers = HashMap::default(); + for (space_id, center) in &visible_spaces_input { + visible_space_centers.insert(*space_id, *center); + } + let visible_spaces = order_visible_spaces_by_position(visible_spaces_input.iter().cloned()); + let mut preview_engine = self.layout_manager.layout_engine.clone_for_preview()?; + preview_engine.handle_command( + Some(space), + &visible_spaces, + &visible_space_centers, + LayoutCommand::MoveNode(direction), + ); + let display_uuid_opt = if display_uuid.is_empty() { + None + } else { + Some(display_uuid.as_str()) + }; + let gaps = self + .config_manager + .config + .settings + .layout + .gaps + .effective_for_display(display_uuid_opt); + let stack_line = &self.config_manager.config.settings.ui.stack_line; + let layout_result = preview_engine.calculate_layout_with_virtual_workspaces( + space, + screen_frame, + &gaps, + stack_line.thickness(), + stack_line.horiz_placement, + stack_line.vert_placement, + |wid| { + self.window_manager + .windows + .get(&wid) + .map(|w| w.frame_monotonic.size) + .unwrap_or_else(|| CGSize::new(500.0, 500.0)) + }, + ); + layout_result + .into_iter() + .find_map(|(wid, rect)| (wid == dragged_wid).then(|| rect)) + } + fn window_id_under_cursor(&self) -> Option { let wsid = window_server::window_under_cursor()?; self.window_manager.window_ids.get(&wsid).copied() diff --git a/src/layout_engine/engine.rs b/src/layout_engine/engine.rs index faabf7c3..f62b3fa6 100644 --- a/src/layout_engine/engine.rs +++ b/src/layout_engine/engine.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; use objc2_core_foundation::{CGPoint, CGRect, CGSize}; use serde::{Deserialize, Serialize}; +use serde_json; use tracing::{debug, info, warn}; use super::{Direction, FloatingManager, LayoutId, LayoutSystemKind, WorkspaceLayouts}; @@ -122,6 +123,16 @@ impl LayoutEngine { self.virtual_workspace_manager.update_settings(settings); } + pub fn clone_for_preview(&self) -> Option { + let data = serde_json::to_vec(self).ok()?; + let mut cloned: LayoutEngine = serde_json::from_slice(&data).ok()?; + cloned.layout_settings = self.layout_settings.clone(); + cloned.focused_window = self.focused_window; + cloned.space_display_map = self.space_display_map.clone(); + cloned.broadcast_tx = None; + Some(cloned) + } + fn active_floating_windows_in_workspace(&self, space: SpaceId) -> Vec { self.floating .active_flat(space) diff --git a/src/ui/drag_feedback.rs b/src/ui/drag_feedback.rs index 4e300eee..148a6601 100644 --- a/src/ui/drag_feedback.rs +++ b/src/ui/drag_feedback.rs @@ -150,9 +150,7 @@ impl DragFeedback { self.present(); self.visible = true; - if !same_frame { - self.cgs_window.order_above(relative_to)?; - } + self.cgs_window.order_above(relative_to)?; Ok(()) } From af865d3418d02f158efebc586280fe74a4daaf6a Mon Sep 17 00:00:00 2001 From: acsandmann <157552025+acsandmann@users.noreply.github.com> Date: Tue, 2 Dec 2025 22:46:59 -0500 Subject: [PATCH 5/5] wip --- src/actor/reactor.rs | 75 +++++++++++++++------------------- src/layout_engine/engine.rs | 51 ++++++++++++++++++++++- src/model/virtual_workspace.rs | 7 ++++ 3 files changed, 89 insertions(+), 44 deletions(-) diff --git a/src/actor/reactor.rs b/src/actor/reactor.rs index 92919494..382bdb35 100644 --- a/src/actor/reactor.rs +++ b/src/actor/reactor.rs @@ -2174,24 +2174,28 @@ impl Reactor { dragged_wid: WindowId, direction: Direction, ) -> Option { - let (screen_frame, display_uuid) = - self.space_manager.screens.iter().find_map(|screen| { - if self.space_manager.space_for_screen(screen) == Some(space) { - Some((screen.frame, screen.display_uuid.clone())) - } else { + let mut visible_spaces_input: Vec<(SpaceId, CGPoint)> = Vec::new(); + let mut screen_frames = HashMap::default(); + let mut gaps_by_space = HashMap::default(); + for screen in &self.space_manager.screens { + if let Some(space_id) = self.space_manager.space_for_screen(screen) { + visible_spaces_input.push((space_id, screen.frame.mid())); + screen_frames.insert(space_id, screen.frame); + let display_uuid_opt = if screen.display_uuid.is_empty() { None - } - })?; - let visible_spaces_input: Vec<(SpaceId, CGPoint)> = self - .space_manager - .screens - .iter() - .filter_map(|screen| { - self.space_manager - .space_for_screen(screen) - .map(|space_id| (space_id, screen.frame.mid())) - }) - .collect(); + } else { + Some(screen.display_uuid.as_str()) + }; + let gaps = self + .config_manager + .config + .settings + .layout + .gaps + .effective_for_display(display_uuid_opt); + gaps_by_space.insert(space_id, gaps); + } + } if visible_spaces_input.is_empty() { return None; } @@ -2200,33 +2204,21 @@ impl Reactor { visible_space_centers.insert(*space_id, *center); } let visible_spaces = order_visible_spaces_by_position(visible_spaces_input.iter().cloned()); - let mut preview_engine = self.layout_manager.layout_engine.clone_for_preview()?; - preview_engine.handle_command( - Some(space), - &visible_spaces, - &visible_space_centers, - LayoutCommand::MoveNode(direction), - ); - let display_uuid_opt = if display_uuid.is_empty() { - None - } else { - Some(display_uuid.as_str()) - }; - let gaps = self - .config_manager - .config - .settings - .layout - .gaps - .effective_for_display(display_uuid_opt); + if !screen_frames.contains_key(&space) { + return None; + } let stack_line = &self.config_manager.config.settings.ui.stack_line; - let layout_result = preview_engine.calculate_layout_with_virtual_workspaces( + self.layout_manager.layout_engine.preview_window_frame_after_command( space, - screen_frame, - &gaps, + &visible_spaces, + &visible_space_centers, + &screen_frames, + &gaps_by_space, stack_line.thickness(), stack_line.horiz_placement, stack_line.vert_placement, + LayoutCommand::MoveNode(direction), + dragged_wid, |wid| { self.window_manager .windows @@ -2234,10 +2226,7 @@ impl Reactor { .map(|w| w.frame_monotonic.size) .unwrap_or_else(|| CGSize::new(500.0, 500.0)) }, - ); - layout_result - .into_iter() - .find_map(|(wid, rect)| (wid == dragged_wid).then(|| rect)) + ) } fn window_id_under_cursor(&self) -> Option { diff --git a/src/layout_engine/engine.rs b/src/layout_engine/engine.rs index f62b3fa6..b466b449 100644 --- a/src/layout_engine/engine.rs +++ b/src/layout_engine/engine.rs @@ -12,7 +12,7 @@ use super::{Direction, FloatingManager, LayoutId, LayoutSystemKind, WorkspaceLay use crate::actor::app::{AppInfo, WindowId, pid_t}; use crate::actor::broadcast::{BroadcastEvent, BroadcastSender}; use crate::common::collections::HashMap; -use crate::common::config::LayoutSettings; +use crate::common::config::{GapSettings, HorizontalPlacement, LayoutSettings, VerticalPlacement}; use crate::layout_engine::LayoutSystem; use crate::model::{VirtualWorkspaceId, VirtualWorkspaceManager}; use crate::sys::screen::SpaceId; @@ -1633,6 +1633,55 @@ impl LayoutEngine { self.tree.select_window(layout, wid) } + pub fn space_for_window(&self, window_id: WindowId) -> Option { + self.virtual_workspace_manager.space_for_window(window_id) + } + + pub fn preview_window_frame_after_command( + &self, + space: SpaceId, + visible_spaces: &[SpaceId], + visible_space_centers: &HashMap, + screen_frames: &HashMap, + gaps_by_space: &HashMap, + stack_line_thickness: f64, + stack_line_horiz: HorizontalPlacement, + stack_line_vert: VerticalPlacement, + command: LayoutCommand, + window_id: WindowId, + get_window_size: F, + ) -> Option + where + F: Fn(WindowId) -> CGSize, + { + let mut preview_engine = self.clone_for_preview()?; + if !preview_engine.select_window_in_space(space, window_id) { + return None; + } + preview_engine.handle_command(Some(space), visible_spaces, visible_space_centers, command); + let target_space = preview_engine.space_for_window(window_id).unwrap_or(space); + let screen_frame = screen_frames + .get(&target_space) + .copied() + .or_else(|| screen_frames.get(&space).copied())?; + let gaps = gaps_by_space + .get(&target_space) + .or_else(|| gaps_by_space.get(&space)) + .unwrap_or(&self.layout_settings.gaps); + let layout_result = preview_engine.calculate_layout_with_virtual_workspaces( + target_space, + screen_frame, + gaps, + stack_line_thickness, + stack_line_horiz, + stack_line_vert, + get_window_size, + ); + layout_result + .into_iter() + .find_map(|(wid, rect)| (wid == window_id).then(|| rect)) + } + pub fn is_window_in_active_workspace(&self, space: SpaceId, window_id: WindowId) -> bool { self.virtual_workspace_manager.is_window_in_active_workspace(space, window_id) } diff --git a/src/model/virtual_workspace.rs b/src/model/virtual_workspace.rs index 1a2dc55b..a49d4dc2 100644 --- a/src/model/virtual_workspace.rs +++ b/src/model/virtual_workspace.rs @@ -421,6 +421,13 @@ impl VirtualWorkspaceManager { self.window_to_workspace.get(&(space, window_id)).copied() } + pub fn space_for_window(&self, window_id: WindowId) -> Option { + self.window_to_workspace + .iter() + .find(|((space, wid), _)| *wid == window_id) + .map(|((space, _), _)| *space) + } + pub fn remove_window(&mut self, window_id: WindowId) { let keys: Vec<(SpaceId, WindowId)> = self .window_to_workspace