use bevy::app::{App, Plugin, Update};
use bevy::ecs::entity::Entity;
use bevy::ecs::message::MessageReader;
use bevy::ecs::query::{With, Without};
use bevy::ecs::schedule::IntoScheduleConfigs as _;
use bevy::ecs::system::{Commands, Local, Populated, Res, Single};
use bevy::math::IRect;
use bevy::time::Time;
use std::time::{Duration, Instant};
use tracing::{Level, instrument};
use crate::commands::{Command, Direction, Operation};
use crate::config::Config;
use crate::config::swipe::SwipeGestureDirection;
use crate::ecs::layout::{Column, LayoutStrip};
use crate::ecs::params::{ActiveDisplay, Windows};
use crate::ecs::{
ActiveWorkspaceMarker, ManualStripOffset, MissionControlActive, Position, Scrolling,
SendMessageTrigger,
};
use crate::errors::Result;
use bevy::ecs::schedule::common_conditions::on_message;
use crate::events::{Event, InputEvent};
use crate::manager::{Window, WindowManager};
use crate::platform::Modifiers;
use crate::util::round_px;
pub struct ScrollEventsPlugin;
const MAX_STEP_SECS: f64 = 1.0 / 30.0;
const MIN_STEP_SECS: f64 = 1.0 / 1000.0;
type ScrollingStrip<'w, 's> = Single<
'w,
's,
(
&'static LayoutStrip,
&'static mut Position,
&'static mut Scrolling,
),
(With<ActiveWorkspaceMarker>, Without<Window>),
>;
impl Plugin for ScrollEventsPlugin {
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,
(
vertical_swipe_gesture
.run_if(mission_control_inactive)
.run_if(on_message::<InputEvent>),
(
swipe_gesture
.run_if(mission_control_inactive)
.run_if(on_message::<InputEvent>),
apply_inertia,
apply_snap_force,
scrolling_integrator,
apply_scrolling_constraints,
swiping_timeout,
)
.chain(),
),
);
}
}
#[instrument(level = Level::TRACE, skip_all)]
fn swipe_gesture(
mut messages: MessageReader<InputEvent>,
active_display: ActiveDisplay,
mut active_workspace: Single<
(Entity, &Position, Option<&mut Scrolling>),
With<ActiveWorkspaceMarker>,
>,
time: Res<Time>,
config: Res<Config>,
mut commands: Commands,
) {
let swipe_sensitivity = config.swipe_sensitivity();
let mut total_delta = 0.0;
let mut gesture_delta = 0.0;
let mut touchpad_down = false;
let mut has_scroll_event = false;
let mut has_gesture_event = false;
const SCROLL_SCALE_UPPER: f64 = 0.15;
const SCROLL_SCALE_LOWER: f64 = 0.005;
const SCROLL_FULL_RANGE: f64 = 2.0;
let scroll_scale = SCROLL_SCALE_LOWER
+ ((SCROLL_SCALE_UPPER - SCROLL_SCALE_LOWER) / SCROLL_FULL_RANGE) * swipe_sensitivity;
for InputEvent(event) in messages.read() {
match event {
Event::TouchpadDown => {
touchpad_down = true;
total_delta = 0.0;
}
Event::Scroll { delta } => {
total_delta += *delta * scroll_scale;
has_scroll_event = true;
}
Event::Swipe { delta, fingers }
if config
.swipe_gesture_fingers()
.is_some_and(|fingers_configured| fingers_configured == *fingers) =>
{
total_delta += delta;
gesture_delta += delta;
has_scroll_event = true;
has_gesture_event = true;
}
_ => (),
}
}
if !touchpad_down && !has_scroll_event {
return;
}
let (entity, position, scrolling) = &mut *active_workspace;
if let Ok(mut entity_commands) = commands.get_entity(*entity) {
entity_commands.try_remove::<ManualStripOffset>();
}
if touchpad_down && let Some(scrolling) = scrolling.as_mut() {
scrolling.velocity = 0.0;
scrolling.is_user_swiping = true;
scrolling.last_event = time.elapsed();
}
if has_scroll_event {
let viewport_width = f64::from(active_display.bounds().width());
let direction_modifier = match config.swipe_gesture_direction() {
SwipeGestureDirection::Natural => -1.0,
SwipeGestureDirection::Reversed => 1.0,
};
let dt = time.delta_secs_f64().max(MIN_STEP_SECS);
let new_velocity = if has_gesture_event {
gesture_delta * swipe_sensitivity / dt
} else {
0.0
};
if let Some(scrolling) = scrolling.as_mut() {
scrolling.velocity = if has_gesture_event {
0.3 * new_velocity + 0.7 * scrolling.velocity
} else {
0.0
};
scrolling.is_user_swiping = true;
scrolling.last_event = time.elapsed();
scrolling.position +=
total_delta * viewport_width * direction_modifier * swipe_sensitivity;
} else if let Ok(mut entity_commands) = commands.get_entity(*entity) {
entity_commands.try_insert(Scrolling {
velocity: new_velocity,
position: f64::from(position.0.x)
+ total_delta * viewport_width * direction_modifier * swipe_sensitivity,
is_user_swiping: true,
last_event: time.elapsed(),
});
}
}
}
#[instrument(level = Level::TRACE, skip_all)]
pub(super) fn swiping_timeout(
strips: Populated<(Entity, &mut Scrolling), With<LayoutStrip>>,
active_display: ActiveDisplay,
time: Res<Time>,
window_manager: Res<WindowManager>,
mut commands: Commands,
) {
const FINGER_LIFT_THRESHOLD: Duration = Duration::from_millis(50);
const MIN_VELOCITY_PX: f64 = 5.0;
let dt = time.delta_secs_f64().min(MAX_STEP_SECS);
let viewport_width = f64::from(active_display.bounds().width());
for (entity, mut scroll) in strips {
if time.elapsed().abs_diff(scroll.last_event) > FINGER_LIFT_THRESHOLD {
scroll.is_user_swiping = false;
if scroll.velocity.abs() * dt * viewport_width < MIN_VELOCITY_PX
&& let Ok(mut entity_commands) = commands.get_entity(entity)
{
entity_commands.try_remove::<Scrolling>();
}
if let Some(point) = window_manager.cursor_position() {
commands.trigger(SendMessageTrigger(Event::MouseMoved {
point,
modifiers: Modifiers::empty(),
}));
}
}
}
}
#[instrument(level = Level::TRACE, skip_all)]
fn apply_inertia(
mut strips: Populated<(Entity, &mut Scrolling), With<LayoutStrip>>,
time: Res<Time>,
config: Res<Config>,
) {
let dt = time.delta_secs_f64();
for (_, mut scroll) in &mut strips {
if scroll.is_user_swiping {
continue;
}
if scroll.velocity.abs() > 0.001 {
let decay_rate = config.swipe_deceleration();
scroll.velocity *= (-decay_rate * dt).exp();
} else {
scroll.velocity = 0.0;
}
}
}
#[instrument(level = Level::TRACE, skip_all)]
fn apply_snap_force(
mut strip: Single<(&LayoutStrip, &Position, &mut Scrolling)>,
active_display: ActiveDisplay,
windows: Windows,
config: Res<Config>,
time: Res<Time>,
) {
const CENTER_MAGNETIC_FORCE: f64 = 10.0;
const SNAP_DISPLAY_RATIO: f64 = 0.45;
if !config.auto_center() {
return;
}
let viewport = active_display.actual_bounds(&config);
let viewport_center = viewport.center().x;
let snap_threshold = SNAP_DISPLAY_RATIO * f64::from(viewport.width());
let (strip, position, ref mut scroll) = *strip;
if scroll.is_user_swiping || scroll.velocity.abs() > 0.5 {
return;
}
let target_offset = strip
.all_columns()
.into_iter()
.filter_map(|entity| {
windows
.layout_position(entity)
.map(|p| p.0.x)
.zip(Some(entity))
})
.map(|(position, entity)| {
let col_width = windows.moving_frame(entity).map_or(0, |f| f.width());
viewport_center - (position + col_width / 2)
})
.min_by_key(|target| (position.x - target).abs())
.unwrap_or(position.x);
let dist_to_snap = f64::from(position.x - target_offset);
if dist_to_snap.abs() < snap_threshold {
let approach = (time.delta_secs_f64() * CENTER_MAGNETIC_FORCE).min(1.0);
scroll.position -= dist_to_snap * approach;
}
}
#[instrument(level = Level::TRACE, skip_all)]
fn scrolling_integrator(
mut strip: Single<&mut Scrolling, With<LayoutStrip>>,
time: Res<Time>,
active_display: ActiveDisplay,
config: Res<Config>,
) {
let dt = time.delta_secs_f64().min(MAX_STEP_SECS);
let viewport = active_display.actual_bounds(&config);
let viewport_width = f64::from(viewport.width());
let direction_modifier = match config.swipe_gesture_direction() {
SwipeGestureDirection::Natural => -1.0,
SwipeGestureDirection::Reversed => 1.0,
};
let scroll = &mut *strip;
if scroll.velocity.abs() > 0.0001 {
scroll.position += scroll.velocity * dt * viewport_width * direction_modifier;
}
}
#[instrument(level = Level::TRACE, skip_all)]
fn apply_scrolling_constraints(
mut strip: ScrollingStrip,
active_display: ActiveDisplay,
windows: Windows,
config: Res<Config>,
) {
let viewport = active_display.actual_bounds(&config);
let (strip, ref mut position, ref mut scroll) = *strip;
let get_window_frame = |entity| windows.moving_frame(entity);
if let Some(clamped_offset) = clamp_viewport_offset(
round_px(scroll.position),
strip,
&windows,
&get_window_frame,
&viewport,
&config,
) {
position.x = clamped_offset;
scroll.position = f64::from(clamped_offset);
} else {
scroll.velocity = 0.0;
}
}
#[instrument(level = Level::TRACE, skip_all)]
fn clamp_viewport_offset<W>(
current_offset: i32,
layout_strip: &LayoutStrip,
windows: &Windows,
get_window_frame: &W,
viewport: &IRect,
config: &Config,
) -> Option<i32>
where
W: Fn(Entity) -> Option<IRect>,
{
let total_strip_width = layout_strip
.last()
.ok()
.and_then(|column| column.top())
.and_then(|entity| {
windows
.layout_position(entity)
.zip(get_window_frame(entity))
})
.map(|(position, frame)| position.x + frame.width())?;
let continuous_swipe = config.continuous_swipe();
let strip_position = |column: Result<Column>| {
column
.ok()
.and_then(|column| column.top())
.and_then(|entity| windows.layout_position(entity))
.map(|position| position.0.x)
};
let left_snap = strip_position(layout_strip.last());
let right_snap = strip_position(layout_strip.first());
Some(
if continuous_swipe && let Some((left_snap, right_snap)) = left_snap.zip(right_snap) {
current_offset.clamp(viewport.min.x - left_snap, viewport.max.x - right_snap)
} else if viewport.width() < total_strip_width {
current_offset.clamp(viewport.max.x - total_strip_width, viewport.min.x)
} else {
current_offset.clamp(viewport.min.x, viewport.max.x - total_strip_width)
},
)
}
#[derive(Default)]
struct VerticalGestureState {
accumulated: f64,
last_event: Option<Instant>,
fired: bool,
}
#[instrument(level = Level::TRACE, skip_all)]
fn vertical_swipe_gesture(
mut messages: MessageReader<InputEvent>,
active_display: ActiveDisplay,
config: Res<Config>,
mut commands: Commands,
mut state: Local<VerticalGestureState>,
) {
const GESTURE_TIMEOUT: Duration = Duration::from_millis(150);
if active_display.fullscreen().is_some() {
return;
}
if let Some(last) = state.last_event
&& last.elapsed() > GESTURE_TIMEOUT
{
state.accumulated = 0.0;
state.fired = false;
}
for InputEvent(event) in messages.read() {
match event {
Event::VerticalScrollTick { delta } => {
switch_virtual_workspace(*delta, &config, &mut commands);
}
Event::VerticalSwipe { delta, fingers }
if config
.swipe_gesture_fingers()
.is_some_and(|fingers_configured| fingers_configured == *fingers) =>
{
state.last_event = Some(Instant::now());
if !state.fired {
state.accumulated += delta;
}
}
_ => {}
}
}
let threshold = 0.15 / config.swipe_sensitivity();
if state.accumulated.abs() >= threshold {
switch_virtual_workspace(state.accumulated, &config, &mut commands);
state.accumulated = 0.0;
state.fired = true;
}
}
fn switch_virtual_workspace(delta: f64, config: &Config, commands: &mut Commands) {
let physical_finger_direction = if delta > 0.0 {
Direction::South
} else {
Direction::North
};
let direction = match config.swipe_gesture_direction() {
SwipeGestureDirection::Natural => physical_finger_direction.reverse(),
SwipeGestureDirection::Reversed => physical_finger_direction,
};
commands.trigger(SendMessageTrigger(Event::Command {
command: Command::Window(Operation::Virtual(direction)),
}));
}