use bevy::ecs::entity::Entity;
use bevy::ecs::observer::On;
use bevy::ecs::system::{Commands, Res, ResMut};
use log::{debug, error};
use objc2_core_foundation::CGPoint;
use stdext::function_name;
use crate::config::Config;
use crate::ecs::params::{ActiveDisplayMut, Windows};
use crate::ecs::{
CommandTrigger, FocusFollowsMouse, FullWidthMarker, Unmanaged, WMEventTrigger,
reposition_entity, reshuffle_around, resize_entity,
};
use crate::errors::Result;
use crate::events::Event;
use crate::manager::{Column, LayoutStrip, WindowManager};
#[derive(Clone, Debug)]
pub enum Direction {
North,
South,
West,
East,
First,
Last,
}
#[derive(Clone, Debug)]
pub enum Operation {
Focus(Direction),
Swap(Direction),
Center,
Resize,
FullWidth,
ToNextDisplay,
Equalize,
Manage,
Stack(bool),
}
#[derive(Clone, Debug)]
pub enum MouseMove {
ToNextDisplay,
}
#[derive(Clone, Debug)]
pub enum Command {
Window(Operation),
Mouse(MouseMove),
Quit,
PrintState,
}
fn get_window_in_direction(
direction: &Direction,
entity: Entity,
strip: &LayoutStrip,
) -> Option<Entity> {
let index = strip.index_of(entity).ok()?;
match direction {
Direction::West => strip.left_neighbour(entity),
Direction::East => strip.right_neighbour(entity),
Direction::First => strip.first().ok().and_then(|column| column.top()),
Direction::Last => strip.last().ok().and_then(|column| column.top()),
Direction::North => match strip.get(index).ok()? {
Column::Single(window) => Some(window),
Column::Stack(stack) => stack
.iter()
.enumerate()
.find(|(_, window_id)| entity == **window_id)
.and_then(|(index, _)| (index > 0).then(|| stack.get(index - 1)).flatten())
.copied(),
},
Direction::South => match strip.get(index).ok()? {
Column::Single(window) => Some(window),
Column::Stack(stack) => stack
.iter()
.enumerate()
.find(|(_, window_id)| entity == **window_id)
.and_then(|(index, _)| {
(index < stack.len() - 1)
.then(|| stack.get(index + 1))
.flatten()
})
.copied(),
},
}
}
fn command_move_focus(
direction: &Direction,
strip: &LayoutStrip,
windows: &Windows,
) -> Option<Entity> {
let (_, entity) = windows.focused()?;
get_window_in_direction(direction, entity, strip).inspect(|entity| {
if let Some(window) = windows.get(*entity) {
window.focus_with_raise();
}
})
}
fn command_swap_focus(
direction: &Direction,
windows: &Windows,
active_display: &mut ActiveDisplayMut,
commands: &mut Commands,
) -> Option<Entity> {
let display_bounds = active_display.bounds();
let display_id = active_display.id();
let active_strip = active_display.active_strip();
let (_, current) = windows.focused()?;
let index = active_strip.index_of(current).ok()?;
let other_window = get_window_in_direction(direction, current, active_strip)?;
let new_index = active_strip.index_of(other_window).ok()?;
let current_frame = windows.get(current)?.frame();
let origin = if new_index == 0 {
CGPoint::new(0.0, 0.0)
} else if new_index == (active_strip.len() - 1) {
CGPoint::new(display_bounds.size.width - current_frame.size.width, 0.0)
} else {
active_strip
.get(new_index)
.ok()
.and_then(|column| column.top())
.and_then(|entity| windows.get(entity))?
.frame()
.origin
};
reposition_entity(current, origin.x, origin.y, display_id, commands);
if index < new_index {
(index..new_index).for_each(|idx| active_strip.swap(idx, idx + 1));
} else {
(new_index..index)
.rev()
.for_each(|idx| active_strip.swap(idx, idx + 1));
}
Some(other_window)
}
fn command_center_window(
windows: &Windows,
active_display: &ActiveDisplayMut,
window_manager: &WindowManager,
commands: &mut Commands,
) {
let Some((window, entity)) = windows.focused() else {
return;
};
let frame = window.frame();
reposition_entity(
entity,
(active_display.bounds().size.width - frame.size.width) / 2.0,
frame.origin.y,
active_display.id(),
commands,
);
window_manager.center_mouse(Some(window), &active_display.bounds());
}
fn resize_window(
windows: &Windows,
active_display: &mut ActiveDisplayMut,
commands: &mut Commands,
config: &Config,
) {
let Some((window, entity)) = windows.focused() else {
return;
};
let display_width = active_display.bounds().size.width;
let current_ratio = window.frame().size.width / display_width;
let next_ratio = config
.preset_column_widths()
.into_iter()
.find(|&r| r > current_ratio + 0.05)
.unwrap_or_else(|| *config.preset_column_widths().first().unwrap_or(&0.5));
let width = next_ratio * display_width;
let height = window.frame().size.height;
let x = (display_width - width).min(window.frame().origin.x);
let y = window.frame().origin.y;
reposition_entity(entity, x, y, active_display.id(), commands);
resize_entity(entity, width, height, active_display.id(), commands);
}
fn full_width_window(
windows: &Windows,
active_display: &mut ActiveDisplayMut,
commands: &mut Commands,
) {
let Some((window, entity, _, _)) = windows
.focused()
.and_then(|(_, entity)| windows.get_all(entity))
else {
return;
};
let display_width = active_display.bounds().size.width;
let height = window.frame().size.height;
let y = window.frame().origin.y;
let (width, x) = if let Some(previous_ratio) = windows.full_width(entity) {
commands.entity(entity).try_remove::<FullWidthMarker>();
let w = previous_ratio * display_width;
let x_pos = (display_width - w).min(window.frame().origin.x);
(w, x_pos)
} else {
commands
.entity(entity)
.try_insert(FullWidthMarker(window.width_ratio()));
(display_width, 0.0)
};
reposition_entity(entity, x, y, active_display.id(), commands);
resize_entity(entity, width, height, active_display.id(), commands);
}
fn manage_window(windows: &Windows, commands: &mut Commands) {
let Some((window, entity, _, unmanaged)) = windows
.focused()
.and_then(|(_, entity)| windows.get_all(entity))
else {
return;
};
debug!(
"{}: window: {} {entity} unmanaged: {}.",
function_name!(),
window.id(),
unmanaged.is_some()
);
if unmanaged.is_some() {
commands.entity(entity).try_remove::<Unmanaged>();
} else {
commands.entity(entity).try_insert(Unmanaged::Floating);
}
}
fn to_next_display(
windows: &Windows,
active_display: &mut ActiveDisplayMut,
commands: &mut Commands,
) {
let Some((window, entity, _, unmanaged)) = windows
.focused()
.and_then(|(_, entity)| windows.get_all(entity))
else {
return;
};
if unmanaged.is_some() {
return;
}
let Some(other) = active_display.other().next() else {
debug!("{}: no other display to move window to.", function_name!());
return;
};
debug!(
"{}: moving window (id {}, {entity}) to display {}: {}:{}.",
function_name!(),
window.id(),
other.id(),
other.bounds.size.width / 2.0,
other.menubar_height,
);
let dest = CGPoint::new(other.bounds.size.width / 2.0, other.menubar_height);
reposition_entity(entity, dest.x, dest.y, other.id(), commands);
reshuffle_around(entity, commands);
active_display.active_strip().remove(entity);
}
fn mouse_to_next_display(
active_display: &mut ActiveDisplayMut,
window_manager: &WindowManager,
ffm_flag: &mut ResMut<FocusFollowsMouse>,
commands: &mut Commands,
) {
let Some(other) = active_display.other().next() else {
debug!("{}: no other display to move mouse to.", function_name!());
return;
};
let point = CGPoint::new(
other.bounds.origin.x + other.bounds.size.width / 2.0,
other.bounds.origin.y + other.bounds.size.height / 2.0,
);
window_manager.center_mouse(None, &other.bounds);
ffm_flag.as_mut().0 = None;
commands.trigger(WMEventTrigger(Event::MouseMoved { point }));
}
fn equalize_column(
windows: &Windows,
active_display: &mut ActiveDisplayMut,
commands: &mut Commands,
) {
let Some((_, entity)) = windows.focused() else {
return;
};
let active_strip = active_display.active_strip();
let Ok(column) = active_strip
.index_of(entity)
.and_then(|index| active_strip.get(index))
else {
return;
};
if let Column::Stack(stack) = column {
let display_height =
active_display.bounds().size.height - active_display.display().menubar_height;
#[allow(clippy::cast_precision_loss)]
let equal_height = (display_height / stack.len() as f64).floor();
for &entity in &stack {
if let Some(window) = windows.get(entity) {
let width = window.frame().size.width;
resize_entity(entity, width, equal_height, active_display.id(), commands);
}
}
}
}
fn command_windows(
operation: &Operation,
windows: &Windows,
active_display: &mut ActiveDisplayMut,
window_manager: &WindowManager,
commands: &mut Commands,
config: &Config,
) -> Result<()> {
match operation {
Operation::Focus(direction) => {
command_move_focus(direction, active_display.active_strip(), windows);
}
Operation::Swap(direction) => {
command_swap_focus(direction, windows, active_display, commands);
}
Operation::Center => {
command_center_window(windows, active_display, window_manager, commands);
}
Operation::Resize => {
resize_window(windows, active_display, commands, config);
}
Operation::FullWidth => {
full_width_window(windows, active_display, commands);
}
Operation::ToNextDisplay => {
to_next_display(windows, active_display, commands);
}
Operation::Equalize => {
equalize_column(windows, active_display, commands);
}
Operation::Manage => {
manage_window(windows, commands);
}
Operation::Stack(stack) => {
if let Some((_, entity, _, unmanaged)) = windows
.focused()
.and_then(|(_, entity)| windows.get_all(entity))
{
if unmanaged.is_some() {
return Ok(());
} else if *stack {
active_display.active_strip().stack(entity)?;
} else {
active_display.active_strip().unstack(entity)?;
}
} else {
return Ok(());
}
}
}
if let Some((_, entity)) = windows.focused() {
reshuffle_around(entity, commands);
}
Ok(())
}
#[allow(clippy::needless_pass_by_value, clippy::too_many_arguments)]
pub fn process_command_trigger(
trigger: On<CommandTrigger>,
windows: Windows,
mut active_display: ActiveDisplayMut,
window_manager: Res<WindowManager>,
config: Res<Config>,
mut ffm_flag: ResMut<FocusFollowsMouse>,
mut commands: Commands,
) {
let res = match &trigger.event().0 {
Command::Window(operation) => {
let eligible = windows
.focused()
.is_some_and(|(window, _)| window.is_eligible());
if eligible {
command_windows(
operation,
&windows,
&mut active_display,
&window_manager,
&mut commands,
config.as_ref(),
)
} else {
Ok(())
}
}
Command::Mouse(movement) => {
match movement {
MouseMove::ToNextDisplay => {
mouse_to_next_display(
&mut active_display,
&window_manager,
&mut ffm_flag,
&mut commands,
);
}
}
Ok(())
}
Command::PrintState => {
commands.trigger(WMEventTrigger(Event::PrintState));
Ok(())
}
Command::Quit => window_manager.quit(),
};
if let Err(err) = res {
error!("{}: {err}", function_name!());
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::prelude::*;
fn setup_world_with_layout() -> (World, LayoutStrip, Vec<Entity>) {
let mut world = World::new();
let entities = world
.spawn_batch(vec![(), (), (), ()])
.collect::<Vec<Entity>>();
let mut strip = LayoutStrip::default();
strip.append(entities[0]); strip.append(entities[1]);
strip.append(entities[2]);
strip.append(entities[3]);
strip.stack(entities[1]).unwrap();
(world, strip, entities)
}
#[test]
fn test_get_window_in_direction_simple() {
let (_world, strip, entities) = setup_world_with_layout();
let e0 = entities[0];
let e2 = entities[2];
let e3 = entities[3];
let east = Direction::East;
let west = Direction::West;
assert_eq!(get_window_in_direction(&east, e2, &strip), Some(e3));
assert_eq!(get_window_in_direction(&west, e2, &strip), Some(e0));
assert_eq!(get_window_in_direction(&west, e3, &strip), Some(e2));
assert_eq!(get_window_in_direction(&east, e3, &strip), None);
assert_eq!(get_window_in_direction(&east, e0, &strip), Some(e2));
assert_eq!(get_window_in_direction(&west, e0, &strip), None);
}
#[test]
fn test_get_window_in_direction_stacked() {
let (_world, strip, entities) = setup_world_with_layout();
let e0 = entities[0];
let e1 = entities[1];
let north = Direction::North;
let south = Direction::South;
assert_eq!(get_window_in_direction(&south, e0, &strip), Some(e1));
assert_eq!(get_window_in_direction(&north, e0, &strip), None);
assert_eq!(get_window_in_direction(&north, e1, &strip), Some(e0));
assert_eq!(get_window_in_direction(&south, e1, &strip), None);
}
}