use bevy::app::{App, Plugin, Update};
use bevy::ecs::entity::Entity;
use bevy::ecs::message::MessageReader;
use bevy::ecs::query::With;
use bevy::ecs::schedule::IntoScheduleConfigs as _;
use bevy::ecs::system::{Commands, Local, Query, Res, Single};
use std::time::{Duration, Instant};
use tracing::{debug, trace, warn};
use super::{MouseHeldMarker, Timeout};
use crate::config::Config;
use crate::ecs::layout::LayoutStrip;
use crate::ecs::params::{GlobalState, Windows};
use crate::ecs::{
ActiveWorkspaceMarker, DockPosition, MissionControlActive, Position, Scrolling,
SpawnCommandsExt,
};
use crate::events::Event;
use crate::manager::{Display, Origin, WindowManager, origin_from};
use crate::platform::WinID;
const CORNER_DEAD_ZONE_PX: i32 = 30;
pub struct MouseEventsPlugin;
impl Plugin for MouseEventsPlugin {
fn build(&self, app: &mut App) {
let mission_control_inactive = |mission_control: Option<Res<MissionControlActive>>| {
mission_control.is_none_or(|active| !active.0)
};
app.add_systems(
Update,
(
(
mouse_moved_trigger,
mouse_resize_trigger,
mouse_down_trigger,
)
.run_if(mission_control_inactive),
mouse_up_trigger,
horizontal_warp_mouse_trigger,
),
);
}
}
fn is_in_corner_dead_zone(
point: Origin,
display: &Display,
dock: Option<&DockPosition>,
config: &Config,
) -> bool {
let bounds = display.actual_display_bounds(dock, config);
point.x >= bounds.max.x - CORNER_DEAD_ZONE_PX && point.y >= bounds.max.y - CORNER_DEAD_ZONE_PX
}
#[allow(clippy::needless_pass_by_value)]
fn mouse_moved_trigger(
mut messages: MessageReader<Event>,
windows: Windows,
displays: Query<(&Display, Option<&DockPosition>)>,
window_manager: Res<WindowManager>,
config: Res<Config>,
mut global_state: GlobalState,
mut commands: Commands,
) {
for event in messages.read() {
let Event::MouseMoved { point, modifiers } = event else {
continue;
};
if config
.mouse_resize_modifier()
.is_some_and(|modifier| modifier.matches(*modifiers))
{
continue;
}
let cursor = origin_from(*point);
if displays.iter().any(|(display, dock)| {
display.bounds().contains(cursor)
&& is_in_corner_dead_zone(cursor, display, dock, &config)
}) {
trace!("mouse moved suppressed in corner dead-zone {point:?}");
continue;
}
if !config.focus_follows_mouse() {
continue;
}
if global_state.ffm_flag().is_some() {
trace!("ffm_window_id > 0");
continue;
}
let pointer = origin_from(*point);
if windows
.focused()
.is_some_and(|(window, _)| window.frame().contains(pointer))
{
trace!("pointer still inside focused window");
continue;
}
let Ok(window_id) = window_manager.find_window_at_point(point) else {
debug!("can not find window at point {point:?}");
continue;
};
if windows
.focused()
.is_some_and(|(window, _)| window.id() == window_id)
{
trace!("allready focused {window_id}");
continue;
}
let Some((window, entity)) = windows.find(window_id) else {
trace!("can not find focused window: {window_id}");
continue;
};
let child_window = window_manager
.get_associated_windows(window_id)
.into_iter()
.find_map(|child_wid| {
windows.find(child_wid).and_then(|(window, _)| {
window
.child_role()
.inspect_err(|err| {
warn!("getting role {window_id}: {err}");
})
.is_ok_and(|child| child)
.then_some(window)
})
});
if let Some(child) = child_window {
debug!("found child of {}: {}", child.id(), window.id());
}
global_state.set_skip_reshuffle(true);
global_state.set_ffm_flag(Some(window.id()));
commands.focus_entity(entity, false);
}
}
#[allow(clippy::needless_pass_by_value)]
fn mouse_down_trigger(
mut messages: MessageReader<Event>,
windows: Windows,
active_workspace: Query<(Entity, Option<&Scrolling>), With<ActiveWorkspaceMarker>>,
window_manager: Res<WindowManager>,
config: Res<Config>,
mouse_held: Query<Entity, With<MouseHeldMarker>>,
mut commands: Commands,
) {
for event in messages.read() {
let Event::MouseDown { point, .. } = event else {
continue;
};
trace!("{point:?}");
let Some((_, entity)) = window_manager
.find_window_at_point(point)
.ok()
.and_then(|window_id| windows.find(window_id))
else {
continue;
};
for (entity, scroll) in active_workspace {
if scroll.is_some()
&& let Ok(mut entity_commands) = commands.get_entity(entity)
{
entity_commands.try_remove::<Scrolling>();
}
}
for held in &mouse_held {
if let Ok(mut entity_commands) = commands.get_entity(held) {
entity_commands.try_despawn();
}
}
if config.window_hidden_ratio() >= 1.0 {
} else {
let timeout = Timeout::new(Duration::from_secs(5), None, &mut commands);
commands.spawn((MouseHeldMarker(entity), timeout));
}
}
}
#[allow(clippy::needless_pass_by_value)]
fn mouse_up_trigger(
mut messages: MessageReader<Event>,
mouse_held: Query<(Entity, &MouseHeldMarker)>,
mut commands: Commands,
) {
for event in messages.read() {
if !matches!(event, Event::MouseUp { .. }) {
continue;
}
for (held_entity, marker) in &mouse_held {
commands.reshuffle_around(marker.0);
if let Ok(mut entity_commands) = commands.get_entity(held_entity) {
entity_commands.try_despawn();
}
}
}
}
#[derive(Default)]
pub(super) struct MouseResizeState {
last_point: Option<Origin>,
window_id: Option<WinID>,
}
#[allow(clippy::needless_pass_by_value)]
fn mouse_resize_trigger(
mut messages: MessageReader<Event>,
windows: Windows,
active_workspace: Single<(Entity, &LayoutStrip, &Position), With<ActiveWorkspaceMarker>>,
window_manager: Res<WindowManager>,
config: Res<Config>,
mut state: Local<MouseResizeState>,
mut commands: Commands,
) {
for event in messages.read() {
let Event::MouseMoved { point, modifiers } = event else {
continue;
};
if config
.mouse_resize_modifier()
.is_none_or(|modifier| !modifier.matches(*modifiers))
{
state.last_point = None;
state.window_id = None;
continue;
}
let pointer = origin_from(*point);
let Some(last_point) = state.last_point else {
state.last_point = Some(pointer);
continue;
};
state.last_point = Some(pointer);
let dx = (pointer.x - last_point.x) * 5;
if dx.abs() < 1 {
continue;
}
let window_id = if let Some(window_id) = state.window_id {
window_id
} else {
let Ok(window_id) = window_manager.find_window_at_point(point) else {
continue;
};
state.window_id = Some(window_id);
window_id
};
let Some((window, entity)) = windows.find(window_id) else {
continue;
};
let (strip_entity, strip, strip_position) = *active_workspace;
let floating = !strip.contains(entity);
let mut frame = window.frame();
let center = frame.center();
if pointer.x < center.x {
if floating && let Some(mut origin) = windows.origin(entity) {
origin.x += dx;
commands.reposition_entity(entity, origin);
} else {
let mut origin = strip_position.0;
origin.x += dx;
commands.reposition_entity(strip_entity, origin);
}
frame.min.x += dx;
} else {
frame.max.x += dx;
}
commands.resize_entity(entity, frame.size());
}
}
#[derive(Default)]
pub(super) struct WarpVelocityState {
last: Option<(Origin, Instant)>,
}
#[allow(clippy::needless_pass_by_value)]
fn horizontal_warp_mouse_trigger(
mut messages: MessageReader<Event>,
displays: Query<&Display>,
window_manager: Res<WindowManager>,
config: Res<Config>,
mut state: Local<WarpVelocityState>,
) {
const EDGE_THRESHOLD: i32 = 3;
const LANDING_INSET: i32 = 6;
const CARRY_DURATION: Duration = Duration::from_millis(30);
const MAX_CARRY_PX: i32 = 80;
const VELOCITY_FRESHNESS: Duration = Duration::from_millis(80);
for event in messages.read() {
let Event::MouseMoved { point, .. } = event else {
continue;
};
let now = Instant::now();
let point = origin_from(*point);
let velocity_x = state.last.and_then(|(prev, t)| {
let dt = now.saturating_duration_since(t);
if dt.is_zero() || dt > VELOCITY_FRESHNESS {
return None;
}
let dx = f64::from(point.x - prev.x);
Some(dx / dt.as_secs_f64())
});
state.last = Some((point, now));
let Some(warp_direction) = config.horizontal_mouse_warp() else {
return;
};
if displays.count() < 2 {
return;
}
let Some(current_display) = displays
.iter()
.find(|display| display.bounds().contains(point))
else {
return;
};
let on_left_edge = (point.x - current_display.bounds().min.x).abs() < EDGE_THRESHOLD;
let on_right_edge = (current_display.bounds().max.x - point.x).abs() < EDGE_THRESHOLD;
if !on_left_edge && !on_right_edge {
return;
}
let mut target_displays = displays
.iter()
.filter(|display| {
let above = display.bounds().min.y < current_display.bounds().min.y;
let below = display.bounds().min.y > current_display.bounds().min.y;
if on_left_edge {
if warp_direction > 0 { below } else { above }
} else if warp_direction > 0 {
above
} else {
below
}
})
.collect::<Vec<_>>();
target_displays
.sort_by_key(|display| (display.bounds().min.y - current_display.bounds().min.y).abs());
let Some(warp_to) = target_displays.first() else {
return;
};
let target = warp_to.bounds();
let carry = velocity_x
.map_or(0, |v| (v * CARRY_DURATION.as_secs_f64()) as i32)
.clamp(-MAX_CARRY_PX, MAX_CARRY_PX);
let target_x = if on_left_edge {
(target.max.x - LANDING_INSET + carry).clamp(target.min.x + 1, target.max.x - 1)
} else {
(target.min.x + LANDING_INSET + carry).clamp(target.min.x + 1, target.max.x - 1)
};
let relative_y = point.y - current_display.bounds().min.y;
let direction_sign = if target.min.y > current_display.bounds().min.y {
1
} else {
-1
};
let signed_offset = config.horizontal_mouse_warp_offset() * direction_sign;
let target_y = target.min.y + relative_y + signed_offset;
if target_y < target.min.y || target_y >= target.max.y {
return;
}
let landing = Origin::new(target_x, target_y);
window_manager.warp_mouse(landing);
state.last = Some((landing, now));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::ecs::DockPosition;
use crate::manager::{Display, Origin};
use bevy::math::IRect;
fn make_display() -> Display {
Display::new(
1,
IRect {
min: Origin::new(0, 0),
max: Origin::new(1024, 768),
},
20,
)
}
#[test]
fn corner_dead_zone_no_dock() {
let display = make_display();
let config = Config::default();
assert!(is_in_corner_dead_zone(
Origin::new(1000, 750),
&display,
None,
&config
));
assert!(is_in_corner_dead_zone(
Origin::new(1024, 768),
&display,
None,
&config
));
assert!(!is_in_corner_dead_zone(
Origin::new(993, 750),
&display,
None,
&config
));
assert!(!is_in_corner_dead_zone(
Origin::new(1000, 737),
&display,
None,
&config
));
}
#[test]
fn corner_dead_zone_with_bottom_dock() {
let display = make_display();
let config = Config::default();
let dock = DockPosition::Bottom(80);
assert!(is_in_corner_dead_zone(
Origin::new(1000, 680),
&display,
Some(&dock),
&config
));
assert!(!is_in_corner_dead_zone(
Origin::new(1000, 657),
&display,
Some(&dock),
&config
));
}
}